diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 1f77e456aa..c934ad7446 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,7 +37,7 @@ "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", - "canOnlyInviteEmailUuid": "Can only invite using uuids or emails but not both at the same time.", + "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", @@ -47,5 +47,13 @@ "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", - "partyMustbePrivate": "Parties must be private" + "partyMustbePrivate": "Parties must be private", + "userAlreadyInGroup": "User already in that group.", + "userAlreadyInvitedToGroup": "User already invited to that group.", + "userAlreadyPendingInvitation": "User already pending invitation.", + "userAlreadyInAParty": "User already in a party.", + "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", + "emailsMustBeAnArray": "Email invites must be a an Array.", + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time" } diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js new file mode 100644 index 0000000000..2466bdf649 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -0,0 +1,310 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +const INVITES_LIMIT = 100; + +describe('Post /groups/:groupId/invite', () => { + let inviter; + let group; + let groupName = 'Test Public Guild'; + + beforeEach(async () => { + inviter = await generateUser({balance: 1}); + group = await inviter.post('/groups', { + name: groupName, + type: 'guild', + }); + }); + + describe('user id invites', () => { + it('returns an error when invited user is not found', async () => { + let fakeID = generateUUID(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: fakeID}), + }); + }); + + it('returns an error when uuids is not an array', async () => { + let fakeID = generateUUID(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: {fakeID}, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('uuidsMustBeAnArray'), + }); + }); + + it('returns empty when uuids is empty', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [], + })) + .to.eventually.be.empty; + }); + + it('returns an error when there are more than INVITES_LIMIT uuids', async () => { + let uuids = []; + + for (let i = 0; i < 101; i += 1) { + uuids.push(generateUUID()); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + + it('invites a user to a group by uuid', async () => { + let userToInvite = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + })).to.eventually.deep.equal([{ + id: group._id, + name: groupName, + inviter: inviter._id, + }]); + await expect(userToInvite.get('/user')) + .to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + }); + + it('invites multiple users to a group by uuid', async () => { + let userToInvite = await generateUser(); + let userToInvite2 = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id, userToInvite2._id], + })).to.eventually.deep.equal([ + { + id: group._id, + name: groupName, + inviter: inviter._id, + }, + { + id: group._id, + name: groupName, + inviter: inviter._id, + }, + ]); + await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + }); + + it('returns an error when inviting multiple users and a user is not found', async () => { + let userToInvite = await generateUser(); + let fakeID = generateUUID(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id, fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: fakeID}), + }); + }); + }); + + describe('email invites', () => { + let testInvite = {name: 'test', email: 'test@habitica.com'}; + + it('returns an error when invite is missing an email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [{name: 'test'}], + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('inviteMissingEmail'), + }); + }); + + it('returns an error when emails is not an array', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: {testInvite}, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('emailsMustBeAnArray'), + }); + }); + + it('returns empty when emails is an empty array', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [], + })) + .to.eventually.be.empty; + }); + + it('returns an error when there are more than INVITES_LIMIT emails', async () => { + let emails = []; + + for (let i = 0; i < 101; i += 1) { + emails.push(`${generateUUID()}@habitica.com`); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + + it('invites a user to a group by email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [testInvite], + })).to.exist; + }); + + it('invites multiple users to a group by email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}], + })).to.exist; + }); + }); + + describe('user and email invites', () => { + it('returns an error when emails and uuids are not provided', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteEmailUuid'), + }); + }); + + it('returns an error when there are more than INVITES_LIMIT uuids and emails', async () => { + let emails = []; + let uuids = []; + + for (let i = 0; i < 50; i += 1) { + emails.push(`${generateUUID()}@habitica.com`); + } + + for (let i = 0; i < 51; i += 1) { + uuids.push(generateUUID()); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails, + uuids, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + + it('invites users to a group by uuid and email', async () => { + let newUser = await generateUser(); + let invite = await inviter.post(`/groups/${group._id}/invite`, { + uuids: [newUser._id], + emails: [{name: 'test', email: 'test@habitica.com'}], + }); + let invitedUser = await newUser.get('/user'); + + expect(invite).to.exist; + expect(invitedUser.invitations.guilds[0].id).to.equal(group._id); + }); + }); + + describe('guild invites', () => { + it('returns an error when invited user is already invited to the group', async () => { + let userToInivite = await generateUser(); + await inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInivite._id], + }); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInivite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInvitedToGroup'), + }); + }); + + it('returns an error when invited user is already in the group', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${group._id}/join`); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInGroup'), + }); + }); + }); + + describe('party invites', () => { + let party; + + beforeEach(async () => { + party = await inviter.post('/groups', { + name: 'Test Party', + type: 'party', + }); + }); + + it('returns an error when invited user has a pending invitation to the party', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + + await expect(inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyPendingInvitation'), + }); + }); + + it('returns an error when invited user is already in the party', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${party._id}/join`); + + await expect(inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInAParty'), + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 13ddf76d64..70da1221c0 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -2,16 +2,20 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import Q from 'q'; import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; -import { model as Group } from '../../models/group'; +import { + INVITES_LIMIT, + model as Group, +} from '../../models/group'; import { model as User } from '../../models/user'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { NotFound, BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; import * as firebase from '../../libs/api-v3/firebase'; -import { txnEmail } from '../../libs/api-v3/email'; -// import { encrypt } from '../../libs/api-v3/encryption'; +import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; +import { encrypt } from '../../libs/api-v3/encryption'; let api = {}; @@ -305,7 +309,7 @@ api.leaveGroup = { // Send an email to the removed user with an optional message from the leader function _sendMessageToRemoved (group, removedUser, message) { if (removedUser.preferences.emailNotifications.kickedGroup !== false) { - txnEmail(removedUser, `kicked-from-${group.type}`, [ + sendTxnEmail(removedUser, `kicked-from-${group.type}`, [ {name: 'GROUP_NAME', content: group.name}, {name: 'MESSAGE', content: message}, {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'}, @@ -392,145 +396,105 @@ api.removeGroupMember = { }, }; -/* function _inviteByUUIDs (uuids, group, inviter, req, res, next) { - async.each(uuids, function(uuid, cb){ - User.findById(uuid, function(err,invite){ - if (err) return cb(err); - if (!invite) - return cb({code:400,err:'User with id "' + uuid + '" not found'}); - if (group.type == 'guild') { - if (_.contains(group.members,uuid)) - return cb({code:400, err: "User already in that group"}); - if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) - return cb({code:400, err:"User already invited to that group"}); - sendInvite(); - } else if (group.type == 'party') { - if (invite.invitations && !_.isEmpty(invite.invitations.party)) - return cb({code: 400,err:"User already pending invitation."}); - Group.find({type: 'party', members: {$in: [uuid]}}, function(err, groups){ - if (err) return cb(err); - if (!_.isEmpty(groups) && groups[0].members.length > 1) { - return cb({code: 400, err: "User already in a party."}) - } - sendInvite(); - }); - } +async function _inviteByUUID (uuid, group, inviter, req, res) { + // @TODO: Add Push Notifications + let userToInvite = await User.findById(uuid).exec(); - function sendInvite (){ - if(group.type === 'guild'){ - invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); + if (!userToInvite) { + throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); + } - pushNotify.sendNotify(invite, shared.i18n.t('invitedGuild'), group.name); - }else{ - //req.body.type in 'guild', 'party' - invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; + if (group.type === 'guild') { + if (_.contains(userToInvite.guilds, group._id)) { + throw new NotAuthorized(res.t('userAlreadyInGroup')); + } + if (_.find(userToInvite.invitations.guilds, {id: group._id})) { + throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); + } + userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id}); + } else if (group.type === 'party') { + if (!_.isEmpty(userToInvite.invitations.party)) { + throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); + } + if (userToInvite.party._id) { + throw new NotAuthorized(res.t('userAlreadyInAParty')); + } + // @TODO: Why was this here? + // req.body.type in 'guild', 'party' + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; + } - pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); - } + let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; + if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { + let emailVars = [ + {name: 'INVITER', content: inviter.profile.name}, + {name: 'REPLY_TO_ADDRESS', content: inviter.email}, + ]; - group.invites.push(invite._id); - - async.series([ - function(cb){ - invite.save(cb); - } - ], function(err, results){ - if (err) return cb(err); - - if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ - var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); - var emailVars = [ - {name: 'INVITER', content: inviterVars.name}, - {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} - ]; - - if(group.type == 'guild'){ - emailVars.push( - {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} - ); - }else{ - emailVars.push( - {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: '/#/options/groups/party'} - ) - } - - utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); - } - - cb(); - }); - } - }); - }, function(err){ - if(err) return err.code ? res.json(err.code, {err: err.err}) : next(err); - - async.series([ - function(cb) { - group.save(cb); - }, - function(cb) { - // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? - populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ - if(err) return next(err); - - res.json(populatedGroup); - }); - } - ]); - }); -}; - -function _inviteByEmails (emails, group, inviter, req, res, next) { - let usersAlreadyRegistered = []; - let invitesToSend = []; - - return Q.all(emails.forEach(invite => { - if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); - - return User.findOne({$or: [ - {'auth.local.email': invite.email}, - {'auth.facebook.emails.value': invite.email} - ]}) - .select({_id: true, 'preferences.emailNotifications': true}) - .exec() - .then(userToContact => { - if(userToContact){ - usersAlreadyRegistered.push(userToContact._id); // TODO does it work not returning - } else { - // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query - // TODO absolutely refactor this horrible code - let link = `?partyInvite=${utils.encrypt(JSON.stringify({id: group._id, inviter: inviter, name: group.name}))}`; - - let inviterVars = getUserInfo(inviter, ['name', 'email']); - let variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || inviterVars.name}, - {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} - ]; - - if(group.type == 'guild'){ - variables.push({name: 'GUILD_NAME', content: group.name}); - } - - // TODO implement "users can only be invited once" - // Check for the email address not to be unsubscribed - return EmailUnsubscription.findOne({email: invite.email}).exec() - .then(unsubscribed => { - if (!unsubscribed) utils.txnEmail(invite, ('invite-friend' + (group.type == 'guild' ? '-guild' : '')), variables); - }); - } - }); - })) - .then(() => { - if (usersAlreadyRegistered.length > 0){ - return _inviteByUUIDs(usersAlreadyRegistered, group, inviter, req, res, next); + if (group.type === 'guild') { + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'}, + ); + } else { + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: '/#/options/groups/party'}, + ); } - res.respond(200, {}); // TODO what to return? - }); -}; */ + sendTxnEmail(userToInvite, `invited-${groupLabel}`, emailVars); + } + + let userInvited = await userToInvite.save(); + if (group.type === 'guild') { + return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; + } else if (group.type === 'party') { + return userInvited.invitations.party; + } +} + +async function _inviteByEmail (invite, group, inviter, req, res) { + let userReturnInfo; + + if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); + + let userToContact = await User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email}, + ]}) + .select({_id: true, 'preferences.emailNotifications': true}) + .exec(); + + if (userToContact) { + userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res); + } else { + userReturnInfo = invite.email; + // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query + // TODO absolutely refactor this horrible code + const partyQueryString = JSON.stringify({id: group._id, inviter, name: group.name}); + const encryptedPartyqueryString = encrypt(partyQueryString); + let link = `?partyInvite=${encryptedPartyqueryString}`; + + let variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: inviter || inviter.profile.name}, + {name: 'REPLY_TO_ADDRESS', content: inviter.email}, + ]; + + if (group.type === 'guild') { + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // TODO implement "users can only be invited once" + // Check for the email address not to be unsubscribed + let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec(); + let groupLabel = group.type === 'guild' ? '-guild' : ''; + if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables); + } + + return userReturnInfo; +} /** * @api {post} /groups/:groupId/invite Invite users to a group using their UUIDs or email addresses @@ -564,15 +528,49 @@ api.inviteToGroup = { let uuids = req.body.uuids; let emails = req.body.emails; - if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } else if (Array.isArray(uuids)) { - // return _inviteByUUIDs(uuids, group, user, req, res, next); - } else if (Array.isArray(emails)) { - // return _inviteByEmails(emails, group, user, req, res, next); - } else { + let uuidsIsArray = Array.isArray(uuids); + let emailsIsArray = Array.isArray(emails); + + if (!uuids && !emails) { throw new BadRequest(res.t('canOnlyInviteEmailUuid')); } + + let results = []; + let totalInvites = 0; + + if (uuids) { + if (!uuidsIsArray) { + throw new BadRequest(res.t('uuidsMustBeAnArray')); + } else { + totalInvites += uuids.length; + } + } + + if (emails) { + if (!emailsIsArray) { + throw new BadRequest(res.t('emailsMustBeAnArray')); + } else { + totalInvites += emails.length; + } + } + + if (totalInvites > INVITES_LIMIT) { + throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT})); + } + + if (uuids) { + let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); + let uuidResults = await Q.all(uuidInvites); + results.push(...uuidResults); + } + + if (emails) { + let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); + let emailResults = await Q.all(emailInvites); + results.push(...emailResults); + } + + res.respond(200, results); }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index a45d2eaa60..27ca80e73f 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -520,3 +520,5 @@ model.count({_id: 'habitrpg'}, (err, ct) => { privacy: 'public', }).save(); }); + +export const INVITES_LIMIT = 100;