',
- to: email,
- subject: res.t('passwordResetEmailSubject'),
- text: res.t('passwordResetEmailText', {
- username: user.auth.local.username,
- passwordResetLink: link,
- }),
- html: res.t('passwordResetEmailHtml', {
- username: user.auth.local.username,
- passwordResetLink: link,
- }),
- });
+ sendTxnEmail(user, 'reset-password', [
+ {name: 'PASSWORD_RESET_LINK', content: link},
+ ]);
await user.save();
}
@@ -372,7 +362,7 @@ api.updateEmail = {
};
/**
- * @api {post} /api/v3/user/auth/reset-password-set-new-one Reser Password Set New one
+ * @api {post} /api/v3/user/auth/reset-password-set-new-one Reset Password Set New one
* @apiDescription Set a new password for a user that reset theirs. Not meant for public usage.
* @apiName ResetPasswordSetNewOne
* @apiGroup User
diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js
index 4a70540d85..f0a60396a9 100644
--- a/website/server/controllers/api-v3/challenges.js
+++ b/website/server/controllers/api-v3/challenges.js
@@ -364,12 +364,14 @@ api.getUserChallenges = {
$and: [{$or: orOptions}],
};
- if (owned && owned === 'not_owned') {
- query.$and.push({leader: {$ne: user._id}});
- }
+ if (owned) {
+ if (owned === 'not_owned') {
+ query.$and.push({leader: {$ne: user._id}});
+ }
- if (owned && owned === 'owned') {
- query.$and.push({leader: user._id});
+ if (owned === 'owned') {
+ query.$and.push({leader: user._id});
+ }
}
if (req.query.search) {
@@ -400,7 +402,6 @@ api.getUserChallenges = {
// .populate('leader', nameFields)
const challenges = await mongoQuery.exec();
-
let resChals = challenges.map(challenge => challenge.toJSON());
resChals = _.orderBy(resChals, [challenge => {
@@ -424,11 +425,11 @@ api.getUserChallenges = {
/**
* @api {get} /api/v3/challenges/groups/:groupId Get challenges for a group
- * @apiDescription Get challenges that the user is a member, public challenges and the ones from the user's groups.
+ * @apiDescription Get challenges hosted in the specified group.
* @apiName GetGroupChallenges
* @apiGroup Challenge
*
- * @apiParam (Path) {UUID} groupId The group _id
+ * @apiParam (Path) {UUID} groupId The group id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Array} data An array of challenges sorted with official challenges first, followed by the challenges in order from newest to oldest
*
@@ -441,7 +442,8 @@ api.getGroupChallenges = {
method: 'GET',
url: '/challenges/groups/:groupId',
middlewares: [authWithHeaders({
- userFieldsToInclude: ['_id', 'party', 'guilds'],
+ // Some fields (including _id) are always loaded (see middlewares/auth)
+ userFieldsToInclude: ['party', 'guilds'], // Some fields are always loaded (see middlewares/auth)
})],
async handler (req, res) {
let user = res.locals.user;
@@ -460,7 +462,7 @@ api.getGroupChallenges = {
const challenges = await Challenge.find({ group: groupId })
.sort('-createdAt')
- // .populate('leader', nameFields) // Only populate the leader as the group is implicit
+ // .populate('leader', nameFields) // Only populate the leader as the group is implicit // see below why we're not using populate
.exec();
let resChals = challenges.map(challenge => challenge.toJSON());
diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js
index 84703ac0da..c2d6c787bc 100644
--- a/website/server/controllers/api-v3/chat.js
+++ b/website/server/controllers/api-v3/chat.js
@@ -186,7 +186,7 @@ api.postChat = {
if (client) {
client = client.replace('habitica-', '');
}
- const newChatMessage = group.sendChat(req.body.message, user, null, client);
+ const newChatMessage = group.sendChat({message: req.body.message, user, metaData: null, client});
let toSave = [newChatMessage.save()];
if (group.type === 'party') {
diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js
index eed4aff4cb..e214bda27b 100644
--- a/website/server/controllers/api-v3/groups.js
+++ b/website/server/controllers/api-v3/groups.js
@@ -82,7 +82,7 @@ let api = {};
* @apiError (401) {NotAuthorized} messageInsufficientGems User does not have enough gems (4)
* @apiError (401) {NotAuthorized} partyMustbePrivate Party must have privacy set to private
* @apiError (401) {NotAuthorized} messageGroupAlreadyInParty
- * @apiError (401) {NotAuthorized} cannotCreatePublicGuildWhenMuted You cannot create a public guild because your chat privileges have been revoked.
+ * @apiError (401) {NotAuthorized} chatPrivilegesRevoked You cannot do this because your chat privileges have been removed...
*
* @apiSuccess (201) {Object} data The created group (See /website/server/models/group.js)
*
@@ -117,7 +117,7 @@ api.createGroup = {
group.leader = user._id;
if (group.type === 'guild') {
- if (group.privacy === 'public' && user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotCreatePublicGuildWhenMuted'));
+ if (group.privacy === 'public' && user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems'));
group.balance = 1;
@@ -375,7 +375,8 @@ api.getGroup = {
method: 'GET',
url: '/groups/:groupId',
middlewares: [authWithHeaders({
- userFieldsToInclude: ['_id', 'party', 'guilds', 'contributor'],
+ // Some fields (including _id, preferences) are always loaded (see middlewares/auth)
+ userFieldsToInclude: ['party', 'guilds', 'contributor'],
})],
async handler (req, res) {
let user = res.locals.user;
@@ -1011,7 +1012,7 @@ api.inviteToGroup = {
async handler (req, res) {
const user = res.locals.user;
- if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted'));
+ if (user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js
index 5309b56e4f..a8ac1456f0 100644
--- a/website/server/controllers/api-v3/hall.js
+++ b/website/server/controllers/api-v3/hall.js
@@ -7,7 +7,10 @@ import {
import _ from 'lodash';
import apiError from '../../libs/apiError';
import validator from 'validator';
-import { validateItemPath } from '../../libs/items/utils';
+import {
+ validateItemPath,
+ castItemVal,
+} from '../../libs/items/utils';
let api = {};
@@ -271,7 +274,7 @@ api.updateHero = {
hero.markModified('items.pets');
}
if (updateData.itemPath && updateData.itemVal && validateItemPath(updateData.itemPath)) {
- _.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
+ _.set(hero, updateData.itemPath, castItemVal(updateData.itemPath, updateData.itemVal)); // Sanitization at 5c30944 (deemed unnecessary)
}
if (updateData.auth && updateData.auth.blocked === true) {
diff --git a/website/server/controllers/api-v3/inbox.js b/website/server/controllers/api-v3/inbox.js
index be32148a12..b806931a8d 100644
--- a/website/server/controllers/api-v3/inbox.js
+++ b/website/server/controllers/api-v3/inbox.js
@@ -12,6 +12,7 @@ let api = {};
* @apiDescription Get inbox messages for a user
*
* @apiParam (Query) {Number} page Load the messages of the selected Page - 10 Messages per Page
+ * @apiParam (Query) {GUID} conversation Loads only the messages of a conversation
*
* @apiSuccess {Array} data An array of inbox messages
*/
@@ -22,9 +23,10 @@ api.getInboxMessages = {
async handler (req, res) {
const user = res.locals.user;
const page = req.query.page;
+ const conversation = req.query.conversation;
const userInbox = await inboxLib.getUserInbox(user, {
- page,
+ page, conversation,
});
res.respond(200, userInbox);
diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js
index 1723c925b2..3623477205 100644
--- a/website/server/controllers/api-v3/members.js
+++ b/website/server/controllers/api-v3/members.js
@@ -20,6 +20,7 @@ import {
} from '../../libs/email';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import { achievements } from '../../../../website/common/';
+import {sentMessage} from '../../libs/inbox';
let api = {};
@@ -633,6 +634,7 @@ api.sendPrivateMessage = {
const sender = res.locals.user;
const message = req.body.message;
+
const receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
@@ -640,26 +642,7 @@ api.sendPrivateMessage = {
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));
- const messageSent = await sender.sendMessage(receiver, { receiverMsg: message });
-
- if (receiver.preferences.emailNotifications.newPM !== false) {
- sendTxnEmail(receiver, 'new-pm', [
- {name: 'SENDER', content: getUserInfo(sender, ['name']).name},
- ]);
- }
-
- if (receiver.preferences.pushNotifications.newPM !== false) {
- sendPushNotification(
- receiver,
- {
- title: res.t('newPM'),
- message: res.t('newPMInfo', {name: getUserInfo(sender, ['name']).name, message}),
- identifier: 'newPM',
- category: 'newPM',
- payload: {replyTo: sender._id},
- }
- );
- }
+ const messageSent = await sentMessage(sender, receiver, message, res.t);
res.respond(200, {message: messageSent});
},
diff --git a/website/server/controllers/api-v3/news.js b/website/server/controllers/api-v3/news.js
index ef0667ffa4..21b16e1702 100644
--- a/website/server/controllers/api-v3/news.js
+++ b/website/server/controllers/api-v3/news.js
@@ -3,7 +3,7 @@ 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 BLOGS: GUILD SPOTLIGHT AND WIKI SPOTLIGHT';
+const LAST_ANNOUNCEMENT_TITLE = 'JULY SUBSCRIBER ITEMS AND HABITICA BLOG POSTS';
const worldDmg = { // @TODO
bailey: false,
};
@@ -30,18 +30,25 @@ api.getNews = {
${res.t('newStuff')}
- 4/11/2019 - ${LAST_ANNOUNCEMENT_TITLE}
+ 7/25/2019 - ${LAST_ANNOUNCEMENT_TITLE}
-
- Guild Spotlight: More New and Notable Guilds!
- There's a new Guild Spotlight on the blog that highlights even more new and upcoming Guilds! Check it out now to find new communities where you can discuss your goals and interests.
- by shanaqui
-
- Blog Post: Burnout
- This month's featured Wiki article is about burnout! We hope that it will help you as you balance realistic expectations for productivity. Be sure to check it out, and let us know what you think by reaching out on Twitter, Tumblr, and Facebook.
+
+ July Subscriber Items Revealed!
+ The July Subscriber Items have been revealed: the extra-special 3-piece Beach Buddy Item Set! You only have until July 31 to receive the item set when you subscribe.
+ If you're already an active subscriber, reload the site or app and then head to Inventory > Items to claim your gear!
+ Subscribers also receive the ability to buy Gems for Gold -- the longer you subscribe, the more Gems you can buy per month! There are other perks as well, such as longer access to uncompressed data and a cute Jackalope pet. Best of all, subscriptions let us keep Habitica running. Thank you very much for your support -- it means a lot to us.
+ by Beffymaroo
+
+ Blog Posts: Healer
+ This month's featured Wiki article and Use Case Spotlight are about the Healer Class! We hope that they will help you as you choose the best class for your Habitica play style. Be sure to check them out, and let us know what you think by reaching out on Twitter, Tumblr, and Facebook.
+ Plus, we're collecting user submissions for the next spotlight! We want to hear your best tricks and strategies for playing the Rogue class to its full advantage. We’ll be featuring player-submitted examples in Use Case Spotlights on the Habitica Blog next month, so post your suggestions in the Use Case Spotlight Guild now. We look forward to learning more about how you use Habitica to improve your life and get things done!
by shanaqui and the Wiki Wizards
+
+ Guild Spotlight: More New and Notable Guilds!
+ There's a new Guild Spotlight on the blog that highlights more of the upcoming Guilds in Habitica dedicated to a variety of topics! Check it out now to find some of Habitica's best new communities.
+ by shanaqui
`,
});
diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js
index c8b2b25ebe..d0f96b8a80 100644
--- a/website/server/controllers/api-v3/quests.js
+++ b/website/server/controllers/api-v3/quests.js
@@ -371,7 +371,14 @@ api.cancelQuest = {
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
let questName = questScrolls[group.quest.key].text('en');
- const newChatMessage = group.sendChat(`\`${user.profile.name} cancelled the party quest ${questName}.\``);
+ const newChatMessage = group.sendChat({
+ message: `\`${user.profile.name} cancelled the party quest ${questName}.\``,
+ info: {
+ type: 'quest_cancel',
+ user: user.profile.name,
+ quest: group.quest.key,
+ },
+ });
group.quest = Group.cleanGroupQuest();
group.markModified('quest');
@@ -427,7 +434,14 @@ api.abortQuest = {
if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
let questName = questScrolls[group.quest.key].text('en');
- const newChatMessage = group.sendChat(`\`${user.profile.name} aborted the party quest ${questName}.\``);
+ const newChatMessage = group.sendChat({
+ message: `\`${common.i18n.t('chatQuestAborted', {username: user.profile.name, questName}, 'en')}\``,
+ info: {
+ type: 'quest_abort',
+ user: user.profile.name,
+ quest: group.quest.key,
+ },
+ });
await newChatMessage.save();
let memberUpdates = User.update({
diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js
index 524669d3a1..1d2b7ab747 100644
--- a/website/server/controllers/api-v3/tasks.js
+++ b/website/server/controllers/api-v3/tasks.js
@@ -244,7 +244,7 @@ api.createChallengeTasks = {
// If the challenge does not exist, or if it exists but user is not the leader -> throw error
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
- if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
let tasks = await createTasks(req, res, {user, challenge});
@@ -285,7 +285,8 @@ api.getUserTasks = {
method: 'GET',
url: '/tasks/user',
middlewares: [authWithHeaders({
- userFieldsToInclude: ['_id', 'tasksOrder', 'preferences'],
+ // Some fields (including _id, preferences) are always loaded (see middlewares/auth)
+ userFieldsToInclude: ['tasksOrder'],
})],
async handler (req, res) {
let types = Tasks.tasksTypes.map(type => `${type}s`);
@@ -453,7 +454,7 @@ api.updateTask = {
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
- if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
@@ -796,7 +797,7 @@ api.addChecklistItem = {
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
- if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
@@ -912,7 +913,7 @@ api.updateChecklistItem = {
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
- if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
@@ -977,7 +978,7 @@ api.removeChecklistItem = {
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
- if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
@@ -1297,7 +1298,7 @@ api.deleteTask = {
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
- if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
} else if (task.userId && task.challenge.id && !task.challenge.broken) {
diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js
index 69f050ad3d..aee836dafd 100644
--- a/website/server/controllers/api-v3/tasks/groups.js
+++ b/website/server/controllers/api-v3/tasks/groups.js
@@ -204,8 +204,23 @@ api.assignTask = {
// User is claiming the task
if (user._id === assignedUserId) {
let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text});
- const newMessage = group.sendChat(message);
+ const newMessage = group.sendChat({
+ message,
+ info: {
+ type: 'claim_task',
+ user: user.profile.name,
+ task: task.text,
+ },
+ });
promises.push(newMessage.save());
+ } else {
+ const taskText = task.text;
+ const managerName = user.profile.name;
+
+ assignedUser.addNotification('GROUP_TASK_ASSIGNED', {
+ message: res.t('youHaveBeenAssignedTask', {managerName, taskText}),
+ taskId: task._id,
+ });
}
promises.push(group.syncTask(task, assignedUser));
@@ -261,6 +276,15 @@ api.unassignTask = {
await group.unlinkTask(task, assignedUser);
+ let notificationIndex = assignedUser.notifications.findIndex(function findNotification (notification) {
+ return notification && notification.data && notification.type === 'GROUP_TASK_ASSIGNED' && notification.data.taskId === task._id;
+ });
+
+ if (notificationIndex !== -1) {
+ assignedUser.notifications.splice(notificationIndex, 1);
+ await assignedUser.save();
+ }
+
res.respond(200, task);
},
};
@@ -308,6 +332,9 @@ api.approveTask = {
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'));
+ }
task.group.approval.dateApproved = new Date();
task.group.approval.approvingUser = user._id;
diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js
index 839c7cd06f..e3e17571f2 100644
--- a/website/server/controllers/api-v3/user.js
+++ b/website/server/controllers/api-v3/user.js
@@ -1539,7 +1539,7 @@ api.userReset = {
};
/**
- * @api {post} /api/v3/user/custom-day-start Set preferences.dayStart for user
+ * @api {post} /api/v3/user/custom-day-start Set preferences.dayStart (Custom Day Start time) for user
* @apiName setCustomDayStart
* @apiGroup User
*
diff --git a/website/server/controllers/api-v4/inbox.js b/website/server/controllers/api-v4/inbox.js
index bfceab993c..bb2163ad3f 100644
--- a/website/server/controllers/api-v4/inbox.js
+++ b/website/server/controllers/api-v4/inbox.js
@@ -72,4 +72,53 @@ api.clearMessages = {
},
};
+/**
+ * @api {get} /api/v4/inbox/conversations Get the conversations for a user
+ * @apiName conversations
+ * @apiGroup Inbox
+ * @apiDescription Get the conversations for a user
+ *
+ * @apiSuccess {Array} data An array of inbox conversations
+ */
+api.conversations = {
+ method: 'GET',
+ middlewares: [authWithHeaders()],
+ url: '/inbox/conversations',
+ async handler (req, res) {
+ const user = res.locals.user;
+
+ const result = await inboxLib.listConversations(user);
+
+ res.respond(200, result);
+ },
+};
+
+/**
+ * @api {get} /api/v4/inbox/paged-messages Get inbox messages for a user
+ * @apiName GetInboxMessages
+ * @apiGroup Inbox
+ * @apiDescription Get inbox messages for a user. Entries already populated with the correct `sent` - information
+ *
+ * @apiParam (Query) {Number} page Load the messages of the selected Page - 10 Messages per Page
+ * @apiParam (Query) {GUID} conversation Loads only the messages of a conversation
+ *
+ * @apiSuccess {Array} data An array of inbox messages
+ */
+api.getInboxMessages = {
+ method: 'GET',
+ url: '/inbox/paged-messages',
+ middlewares: [authWithHeaders()],
+ async handler (req, res) {
+ const user = res.locals.user;
+ const page = req.query.page;
+ const conversation = req.query.conversation;
+
+ const userInbox = await inboxLib.getUserInbox(user, {
+ page, conversation, mapProps: true,
+ });
+
+ res.respond(200, userInbox);
+ },
+};
+
module.exports = api;
diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js
index e9f4448a3f..39333edead 100644
--- a/website/server/controllers/top-level/dataexport.js
+++ b/website/server/controllers/top-level/dataexport.js
@@ -8,7 +8,7 @@ import {
import _ from 'lodash';
import csvStringify from '../../libs/csvStringify';
import moment from 'moment';
-import js2xml from 'js2xmlparser';
+import * as js2xml from 'js2xmlparser';
import Pageres from 'pageres';
import nconf from 'nconf';
import got from 'got';
@@ -262,7 +262,7 @@ api.exportUserAvatarPng = {
return res.redirect(s3url);
}
- let [stream] = await new Pageres()
+ const pageBuffer = await new Pageres()
.src(`${BASE_URL}/export/avatar-${memberId}.html`, ['140x147'], {
crop: true,
filename: filename.replace('.png', ''),
@@ -276,7 +276,7 @@ api.exportUserAvatarPng = {
StorageClass: 'REDUCED_REDUNDANCY',
ContentType: 'image/png',
Expires: moment().add({minutes: 5}).toDate(),
- Body: stream,
+ Body: pageBuffer,
});
let s3res = await new Promise((resolve, reject) => {
diff --git a/website/server/libs/auth/index.js b/website/server/libs/auth/index.js
index 1a806ead3c..609f546ad8 100644
--- a/website/server/libs/auth/index.js
+++ b/website/server/libs/auth/index.js
@@ -184,8 +184,8 @@ async function registerLocal (req, res, { isV3 = false }) {
.remove({email: savedUser.auth.local.email})
.then(() => {
if (existingUser) return;
- if (savedUser._ABtests && savedUser._ABtests.welcomeEmailSplit) {
- sendTxnEmail(savedUser, savedUser._ABtests.welcomeEmailSplit);
+ if (newUser.registeredThrough === 'habitica-web') {
+ sendTxnEmail(savedUser, 'welcome-v2b');
} else {
sendTxnEmail(savedUser, 'welcome');
}
diff --git a/website/server/libs/bannedWords.js b/website/server/libs/bannedWords.js
index f341708710..5b0bd7b94e 100644
--- a/website/server/libs/bannedWords.js
+++ b/website/server/libs/bannedWords.js
@@ -91,6 +91,11 @@ let bannedWords = [
'buggery',
'buggering',
'buggered',
+ 'bullshit',
+ 'bullshiter',
+ 'bullshitter',
+ 'bullshiting',
+ 'bullshitting',
'shiz',
'shit',
'shitty',
diff --git a/website/server/libs/chat/group-chat.js b/website/server/libs/chat/group-chat.js
index 72edb4d52d..92123b6cf2 100644
--- a/website/server/libs/chat/group-chat.js
+++ b/website/server/libs/chat/group-chat.js
@@ -1,6 +1,10 @@
import { chatModel as Chat } from '../../models/message';
+import shared from '../../../common';
+import _ from 'lodash';
import { MAX_CHAT_COUNT, MAX_SUBBED_GROUP_CHAT_COUNT } from '../../models/group';
+const questScrolls = shared.content.quests;
+
// @TODO: Don't use this method when the group can be saved.
export async function getGroupChat (group) {
const maxChatCount = group.isSubscribed() ? MAX_SUBBED_GROUP_CHAT_COUNT : MAX_CHAT_COUNT;
@@ -22,3 +26,82 @@ export async function getGroupChat (group) {
return previous;
}, []);
}
+
+export function translateMessage (lang, info) {
+ let msg;
+ let foundText = '';
+ let spells = shared.content.spells;
+ let quests = shared.content.quests;
+
+ switch (info.type) {
+ case 'quest_start':
+ msg = `\`${shared.i18n.t('chatQuestStarted', {questName: questScrolls[info.quest].text(lang)}, lang)}\``;
+ break;
+
+ case 'boss_damage':
+ msg = `\`${shared.i18n.t('chatBossDamage', {username: info.user, bossName: questScrolls[info.quest].boss.name(lang), userDamage: info.userDamage, bossDamage: info.bossDamage}, lang)}\``;
+ break;
+
+ case 'boss_dont_attack':
+ msg = `\`${shared.i18n.t('chatBossDontAttack', {username: info.user, bossName: questScrolls[info.quest].boss.name(lang), userDamage: info.userDamage}, lang)}\``;
+ break;
+
+ case 'boss_rage':
+ msg = `\`${questScrolls[info.quest].boss.rage.effect(lang)}\``;
+ break;
+
+ case 'boss_defeated':
+ msg = `\`${shared.i18n.t('chatBossDefeated', {bossName: questScrolls[info.quest].boss.name(lang)}, lang)}\``;
+ break;
+
+ case 'user_found_items':
+ foundText = _.reduce(info.items, (m, v, k) => {
+ m.push(`${v} ${questScrolls[info.quest].collect[k].text(lang)}`);
+ return m;
+ }, []).join(', ');
+ msg = `\`${shared.i18n.t('chatFindItems', {username: info.user, items: foundText}, lang)}\``;
+ break;
+
+ case 'all_items_found':
+ msg = `\`${shared.i18n.t('chatItemQuestFinish', lang)}\``;
+ break;
+
+ case 'spell_cast_party':
+ msg = `\`${shared.i18n.t('chatCastSpellParty', {username: info.user, spell: spells[info.class][info.spell].text(lang)}, lang)}\``;
+ break;
+
+ case 'spell_cast_user':
+ msg = `\`${shared.i18n.t('chatCastSpellUser', {username: info.user, spell: spells[info.class][info.spell].text(lang), target: info.target}, lang)}\``;
+ break;
+
+ case 'quest_cancel':
+ msg = `\`${shared.i18n.t('chatQuestCancelled', {username: info.user, questName: questScrolls[info.quest].text(lang)}, lang)}\``;
+ break;
+
+ case 'quest_abort':
+ msg = `\`${shared.i18n.t('chatQuestAborted', {username: info.user, questName: questScrolls[info.quest].text(lang)}, lang)}\``;
+ break;
+
+ case 'tavern_quest_completed':
+ msg = `\`${quests[info.quest].completionChat(lang)}\``;
+ break;
+
+ case 'tavern_boss_rage_tired':
+ msg = `\`${shared.i18n.t('tavernBossTired', {rageName: quests[info.quest].boss.rage.title(lang), bossName: quests[info.quest].boss.name(lang)}, lang)}\``;
+ break;
+
+ case 'tavern_boss_rage':
+ msg = `\`${quests[info.quest].boss.rage[info.scene](lang)}\``;
+ break;
+
+ case 'tavern_boss_desperation':
+ msg = `\`${quests[info.quest].boss.desperation.text(lang)}\``;
+ break;
+
+ case 'claim_task':
+ msg = `${shared.i18n.t('userIsClamingTask', {username: info.user, task: info.task}, lang)}`;
+ break;
+ }
+
+ return msg;
+}
diff --git a/website/server/libs/email.js b/website/server/libs/email.js
index 42ec7387a4..02e236fe60 100644
--- a/website/server/libs/email.js
+++ b/website/server/libs/email.js
@@ -1,4 +1,3 @@
-import nodemailer from 'nodemailer';
import nconf from 'nconf';
import { TAVERN_ID } from '../models/group';
import { encrypt } from './encryption';
@@ -16,25 +15,11 @@ const EMAIL_SERVER = {
};
const BASE_URL = nconf.get('BASE_URL');
-let smtpTransporter = nodemailer.createTransport({
- service: nconf.get('SMTP_SERVICE'),
- auth: {
- user: nconf.get('SMTP_USER'),
- pass: nconf.get('SMTP_PASS'),
- },
-});
-
-// Send email directly from the server using the smtpTransporter,
-// used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them
-export function send (mailData) {
- return smtpTransporter.sendMail(mailData); // promise
-}
-
export function getUserInfo (user, fields = []) {
let info = {};
if (fields.indexOf('name') !== -1) {
- info.name = user.profile && user.profile.name;
+ info.name = user.auth && user.auth.local.username;
}
if (fields.indexOf('email') !== -1) {
diff --git a/website/server/libs/inbox/index.js b/website/server/libs/inbox/index.js
index c131d11fa4..dd9a4cb4b2 100644
--- a/website/server/libs/inbox/index.js
+++ b/website/server/libs/inbox/index.js
@@ -1,14 +1,52 @@
-import { inboxModel as Inbox } from '../../models/message';
+import {mapInboxMessage, inboxModel as Inbox} from '../../models/message';
+import orderBy from 'lodash/orderBy';
+import {getUserInfo, sendTxn as sendTxnEmail} from '../email';
+import {sendNotification as sendPushNotification} from '../pushNotifications';
const PM_PER_PAGE = 10;
-export async function getUserInbox (user, options = {asArray: true, page: 0}) {
+export async function sentMessage (sender, receiver, message, translate) {
+ const messageSent = await sender.sendMessage(receiver, { receiverMsg: message });
+
+ if (receiver.preferences.emailNotifications.newPM !== false) {
+ sendTxnEmail(receiver, 'new-pm', [
+ {name: 'SENDER', content: getUserInfo(sender, ['name']).name},
+ ]);
+ }
+
+ if (receiver.preferences.pushNotifications.newPM !== false) {
+ sendPushNotification(
+ receiver,
+ {
+ title: translate('newPM'),
+ message: translate('newPMInfo', {name: getUserInfo(sender, ['name']).name, message}),
+ identifier: 'newPM',
+ category: 'newPM',
+ payload: {replyTo: sender._id},
+ }
+ );
+ }
+
+ return messageSent;
+}
+
+export async function getUserInbox (user, options = {asArray: true, page: 0, conversation: null, mapProps: false}) {
if (typeof options.asArray === 'undefined') {
options.asArray = true;
}
+ if (typeof options.mapProps === 'undefined') {
+ options.mapProps = false;
+ }
+
+ const findObj = {ownerId: user._id};
+
+ if (options.conversation) {
+ findObj.uuid = options.conversation;
+ }
+
let query = Inbox
- .find({ownerId: user._id})
+ .find(findObj)
.sort({timestamp: -1});
if (typeof options.page !== 'undefined') {
@@ -17,7 +55,15 @@ export async function getUserInbox (user, options = {asArray: true, page: 0}) {
.skip(PM_PER_PAGE * Number(options.page));
}
- const messages = (await query.exec()).map(msg => msg.toJSON());
+ const messages = (await query.exec()).map(msg => {
+ const msgObj = msg.toJSON();
+
+ if (options.mapProps) {
+ mapInboxMessage(msgObj, user);
+ }
+
+ return msgObj;
+ });
if (options.asArray) {
return messages;
@@ -29,12 +75,42 @@ export async function getUserInbox (user, options = {asArray: true, page: 0}) {
}
}
+export async function listConversations (owner) {
+ let query = Inbox
+ .aggregate([
+ {
+ $match: {
+ ownerId: owner._id,
+ },
+ },
+ {
+ $group: {
+ _id: '$uuid',
+ user: {$first: '$user' },
+ username: {$first: '$username' },
+ timestamp: {$max: '$timestamp'}, // sort before group doesn't work - use the max value to sort it again after
+ },
+ },
+ ]);
+
+ const conversationsList = orderBy(await query.exec(), ['timestamp'], ['desc']);
+
+ const conversations = conversationsList.map(({_id, user, username, timestamp}) => ({
+ uuid: _id,
+ user,
+ username,
+ timestamp,
+ }));
+
+ return conversations;
+}
+
export async function getUserInboxMessage (user, messageId) {
return Inbox.findOne({ownerId: user._id, _id: messageId}).exec();
}
export async function deleteMessage (user, messageId) {
- const message = await Inbox.findOne({_id: messageId, ownerId: user._id }).exec();
+ const message = await Inbox.findOne({_id: messageId, ownerId: user._id}).exec();
if (!message) return false;
await Inbox.remove({_id: message._id, ownerId: user._id}).exec();
diff --git a/website/server/libs/items/utils.js b/website/server/libs/items/utils.js
index 0c0d9adf2f..308bfcc8a7 100644
--- a/website/server/libs/items/utils.js
+++ b/website/server/libs/items/utils.js
@@ -54,4 +54,24 @@ export function validateItemPath (itemPath) {
if (itemPath.indexOf('items.quests') === 0) {
return Boolean(shared.content.quests[key]);
}
+}
+
+// When passed a value of an item in the user object it'll convert the
+// value to the correct format.
+// Example a numeric string like "5" applied to a food item (expecting an interger)
+// will be converted to the number 5
+// TODO cast the correct value for `items.gear.owned`
+export function castItemVal (itemPath, itemVal) {
+ if (
+ itemPath.indexOf('items.pets') === 0 ||
+ itemPath.indexOf('items.eggs') === 0 ||
+ itemPath.indexOf('items.hatchingPotions') === 0 ||
+ itemPath.indexOf('items.food') === 0 ||
+ itemPath.indexOf('items.mounts') === 0 ||
+ itemPath.indexOf('items.quests') === 0
+ ) {
+ return Number(itemVal);
+ }
+
+ return itemVal;
}
\ No newline at end of file
diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js
index fe0119e09a..ba7305da8b 100644
--- a/website/server/libs/payments/subscriptions.js
+++ b/website/server/libs/payments/subscriptions.js
@@ -116,6 +116,7 @@ async function createSubscription (data) {
nextPaymentProcessing: data.nextPaymentProcessing,
nextBillingDate: data.nextBillingDate,
additionalData: data.additionalData,
+ lastReminderDate: null,
owner: data.user._id,
});
diff --git a/website/server/libs/spells.js b/website/server/libs/spells.js
index 2af8f7e8b7..8c46fcb1ab 100644
--- a/website/server/libs/spells.js
+++ b/website/server/libs/spells.js
@@ -11,7 +11,9 @@ import {
} from '../models/group';
import apiError from '../libs/apiError';
-const partyMembersFields = 'profile.name stats achievements items.special';
+const partyMembersFields = 'profile.name stats achievements items.special notifications flags';
+// Excluding notifications and flags from the list of public fields to return.
+const partyMembersPublicFields = 'profile.name stats achievements items.special';
// @TODO: After refactoring individual spells, move quantity to the calculations
@@ -78,8 +80,7 @@ async function castPartySpell (req, party, partyMembers, user, spell, quantity =
'party._id': party._id,
_id: { $ne: user._id }, // add separately
})
- // .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
+ .select(partyMembersFields)
.exec();
partyMembers.unshift(user);
@@ -101,8 +102,7 @@ async function castUserSpell (res, req, party, partyMembers, targetId, user, spe
if (!party) throw new NotFound(res.t('partyNotFound'));
partyMembers = await User
.findOne({_id: targetId, 'party._id': party._id})
- // .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
+ .select(partyMembersFields)
.exec();
}
@@ -183,9 +183,9 @@ async function castSpell (req, res, {isV3 = false}) {
let partyMembersRes = Array.isArray(partyMembers) ? partyMembers : [partyMembers];
// Only return some fields.
- // See comment above on why we can't just select the necessary fields when querying
+ // We can't just return the selected fields because they're private
partyMembersRes = partyMembersRes.map(partyMember => {
- return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersFields));
+ return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersPublicFields));
});
let userToJson = user;
@@ -197,9 +197,30 @@ async function castSpell (req, res, {isV3 = false}) {
});
if (party && !spell.silent) {
- let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``;
- const newChatMessage = party.sendChat(message);
- await newChatMessage.save();
+ if (targetType === 'user') {
+ const newChatMessage = party.sendChat({
+ message: `\`${common.i18n.t('chatCastSpellUser', {username: user.profile.name, spell: spell.text(), target: partyMembers.profile.name}, 'en')}\``,
+ info: {
+ type: 'spell_cast_user',
+ user: user.profile.name,
+ class: klass,
+ spell: spellId,
+ target: partyMembers.profile.name,
+ },
+ });
+ await newChatMessage.save();
+ } else {
+ const newChatMessage = party.sendChat({
+ message: `\`${common.i18n.t('chatCastSpellParty', {username: user.profile.name, spell: spell.text()}, 'en')}\``,
+ info: {
+ type: 'spell_cast_party',
+ user: user.profile.name,
+ class: klass,
+ spell: spellId,
+ },
+ });
+ await newChatMessage.save();
+ }
}
}
}
diff --git a/website/server/middlewares/auth.js b/website/server/middlewares/auth.js
index f4c301acf9..c0881008af 100644
--- a/website/server/middlewares/auth.js
+++ b/website/server/middlewares/auth.js
@@ -9,22 +9,27 @@ import url from 'url';
import gcpStackdriverTracer from '../libs/gcpTraceAgent';
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL');
+const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags'];
function getUserFields (options, req) {
// A list of user fields that aren't needed for the route and are not loaded from the db.
// Must be an array
if (options.userFieldsToExclude) {
- return options.userFieldsToExclude.map(field => {
- return `-${field}`; // -${field} means exclude ${field} in mongodb
- }).join(' ');
+ return options.userFieldsToExclude
+ .filter(field => {
+ return !USER_FIELDS_ALWAYS_LOADED.find(fieldToInclude => field.startsWith(fieldToInclude));
+ })
+ .map(field => {
+ return `-${field}`; // -${field} means exclude ${field} in mongodb
+ })
+ .join(' ');
}
if (options.userFieldsToInclude) {
- return options.userFieldsToInclude.join(' ');
+ return options.userFieldsToInclude.concat(USER_FIELDS_ALWAYS_LOADED).join(' ');
}
// Allows GET requests to /user to specify a list of user fields to return instead of the entire doc
- // Notifications are always included
const urlPath = url.parse(req.url).pathname;
const userFields = req.query.userFields;
if (!userFields || urlPath !== '/user') return '';
@@ -32,7 +37,7 @@ function getUserFields (options, req) {
const userFieldOptions = userFields.split(',');
if (userFieldOptions.length === 0) return '';
- return `notifications ${userFieldOptions.join(' ')}`;
+ return userFieldOptions.concat(USER_FIELDS_ALWAYS_LOADED).join(' ');
}
// Make sure stackdriver traces are storing the user id
diff --git a/website/server/middlewares/index.js b/website/server/middlewares/index.js
index 0ca550f7c4..20f7e28a48 100644
--- a/website/server/middlewares/index.js
+++ b/website/server/middlewares/index.js
@@ -79,7 +79,12 @@ module.exports = function attachMiddlewares (app, server) {
// The site can require basic HTTP authentication to be accessed
if (ENABLE_HTTP_AUTH) {
const httpBasicAuthUsers = {};
- httpBasicAuthUsers[nconf.get('SITE_HTTP_AUTH_USERNAME')] = nconf.get('SITE_HTTP_AUTH_PASSWORD');
+ const usernames = nconf.get('SITE_HTTP_AUTH_USERNAMES').split(',');
+ const passwords = nconf.get('SITE_HTTP_AUTH_PASSWORDS').split(',');
+
+ usernames.forEach((user, index) => {
+ httpBasicAuthUsers[user] = passwords[index];
+ });
app.use(basicAuth({
users: httpBasicAuthUsers,
diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js
index 492b0592dd..62ab8716a0 100644
--- a/website/server/models/challenge.js
+++ b/website/server/models/challenge.js
@@ -62,7 +62,7 @@ schema.pre('init', function ensureSummaryIsFetched (chal) {
});
// A list of additional fields that cannot be updated (but can be set on creation)
-let noUpdate = ['group', 'official', 'shortName', 'prize'];
+let noUpdate = ['group', 'leader', 'official', 'shortName', 'prize'];
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
return this.sanitize(updateObj, noUpdate);
};
diff --git a/website/server/models/group.js b/website/server/models/group.js
index 53f66e9a3a..5bb6da71f3 100644
--- a/website/server/models/group.js
+++ b/website/server/models/group.js
@@ -37,10 +37,11 @@ import {
} from './subscriptionPlan';
import amazonPayments from '../libs/payments/amazon';
import stripePayments from '../libs/payments/stripe';
-import { getGroupChat } from '../libs/chat/group-chat';
+import { getGroupChat, translateMessage } from '../libs/chat/group-chat';
import { model as UserNotification } from './userNotification';
const questScrolls = shared.content.quests;
+const questSeriesAchievements = shared.content.questSeriesAchievements;
const Schema = mongoose.Schema;
export const INVITES_LIMIT = 100; // must not be greater than MAX_EMAIL_INVITES_BY_USER
@@ -344,26 +345,38 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
await getGroupChat(group);
}
- let toJSON = group.toJSON();
+ const groupToJson = group.toJSON();
+ const userLang = user.preferences.language;
- if (!user.contributor.admin) {
- _.remove(toJSON.chat, chatMsg => {
- chatMsg.flags = {};
- if (chatMsg._meta) chatMsg._meta = undefined;
- return user._id !== chatMsg.uuid && chatMsg.flagCount >= 2;
- });
- }
+ groupToJson.chat = groupToJson.chat
+ .map(chatMsg => {
+ // Translate system messages
+ if (!_.isEmpty(chatMsg.info)) {
+ chatMsg.text = translateMessage(userLang, chatMsg.info);
+ }
- // Convert to timestamps because Android expects it
- toJSON.chat.forEach(chat => {
- // old chats are saved with a numeric timestamp
- // new chats use `Date` which then has to be converted to the numeric timestamp
- if (chat.timestamp && chat.timestamp.getTime) {
- chat.timestamp = chat.timestamp.getTime();
- }
- });
+ // Convert to timestamps because Android expects it
+ // old chats are saved with a numeric timestamp
+ // new chats use `Date` which then has to be converted to the numeric timestamp
+ if (chatMsg.timestamp && chatMsg.timestamp.getTime) {
+ chatMsg.timestamp = chatMsg.timestamp.getTime();
+ }
- return toJSON;
+ if (!user.contributor.admin) {
+ // Flags are hidden to non admins
+ chatMsg.flags = {};
+ if (chatMsg._meta) chatMsg._meta = undefined;
+
+ // Messages with >= 2 flags are hidden to non admins and non authors
+ if (user._id !== chatMsg.uuid && chatMsg.flagCount >= 2) return undefined;
+ }
+
+ return chatMsg;
+ })
+ // Used to filter for undefined chat messages that should not be shown to non-admins
+ .filter(chatMsg => chatMsg !== undefined);
+
+ return groupToJson;
};
function getInviteError (uuids, emails, usernames) {
@@ -496,8 +509,9 @@ schema.methods.getMemberCount = async function getMemberCount () {
return await User.count(query).exec();
};
-schema.methods.sendChat = function sendChat (message, user, metaData, client) {
- let newMessage = messageDefaults(message, user, client);
+schema.methods.sendChat = function sendChat (options = {}) {
+ const {message, user, metaData, client, info = {}} = options;
+ let newMessage = messageDefaults(message, user, client, info);
let newChatMessage = new Chat();
newChatMessage = Object.assign(newChatMessage, newMessage);
newChatMessage.groupId = this._id;
@@ -653,8 +667,15 @@ schema.methods.startQuest = async function startQuest (user) {
}, _cleanQuestParty(),
{ multi: true }).exec();
- const newMessage = this.sendChat(`\`Your quest, ${quest.text('en')}, has started.\``, null, {
- participatingMembers: this.getParticipatingQuestMembers().join(', '),
+ const newMessage = this.sendChat({
+ message: `\`${shared.i18n.t('chatQuestStarted', {questName: quest.text('en')}, 'en')}\``,
+ metaData: {
+ participatingMembers: this.getParticipatingQuestMembers().join(', '),
+ },
+ info: {
+ type: 'quest_start',
+ quest: quest.key,
+ },
});
await newMessage.save();
@@ -829,25 +850,6 @@ schema.methods.finishQuest = async function finishQuest (quest) {
}
});
- let masterClasserQuests = [
- 'dilatoryDistress1',
- 'dilatoryDistress2',
- 'dilatoryDistress3',
- 'mayhemMistiflying1',
- 'mayhemMistiflying2',
- 'mayhemMistiflying3',
- 'stoikalmCalamity1',
- 'stoikalmCalamity2',
- 'stoikalmCalamity3',
- 'taskwoodsTerror1',
- 'taskwoodsTerror2',
- 'taskwoodsTerror3',
- 'lostMasterclasser1',
- 'lostMasterclasser2',
- 'lostMasterclasser3',
- 'lostMasterclasser4',
- ];
-
// Send webhooks in background
// @TODO move the find users part to a worker as well, not just the http request
User.find({
@@ -873,24 +875,39 @@ schema.methods.finishQuest = async function finishQuest (quest) {
});
});
+ _.forEach(questSeriesAchievements, (questList, achievement) => {
+ if (questList.includes(questK)) {
+ let questAchievementQuery = {};
+ questAchievementQuery[`achievements.${achievement}`] = {$ne: true};
+
+ _.forEach(questList, (questName) => {
+ if (questName !== questK) {
+ questAchievementQuery[`achievements.quests.${questName}`] = {$gt: 0};
+ }
+ });
+
+ let questAchievementUpdate = {$set: {}, $push: {}};
+ questAchievementUpdate.$set[`achievements.${achievement}`] = true;
+ const achievementTitleCase = `${achievement.slice(0, 1).toUpperCase()}${achievement.slice(1, achievement.length)}`;
+ const achievementSnakeCase = `ACHIEVEMENT_${_.snakeCase(achievement).toUpperCase()}`;
+ questAchievementUpdate.$push = {
+ notifications: new UserNotification({
+ type: achievementSnakeCase,
+ data: {
+ achievement,
+ message: `${shared.i18n.t('modalAchievement')} ${shared.i18n.t(`achievement${achievementTitleCase}`)}`,
+ modalText: shared.i18n.t(`achievement${achievementTitleCase}ModalText`),
+ },
+ }).toObject(),
+ };
+
+ promises.push(participants.map(userId => {
+ return _updateUserWithRetries(userId, questAchievementUpdate, null, questAchievementQuery);
+ }));
+ }
+ });
+
await Promise.all(promises);
-
- if (masterClasserQuests.includes(questK)) {
- let lostMasterclasserQuery = {
- 'achievements.lostMasterclasser': {$ne: true},
- };
- masterClasserQuests.forEach(questName => {
- lostMasterclasserQuery[`achievements.quests.${questName}`] = {$gt: 0};
- });
- let lostMasterclasserUpdate = {
- $set: {'achievements.lostMasterclasser': true},
- };
-
- let lostMasterClasserPromises = participants.map(userId => {
- return _updateUserWithRetries(userId, lostMasterclasserUpdate, null, lostMasterclasserQuery);
- });
- await Promise.all(lostMasterClasserPromises);
- }
};
function _isOnQuest (user, progress, group) {
@@ -913,18 +930,42 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
const promises = [];
group.quest.progress.hp -= progress.up;
- // TODO Create a party preferred language option so emits like this can be localized. Suggestion: Always display the English version too. Or, if English is not displayed to the players, at least include it in a new field in the chat object that's visible in the database - essential for admins when troubleshooting quests!
- let playerAttack = `${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.`;
- let bossAttack = CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE ? `${quest.boss.name('en')} does not attack, because it respects the fact that there are some bugs\` \`post-maintenance and it doesn't want to hurt anyone unfairly. It will continue its rampage soon!` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`;
- // TODO Consider putting the safe mode boss attack message in an ENV var
- const groupMessage = group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``);
- promises.push(groupMessage.save());
+ if (CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE) {
+ const groupMessage = group.sendChat({
+ message: `\`${shared.i18n.t('chatBossDontAttack', {bossName: quest.boss.name('en')}, 'en')}\``,
+ info: {
+ type: 'boss_dont_attack',
+ user: user.profile.name,
+ quest: group.quest.key,
+ userDamage: progress.up.toFixed(1),
+ },
+ });
+ promises.push(groupMessage.save());
+ } else {
+ const groupMessage = group.sendChat({
+ message: `\`${shared.i18n.t('chatBossDamage', {username: user.profile.name, bossName: quest.boss.name('en'), userDamage: progress.up.toFixed(1), bossDamage: Math.abs(down).toFixed(1)}, user.preferences.language)}\``,
+ info: {
+ type: 'boss_damage',
+ user: user.profile.name,
+ quest: group.quest.key,
+ userDamage: progress.up.toFixed(1),
+ bossDamage: Math.abs(down).toFixed(1),
+ },
+ });
+ promises.push(groupMessage.save());
+ }
// If boss has Rage, increment Rage as well
if (quest.boss.rage) {
group.quest.progress.rage += Math.abs(down);
if (group.quest.progress.rage >= quest.boss.rage.value) {
- const rageMessage = group.sendChat(quest.boss.rage.effect('en'));
+ const rageMessage = group.sendChat({
+ message: quest.boss.rage.effect('en'),
+ info: {
+ type: 'boss_rage',
+ quest: quest.key,
+ },
+ });
promises.push(rageMessage.save());
group.quest.progress.rage = 0;
@@ -952,7 +993,13 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
// Boss slain, finish quest
if (group.quest.progress.hp <= 0) {
- const questFinishChat = group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``);
+ const questFinishChat = group.sendChat({
+ message: `\`${shared.i18n.t('chatBossDefeated', {bossName: quest.boss.name('en')}, 'en')}\``,
+ info: {
+ type: 'boss_defeated',
+ quest: quest.key,
+ },
+ });
promises.push(questFinishChat.save());
// Participants: Grant rewards & achievements, finish quest
@@ -1005,7 +1052,15 @@ schema.methods._processCollectionQuest = async function processCollectionQuest (
}, []);
foundText = foundText.join(', ');
- const foundChat = group.sendChat(`\`${user.profile.name} found ${foundText}.\``);
+ const foundChat = group.sendChat({
+ message: `\`${shared.i18n.t('chatFindItems', {username: user.profile.name, items: foundText}, 'en')}\``,
+ info: {
+ type: 'user_found_items',
+ user: user.profile.name,
+ quest: quest.key,
+ items: itemsFound,
+ },
+ });
group.markModified('quest.progress.collect');
// Still needs completing
@@ -1018,7 +1073,12 @@ schema.methods._processCollectionQuest = async function processCollectionQuest (
}
await group.finishQuest(quest);
- const allItemsFoundChat = group.sendChat('`All items found! Party has received their rewards.`');
+ const allItemsFoundChat = group.sendChat({
+ message: `\`${shared.i18n.t('chatItemQuestFinish', 'en')}\``,
+ info: {
+ type: 'all_items_found',
+ },
+ });
const promises = [group.save(), foundChat.save(), allItemsFoundChat.save()];
@@ -1082,7 +1142,13 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
const chatPromises = [];
if (tavern.quest.progress.hp <= 0) {
- const completeChat = tavern.sendChat(quest.completionChat('en'));
+ const completeChat = tavern.sendChat({
+ message: quest.completionChat('en'),
+ info: {
+ type: 'tavern_quest_completed',
+ quest: quest.key,
+ },
+ });
chatPromises.push(completeChat.save());
await tavern.finishQuest(quest);
_.assign(tavernQuest, {extra: null});
@@ -1111,11 +1177,24 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
}
if (!scene) {
- const tiredChat = tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``);
+ const tiredChat = tavern.sendChat({
+ message: `\`${shared.i18n.t('tavernBossTired', {rageName: quest.boss.rage.title('en'), bossName: quest.boss.name('en')}, 'en')}\``,
+ info: {
+ type: 'tavern_boss_rage_tired',
+ quest: quest.key,
+ },
+ });
chatPromises.push(tiredChat.save());
tavern.quest.progress.rage = 0; // quest.boss.rage.value;
} else {
- const rageChat = tavern.sendChat(quest.boss.rage[scene]('en'));
+ const rageChat = tavern.sendChat({
+ message: quest.boss.rage[scene]('en'),
+ info: {
+ type: 'tavern_boss_rage',
+ quest: quest.key,
+ scene,
+ },
+ });
chatPromises.push(rageChat.save());
tavern.quest.extra.worldDmg[scene] = true;
tavern.markModified('quest.extra.worldDmg');
@@ -1127,7 +1206,13 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
}
if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) {
- const progressChat = tavern.sendChat(quest.boss.desperation.text('en'));
+ const progressChat = tavern.sendChat({
+ message: quest.boss.desperation.text('en'),
+ info: {
+ type: 'tavern_boss_desperation',
+ quest: quest.key,
+ },
+ });
chatPromises.push(progressChat.save());
tavern.quest.extra.desperate = true;
tavern.quest.extra.def = quest.boss.desperation.def;
@@ -1341,7 +1426,7 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
matchingTask.group.id = taskToSync.group.id;
matchingTask.userId = user._id;
matchingTask.group.taskId = taskToSync._id;
- user.tasksOrder[`${taskToSync.type}s`].push(matchingTask._id);
+ user.tasksOrder[`${taskToSync.type}s`].unshift(matchingTask._id);
} else {
_.merge(matchingTask, syncableAttrs(taskToSync));
// Make sure the task is in user.tasksOrder
@@ -1419,9 +1504,29 @@ schema.methods.removeTask = async function groupRemoveTask (task) {
$set: {'group.broken': 'TASK_DELETED'},
}, {multi: true}).exec();
+ // 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
+
+ // Remove old notifications
+ let removalPromises = [];
+ managers.forEach((manager) => {
+ let notificationIndex = manager.notifications.findIndex(function findNotification (notification) {
+ return notification && notification.data && notification.data.groupTaskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
+ });
+
+ if (notificationIndex !== -1) {
+ manager.notifications.splice(notificationIndex, 1);
+ removalPromises.push(manager.save());
+ }
+ });
+
removeFromArray(group.tasksOrder[`${task.type}s`], task._id);
group.markModified('tasksOrder');
- return await group.save();
+ removalPromises.push(group.save());
+
+ return await Promise.all(removalPromises);
};
// Returns true if the user has reached the spam message limit
diff --git a/website/server/models/message.js b/website/server/models/message.js
index f93d90ccb6..0f1dff93a4 100644
--- a/website/server/models/message.js
+++ b/website/server/models/message.js
@@ -7,6 +7,7 @@ const defaultSchema = () => ({
id: String,
timestamp: Date,
text: String,
+ info: {$type: mongoose.Schema.Types.Mixed},
// sender properties
user: String, // profile name (unfortunately)
@@ -97,16 +98,23 @@ export function setUserStyles (newMessage, user) {
}
}
+ let contributorCopy = user.contributor;
+ if (contributorCopy && contributorCopy.toObject) {
+ contributorCopy = contributorCopy.toObject();
+ }
+
+ newMessage.contributor = contributorCopy;
newMessage.userStyles = userStyles;
- newMessage.markModified('userStyles');
+ newMessage.markModified('userStyles contributor');
}
-export function messageDefaults (msg, user, client) {
+export function messageDefaults (msg, user, client, info = {}) {
const id = uuid();
const message = {
id,
_id: id,
text: msg.substring(0, 3000),
+ info,
timestamp: Number(new Date()),
likes: {},
flags: {},
@@ -128,3 +136,20 @@ export function messageDefaults (msg, user, client) {
return message;
}
+
+export function mapInboxMessage (msg, user) {
+ if (msg.sent) {
+ msg.toUUID = msg.uuid;
+ msg.toUser = msg.user;
+ msg.toUserName = msg.username;
+ msg.toUserContributor = msg.contributor;
+ msg.toUserBacker = msg.backer;
+ msg.uuid = user._id;
+ msg.user = user.profile.name;
+ msg.username = user.auth.local.username;
+ msg.contributor = user.contributor;
+ msg.backer = user.backer;
+ }
+
+ return msg;
+}
diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js
index c26f03bd6a..aaf2cbf148 100644
--- a/website/server/models/subscriptionPlan.js
+++ b/website/server/models/subscriptionPlan.js
@@ -15,6 +15,7 @@ export let schema = new mongoose.Schema({
extraMonths: {$type: Number, default: 0},
gemsBought: {$type: Number, default: 0},
mysteryItems: {$type: Array, default: () => []},
+ lastReminderDate: Date, // indicates the last time a subscription reminder was sent
lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date
additionalData: mongoose.Schema.Types.Mixed, // Example for Google: {'receipt': 'serialized receipt json', 'signature': 'signature string'}
nextPaymentProcessing: Date, // indicates when the queue server should process this subscription again.
diff --git a/website/server/models/task.js b/website/server/models/task.js
index 1858cd9623..db7a05e867 100644
--- a/website/server/models/task.js
+++ b/website/server/models/task.js
@@ -113,7 +113,7 @@ export let TaskSchema = new Schema({
requested: {$type: Boolean, default: false},
requestedDate: {$type: Date},
},
- sharedCompletion: {$type: String, enum: _.values(SHARED_COMPLETION), default: SHARED_COMPLETION.default},
+ sharedCompletion: {$type: String, enum: _.values(SHARED_COMPLETION), default: SHARED_COMPLETION.single},
},
reminders: [{
diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js
index ed3a7514b5..d5c6a3808d 100644
--- a/website/server/models/user/hooks.js
+++ b/website/server/models/user/hooks.js
@@ -127,17 +127,12 @@ function _setUpNewUser (user) {
user.items.quests.dustbunnies = 1;
user.markModified('items.quests');
+ user.items.mounts['Orca-Base'] = true;
+ user.markModified('items.mounts');
+
user.purchased.background.violet = true;
user.preferences.background = 'violet';
- const testGroup = Math.random();
-
- if (testGroup < 0.5) {
- user._ABtests.welcomeEmailSplit = 'welcome-v2b';
- } else {
- user._ABtests.welcomeEmailSplit = 'welcome';
- }
-
if (user.registeredThrough === 'habitica-web') {
taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag'];
diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js
index e411bfb437..0119969c12 100644
--- a/website/server/models/user/schema.js
+++ b/website/server/models/user/schema.js
@@ -120,6 +120,10 @@ let schema = new Schema({
joinedChallenge: Boolean,
invitedFriend: Boolean,
lostMasterclasser: Boolean,
+ mindOverMatter: Boolean,
+ justAddWater: Boolean,
+ backToBasics: Boolean,
+ allYourBase: Boolean,
},
backer: {
@@ -221,6 +225,7 @@ let schema = new Schema({
classSelected: {$type: Boolean, default: false},
mathUpdates: Boolean,
rebirthEnabled: {$type: Boolean, default: false},
+ lastFreeRebirth: Date,
levelDrops: {$type: Schema.Types.Mixed, default: () => {
return {};
}},
@@ -481,6 +486,7 @@ let schema = new Schema({
weeklyRecaps: {$type: Boolean, default: true},
onboarding: {$type: Boolean, default: true},
majorUpdates: {$type: Boolean, default: true},
+ subscriptionReminders: {$type: Boolean, default: true},
},
pushNotifications: {
unsubscribeFromAll: {$type: Boolean, default: false},
diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js
index acb280b16a..27d29eeb9c 100644
--- a/website/server/models/userNotification.js
+++ b/website/server/models/userNotification.js
@@ -15,6 +15,7 @@ const NOTIFICATION_TYPES = [
'CRON',
'GROUP_TASK_APPROVAL',
'GROUP_TASK_APPROVED',
+ 'GROUP_TASK_ASSIGNED',
'GROUP_TASK_NEEDS_WORK',
'LOGIN_INCENTIVE',
'GROUP_INVITE_ACCEPTED',
@@ -31,6 +32,11 @@ const NOTIFICATION_TYPES = [
'NEW_STUFF',
'NEW_CHAT_MESSAGE',
'LEVELED_UP',
+ 'ACHIEVEMENT_ALL_YOUR_BASE',
+ 'ACHIEVEMENT_BACK_TO_BASICS',
+ 'ACHIEVEMENT_JUST_ADD_WATER',
+ 'ACHIEVEMENT_LOST_MASTERCLASSER',
+ 'ACHIEVEMENT_MIND_OVER_MATTER',
];
const Schema = mongoose.Schema;