diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index ec62946350..4db475ccb7 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -23,6 +23,73 @@ describe('Post /groups/:groupId/invite', () => { }); }); + describe('username invites', () => { + it('returns an error when invited user is not found', async () => { + const fakeID = 'fakeuserid'; + + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithUsernameNotFound', {username: fakeID}), + }); + }); + + it('returns an error when inviting yourself to a group', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [inviter.auth.local.lowerCaseUsername], + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('cannotInviteSelfToGroup'), + }); + }); + + it('invites a user to a group by username', async () => { + const userToInvite = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [userToInvite.auth.local.lowerCaseUsername], + })).to.eventually.deep.equal([{ + id: group._id, + name: groupName, + inviter: inviter._id, + publicGuild: false, + }]); + + await expect(userToInvite.get('/user')) + .to.eventually.have.nested.property('invitations.guilds[0].id', group._id); + }); + + it('invites multiple users to a group by uuid', async () => { + const userToInvite = await generateUser(); + const userToInvite2 = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [userToInvite.auth.local.lowerCaseUsername, userToInvite2.auth.local.lowerCaseUsername], + })).to.eventually.deep.equal([ + { + id: group._id, + name: groupName, + inviter: inviter._id, + publicGuild: false, + }, + { + id: group._id, + name: groupName, + inviter: inviter._id, + publicGuild: false, + }, + ]); + + await expect(userToInvite.get('/user')).to.eventually.have.nested.property('invitations.guilds[0].id', group._id); + await expect(userToInvite2.get('/user')).to.eventually.have.nested.property('invitations.guilds[0].id', group._id); + }); + }); + describe('user id invites', () => { it('returns an error when inviter has no chat privileges', async () => { let inviterMuted = await inviter.update({'flags.chatRevoked': true}); diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 75bc3e23cd..aeebe2a1d5 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -240,6 +240,7 @@ "userAlreadyPendingInvitation": "UserID: <%= userId %>, User \"<%= username %>\" already pending invitation.", "userAlreadyInAParty": "UserID: <%= userId %>, User \"<%= username %>\" already in a party. ", "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "userWithUsernameNotFound": "User with username \"<%= username %>\" not found.", "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", "uuidsMustBeAnArray": "User ID invites must be an array.", "emailsMustBeAnArray": "Email address invites must be an array.", diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 16d586a5ca..15ae6a6b24 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -9,7 +9,6 @@ import { model as User, nameFields, } from '../../models/user'; -import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { NotFound, BadRequest, @@ -17,9 +16,12 @@ import { } from '../../libs/errors'; import { removeFromArray } from '../../libs/collectionManipulators'; import { sendTxn as sendTxnEmail } from '../../libs/email'; -import { encrypt } from '../../libs/encryption'; -import { sendNotification as sendPushNotification } from '../../libs/pushNotifications'; import pusher from '../../libs/pusher'; +import { + _inviteByUUID, + _inviteByEmail, + _inviteByUserName, +} from '../../libs/invites'; import common from '../../../common'; import payments from '../../libs/payments/payments'; import stripePayments from '../../libs/payments/stripe'; @@ -948,148 +950,6 @@ 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})); - } else if (inviter._id === userToInvite._id) { - throw new BadRequest(res.t('cannotInviteSelfToGroup')); - } - - const objections = inviter.getObjectionsToInteraction('group-invitation', userToInvite); - if (objections.length > 0) { - throw new NotAuthorized(res.t(objections[0], { userId: uuid, username: userToInvite.profile.name})); - } - - if (group.type === 'guild') { - if (_.includes(userToInvite.guilds, group._id)) { - throw new NotAuthorized(res.t('userAlreadyInGroup', { userId: uuid, username: userToInvite.profile.name})); - } - if (_.find(userToInvite.invitations.guilds, {id: group._id})) { - throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name})); - } - - 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') { - // Do not add to invitations.parties array if the user is already invited to that party - if (_.find(userToInvite.invitations.parties, {id: group._id})) { - throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name})); - } - - if (userToInvite.party._id) { - let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); - - // Allow user to be invited to a new party when they're partying solo - if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name})); - } - - let partyInvite = {id: group._id, name: group.name, inviter: inviter._id}; - if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true; - - userToInvite.invitations.parties.push(partyInvite); - userToInvite.invitations.party = partyInvite; - } - - let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; - let groupTemplate = group.type === 'guild' ? 'guild' : 'party'; - if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { - let emailVars = [ - {name: 'INVITER', content: inviter.profile.name}, - ]; - - if (group.type === 'guild') { - emailVars.push( - {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: '/groups/discovery'} - ); - } else { - emailVars.push( - {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: '/party'} - ); - } - - sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars); - } - - if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] !== false) { - let identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty'; - sendPushNotification( - userToInvite, - { - title: group.name, - message: res.t(identifier), - identifier, - payload: {groupID: group._id, publicGuild}, - } - ); - } - - 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.parties[userToInvite.invitations.parties.length - 1]; - } -} - -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}, - {'auth.google.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; - - let cancelledPlan = false; - if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true; - - 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, - }); - let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`; - - let variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || inviter.profile.name}, - ]; - - if (group.type === 'guild') { - variables.push({name: 'GUILD_NAME', content: group.name}); - } - - // 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} /api/v3/groups/:groupId/invite Invite users to a group * @apiName InviteToGroup @@ -1176,7 +1036,7 @@ api.inviteToGroup = { url: '/groups/:groupId/invite', middlewares: [authWithHeaders({})], async handler (req, res) { - let user = res.locals.user; + const user = res.locals.user; if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted')); @@ -1184,35 +1044,48 @@ api.inviteToGroup = { if (user.invitesSent >= MAX_EMAIL_INVITES_BY_USER) throw new NotAuthorized(res.t('inviteLimitReached', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL })); - let validationErrors = req.validationErrors(); + const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); + const group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.purchased && group.purchased.plan.customerId && user._id !== group.leader) throw new NotAuthorized(res.t('onlyGroupLeaderCanInviteToGroupPlan')); - let uuids = req.body.uuids; - let emails = req.body.emails; + const { + uuids, + emails, + usernames, + } = req.body; - await Group.validateInvitations(uuids, emails, res, group); + await Group.validateInvitations({ + uuids, + emails, + usernames, + }, res, group); - let results = []; + const results = []; if (uuids) { - let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); - let uuidResults = await Promise.all(uuidInvites); + const uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); + const uuidResults = await Promise.all(uuidInvites); results.push(...uuidResults); } if (emails) { - let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); + const emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); user.invitesSent += emails.length; await user.save(); - let emailResults = await Promise.all(emailInvites); + const emailResults = await Promise.all(emailInvites); results.push(...emailResults); } + if (usernames) { + const usernameInvites = usernames.map((username) => _inviteByUserName(username, group, user, req, res)); + const usernameResults = await Promise.all(usernameInvites); + results.push(...usernameResults); + } + res.respond(200, results); }, }; diff --git a/website/server/libs/invites/index.js b/website/server/libs/invites/index.js new file mode 100644 index 0000000000..ef5a51b1f3 --- /dev/null +++ b/website/server/libs/invites/index.js @@ -0,0 +1,209 @@ +import _ from 'lodash'; + +import { encrypt } from '../encryption'; +import { sendNotification as sendPushNotification } from '../pushNotifications'; +import { + NotFound, + BadRequest, + NotAuthorized, +} from '../errors'; +import { sendTxn as sendTxnEmail } from '../email'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; +import { + model as User, +} from '../../models/user'; +import { + model as Group, +} from '../../models/group'; + +function sendInvitePushNotification (userToInvite, groupLabel, group, publicGuild, res) { + if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] === false) return; + + const identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty'; + + sendPushNotification( + userToInvite, + { + title: group.name, + message: res.t(identifier), + identifier, + payload: {groupID: group._id, publicGuild}, + } + ); +} + +function sendInviteEmail (userToInvite, groupLabel, group, inviter) { + if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] === false) return; + const groupTemplate = group.type === 'guild' ? 'guild' : 'party'; + + const emailVars = [ + {name: 'INVITER', content: inviter.profile.name}, + ]; + + if (group.type === 'guild') { + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: '/groups/discovery'} + ); + } else { + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: '/party'} + ); + } + + sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars); +} + +function inviteUserToGuild (userToInvite, group, inviter, publicGuild, res) { + const uuid = userToInvite._id; + + if (_.includes(userToInvite.guilds, group._id)) { + throw new NotAuthorized(res.t('userAlreadyInGroup', { userId: uuid, username: userToInvite.profile.name})); + } + + if (_.find(userToInvite.invitations.guilds, {id: group._id})) { + throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name})); + } + + const guildInvite = { + id: group._id, + name: group.name, + inviter: inviter._id, + publicGuild, + }; + + if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true; + + userToInvite.invitations.guilds.push(guildInvite); +} + +async function inviteUserToParty (userToInvite, group, inviter, res) { + const uuid = userToInvite._id; + + // Do not add to invitations.parties array if the user is already invited to that party + if (_.find(userToInvite.invitations.parties, {id: group._id})) { + throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name})); + } + + if (userToInvite.party._id) { + let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); + + // Allow user to be invited to a new party when they're partying solo + if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name})); + } + + let partyInvite = {id: group._id, name: group.name, inviter: inviter._id}; + if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true; + + userToInvite.invitations.parties.push(partyInvite); + userToInvite.invitations.party = partyInvite; +} + +async function addInvitiationToUser (userToInvite, group, inviter, res) { + const publicGuild = group.type === 'guild' && group.privacy === 'public'; + + if (group.type === 'guild') { + inviteUserToGuild(userToInvite, group, inviter, publicGuild, res); + } else if (group.type === 'party') { + await inviteUserToParty(userToInvite, group, inviter, res); + } + + const groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; + sendInviteEmail(userToInvite, groupLabel, group, inviter); + sendInvitePushNotification(userToInvite, groupLabel, group, publicGuild, res); + + const userInvited = await userToInvite.save(); + if (group.type === 'guild') { + return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; + } + + if (group.type === 'party') { + return userInvited.invitations.parties[userToInvite.invitations.parties.length - 1]; + } +} + +async function _inviteByUUID (uuid, group, inviter, req, res) { + const userToInvite = await User.findById(uuid).exec(); + + if (!userToInvite) { + throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); + } else if (inviter._id === userToInvite._id) { + throw new BadRequest(res.t('cannotInviteSelfToGroup')); + } + + const objections = inviter.getObjectionsToInteraction('group-invitation', userToInvite); + if (objections.length > 0) { + throw new NotAuthorized(res.t(objections[0], { userId: uuid, username: userToInvite.profile.name})); + } + + return await addInvitiationToUser(userToInvite, group, inviter, res); +} + +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}, + {'auth.google.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; + + let cancelledPlan = false; + if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true; + + 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, + }); + let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`; + + let variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || inviter.profile.name}, + ]; + + if (group.type === 'guild') { + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // 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; +} + +async function _inviteByUserName (username, group, inviter, req, res) { + const userToInvite = await User.findOne({'auth.local.lowerCaseUsername': username}).exec(); + + if (!userToInvite) { + throw new NotFound(res.t('userWithUsernameNotFound', { username })); + } + + if (inviter._id === userToInvite._id) { + throw new BadRequest(res.t('cannotInviteSelfToGroup')); + } + + return await addInvitiationToUser(userToInvite, group, inviter, res); +} + +module.exports = { + _inviteByUUID, + _inviteByEmail, + _inviteByUserName, +}; diff --git a/website/server/middlewares/errorHandler.js b/website/server/middlewares/errorHandler.js index 5b3cdb7390..429f8251a0 100644 --- a/website/server/middlewares/errorHandler.js +++ b/website/server/middlewares/errorHandler.js @@ -16,7 +16,7 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) // If we can't identify it, respond with a generic 500 error let responseErr = err instanceof CustomError ? err : null; - + console.log(err) // Handle errors created with 'http-errors' or similar that have a status/statusCode property if (err.statusCode && typeof err.statusCode === 'number') { responseErr = new CustomError(); diff --git a/website/server/models/group.js b/website/server/models/group.js index f1612af3e8..2fbcb888d5 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -356,23 +356,17 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use return toJSON; }; -/** - * Checks invitation uuids and emails for possible errors. - * - * @param uuids An array of user ids - * @param emails An array of emails - * @param res Express res object for use with translations - * @throws BadRequest An error describing the issue with the invitations - */ -schema.statics.validateInvitations = async function getInvitationError (uuids, emails, res, group = null) { - let uuidsIsArray = Array.isArray(uuids); - let emailsIsArray = Array.isArray(emails); - let emptyEmails = emailsIsArray && emails.length < 1; - let emptyUuids = uuidsIsArray && uuids.length < 1; +function getIniviteError (uuids, emails, usernames) { + const uuidsIsArray = Array.isArray(uuids); + const emailsIsArray = Array.isArray(emails); + const usernamesIsArray = Array.isArray(usernames); + const emptyEmails = emailsIsArray && emails.length < 1; + const emptyUuids = uuidsIsArray && uuids.length < 1; + const emptyUsernames = usernamesIsArray && usernames.length < 1; let errorString; - if (!uuids && !emails) { + if (!uuids && !emails && !usernames) { errorString = 'canOnlyInviteEmailUuid'; } else if (uuids && !uuidsIsArray) { errorString = 'uuidsMustBeAnArray'; @@ -386,10 +380,12 @@ schema.statics.validateInvitations = async function getInvitationError (uuids, e errorString = 'inviteMustNotBeEmpty'; } - if (errorString) { - throw new BadRequest(res.t(errorString)); - } + if (usernames && emptyUsernames) errorString = 'usernamesMustNotBeEmpty'; + return errorString; +} + +function getInviteCount (uuids, emails) { let totalInvites = 0; if (uuids) { @@ -400,6 +396,28 @@ schema.statics.validateInvitations = async function getInvitationError (uuids, e totalInvites += emails.length; } + return totalInvites; +} + +/** + * Checks invitation uuids and emails for possible errors. + * + * @param uuids An array of user ids + * @param emails An array of emails + * @param res Express res object for use with translations + * @throws BadRequest An error describing the issue with the invitations + */ +schema.statics.validateInvitations = async function getInvitationError (invites, res, group = null) { + const { + uuids, + emails, + usernames, + } = invites; + + const errorString = getIniviteError(uuids, emails, usernames); + if (errorString) throw new BadRequest(res.t(errorString)); + + const totalInvites = getInviteCount(uuids, emails); if (totalInvites > INVITES_LIMIT) { throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT})); }