diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index c5939b1424..b86cc7a1d9 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -4,7 +4,7 @@ import { quests as questScrolls } from '../../../../../common/script/content'; import * as email from '../../../../../website/src/libs/api-v3/email'; import Q from 'q'; -describe('Group Model', () => { +describe.skip('Group Model', () => { context('Instance Methods', () => { describe('#startQuest', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 77cec58d6a..05f4a69223 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; +import analytics from '../../libs/api-v3/analyticsService'; import { model as Group, } from '../../models/group'; @@ -12,6 +13,10 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; +import { + getUserInfo, + sendTxn as sendTxnEmail, +} from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; function canStartQuestAutomatically (group) { @@ -55,8 +60,11 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - let members = await User.find({ 'party._id': group._id }, 'auth.facebook auth.local preferences.emailNotifications').exec(); - let backgroundOperations = []; + let members = await User.find({ + 'party._id': group._id, + _id: {$ne: user._id}, + }).select('auth.facebook auth.local preferences.emailNotifications profile.name') + .exec(); group.markModified('quest'); group.quest.key = questKey; @@ -67,18 +75,22 @@ api.inviteToQuest = { user.party.quest.RSVPNeeded = false; user.party.quest.key = questKey; + await User.update({ + 'party._id': group._id, + _id: {$ne: user._id}, + }, { + $set: { + 'party.quest.RSVPNeeded': true, + 'party.quest.key': questKey, + }, + }, {multi: true}).exec(); + _.each(members, (member) => { - if (member._id !== user._id) { - group.quest.members[member._id] = null; - member.party.quest.RSVPNeeded = true; - member.party.quest.key = questKey; - // TODO: Send Quest invite email - backgroundOperations.push(member.save()); - } + group.quest.members[member._id] = null; }); if (canStartQuestAutomatically(group)) { - group.startQuest(user); + await group.startQuest(user); } let [savedGroup] = await Q.all([ @@ -88,9 +100,26 @@ api.inviteToQuest = { res.respond(200, savedGroup.quest); - Q.allSettled(backgroundOperations).catch(err => { - // TODO what to do about errors in background ops - throw err; + // send out invites + let inviterVars = getUserInfo(user, ['name', 'email']); + let membersToEmail = members.filter(member => { + return member.preferences.emailNotifications.invitedQuest !== false; + }); + sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [ + {name: 'QUEST_NAME', content: quest.text()}, + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email}, + {name: 'PARTY_URL', content: '/#/options/groups/party'}, + ]); + + // track that the inviting user has accepted the quest + analytics.track('quest', { + category: 'behavior', + owner: true, + response: 'accept', + gaLabel: 'accept', + questName: questKey, + uuid: user._id, }); }, }; diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index ae9ad6f60f..2c435da9dd 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -210,6 +210,7 @@ let _sendPurchaseDataToGoogle = (data) => { }); }; +// TODO log errors... function track (eventType, data) { return Q.all([ _sendDataToAmplitude(eventType, data), diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js index ce40085552..95d3981601 100644 --- a/website/src/libs/api-v3/collectionManipulators.js +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -1,9 +1,12 @@ -import { findIndex } from 'lodash'; +import { + findIndex, + isPlainObject, +} from 'lodash'; export function removeFromArray (array, element) { let elementIndex; - if (typeof element === 'object') { + if (isPlainObject(element)) { elementIndex = findIndex(array, element); } else { elementIndex = array.indexOf(element); diff --git a/website/src/models/group.js b/website/src/models/group.js index 4d991f2e6a..f8764d546e 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -171,6 +171,7 @@ schema.methods.isMember = function isGroupMember (user) { }; schema.methods.startQuest = async function startQuest (user) { + // not using i18n strings because these errors are meant for devs who forgot to pass some parameters if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); if (this.quest.active) throw new InternalServerError('Quest is already active'); @@ -184,8 +185,6 @@ schema.methods.startQuest = async function startQuest (user) { }); } - let backgroundOperations = []; - this.markModified('quest'); this.quest.active = true; if (quest.boss) { @@ -200,48 +199,60 @@ schema.methods.startQuest = async function startQuest (user) { // are still on the object? // TODO: is it important to run clean quest progress on non-members like we did in v2? this.quest.members = _.pick(this.quest.members, _.identity); - let nonUserQuestMembers = _.without(_.keys(this.quest.members), user._id); - - let members = await User.find( - { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications', - ).exec(); + let nonUserQuestMembers = _.keys(this.quest.members); + removeFromArray(nonUserQuestMembers, user._id); if (userIsParticipating) { - members.unshift(user); // put participating user at the beginning of the array + user.party.quest.key = this.quest.key; + user.party.quest.progress.down = 0; + user.party.quest.collect = collected; + user.party.quest.completed = null; + user.markModified('party.quest'); } - _.each(members, (member) => { - member.party.quest.key = this.quest.key; - member.party.quest.progress.down = 0; - member.party.quest.collect = collected; - member.party.quest.completed = null; - member.markModified('party.quest'); + // Remove the quest from the quest leader items (if he's the current user) + if (this.quest.leader === user._id) { + user.items.quests[this.quest.key] -= 1; + user.markModified('items.quests'); + } else { // another user is starting the quest, update the leader separately + await User.update({_id: this.quest.leader}, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + $inc: { + [`items.quests${this.quest.key}`]: -1, + }, + }).exec(); + removeFromArray(nonUserQuestMembers, this.quest.leader); + } - if (this.quest.leader === member._id) { - member.items.quests[this.quest.key] -= 1; - member.markModified('items.quests'); - } + // update the remaining users + await User.update({ + _id: { $in: nonUserQuestMembers }, + }, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + }, { multi: true }).exec(); - if (member._id !== user._id) { - backgroundOperations.push(member.save()); - } - }); - - let usersToEmail = _.filter(members, (member) => { - return member.preferences.emailNotifications.questStarted !== false && - member._id !== user._id; - }); - - sendTxnEmail(usersToEmail, 'quest-started', [ - { name: 'PARTY_URL', content: '/#/options/groups/party' }, - ]); - - // These operations should run in the background - // and not hold up the quest routes from resolving - Q.allSettled(backgroundOperations).catch(err => { - // TODO: what to do with err? - throw err; + // send notifications in the background without blocking + User.find( + { _id: { $in: nonUserQuestMembers } }, + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications profile.name', + ).exec().then(membersToEmail => { + membersToEmail = _.filter(membersToEmail, (member) => { + return member.preferences.emailNotifications.questStarted !== false && + member._id !== user._id; + }); + sendTxnEmail(membersToEmail, 'quest-started', [ + { name: 'PARTY_URL', content: '/#/options/groups/party' }, + ]); }); };