Files
habitica/website/src/controllers/api-v3/groups.js
T
Keith Holliday ef6afaedb4 Added intitial group leave tests
Fixed test readability, updated party test, and updated challenge update code when leaving group
Updated library, added group existance check, and reset full party
Updated syntax, added new userUnlinkChallenges, and added some initial testing for challenges
Added challenge tasks tests
Added try/catch to group remove, add more party tests, fixed broken challenge test, removed useless return value
Added public guild tests, added more tests to party, and abstracted remove invitations logic

Closes #6506
2016-01-20 08:51:30 -06:00

582 lines
19 KiB
JavaScript

import { authWithHeaders } from '../../middlewares/api-v3/auth';
import Q from 'q';
import _ from 'lodash';
import cron from '../../middlewares/api-v3/cron';
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 { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
import { encrypt } from '../../libs/api-v3/encryption';
let api = {};
// TODO shall we accept party as groupId in all routes?
/**
* @api {post} /groups Create group
* @apiVersion 3.0.0
* @apiName CreateGroup
* @apiGroup Group
*
* @apiSuccess {Object} group The group object
*/
api.createGroup = {
method: 'POST',
url: '/groups',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body
group.leader = user._id;
if (group.type === 'guild') {
if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems'));
group.balance = 1;
user.balance--;
user.guilds.push(group._id);
} else {
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty'));
user.party._id = group._id;
}
let results = await Q.all([user.save(), group.save()]);
let savedGroup = results[1];
firebase.updateGroupData(savedGroup);
firebase.addUserToGroup(savedGroup._id, user._id);
return res.respond(201, savedGroup); // TODO populate
},
};
/**
* @api {get} /groups Get groups
* @apiVersion 3.0.0
* @apiName GetGroups
* @apiGroup Group
*
* @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, privateGuilds, publicGuilds, tavern
*
* @apiSuccess {Array} groups An array of the requested groups
*/
api.getGroups = {
method: 'GET',
url: '/groups',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); // TODO better validation
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
// TODO validate types are acceptable? probably not necessary
let types = req.query.type.split(',');
let groupFields = 'name description memberCount balance leader';
let sort = '-memberCount';
let queries = [];
types.forEach(type => {
switch (type) {
case 'party':
queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields, populateLeader: true}));
break;
case 'privateGuilds':
queries.push(Group.find({
type: 'guild',
privacy: 'private',
_id: {$in: user.guilds},
}).select(groupFields).sort(sort).exec());
break;
case 'publicGuilds':
queries.push(Group.find({
type: 'guild',
privacy: 'public',
}).select(groupFields).sort(sort).exec()); // TODO use lean?
break;
case 'tavern':
queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields, populateLeader: true}));
break;
}
});
// If no valid value for type was supplied, return an error
if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired'));
// TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328
let results = _.reduce(await Q.all(queries), (previousValue, currentValue) => {
if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array
return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue
}, []);
res.respond(200, results);
},
};
/**
* @api {get} /groups/:groupId Get group
* @apiVersion 3.0.0
* @apiName GetGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} group The group object
*/
api.getGroup = {
method: 'GET',
url: '/groups/:groupId',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true});
if (!group) throw new NotFound(res.t('groupNotFound'));
res.respond(200, group);
},
};
/**
* @api {put} /groups/:groupId Update group
* @apiVersion 3.0.0
* @apiName UpdateGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiSuccess {Object} group The updated group object
*/
api.updateGroup = {
method: 'PUT',
url: '/groups/:groupId',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
_.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body)));
let savedGroup = await group.save();
res.respond(200, savedGroup);
firebase.updateGroupData(savedGroup);
},
};
/**
* @api {post} /groups/:groupId/join Join a group
* @apiVersion 3.0.0
* @apiName JoinGroup
* @apiGroup Group
*
* @apiParam {UUID} groupId The group _id
*
* @apiSuccess {Object} empty An empty object
*/
api.joinGroup = {
method: 'POST',
url: '/groups/:groupId/join',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
let inviter;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
// Do not fetch chat and work even if the user is not yet a member of the group
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group
if (!group) throw new NotFound(res.t('groupNotFound'));
let isUserInvited = false;
if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) {
inviter = user.invitations.party.inviter;
user.invitations.party = {}; // Clear invite TODO mark modified?
// invite new user to pending quest
if (group.quest.key && !group.quest.active) {
user.party.quest.RSVPNeeded = true;
user.party.quest.key = group.quest.key;
group.quest.members[user._id] = undefined;
group.markModified('quest.members');
}
user.party._id = group._id; // Set group as user's party
isUserInvited = true;
} else if (group.type === 'guild') {
let i = _.findIndex(user.invitations.guilds, {id: group._id});
if (i !== -1) {
isUserInvited = true;
user.invitations.guilds.splice(i, 1); // Remove invitation
} else {
isUserInvited = group.privacy === 'private' ? false : true;
}
}
if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader
group.memberCount += 1;
let promises = [group.save(), user.save()];
if (group.type === 'party' && inviter) {
promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter
}
await Q.all(promises);
firebase.addUserToGroup(group._id, user._id);
res.respond(200, {}); // TODO what to return?
},
};
/**
* @api {post} /groups/:groupId/leave Leave a group
* @apiVersion 3.0.0
* @apiName LeaveGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
* @apiParam {string="remove-all","keep-all"} keep Wheter to keep or not challenges' tasks, as an optional query string
*
* @apiSuccess {Object} empty An empty object
*/
api.leaveGroup = {
method: 'POST',
url: '/groups/:groupId/leave',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
// When removing the user from challenges, should we keep the tasks?
req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat
if (!group) throw new NotFound(res.t('groupNotFound'));
// During quests, checke wheter user can leave
if (group.type === 'party') {
if (group.quest && group.quest.leader === user._id) {
throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup'));
}
if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) {
throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest'));
}
}
await group.leave(user, req.query.keep);
res.respond(200, {});
},
};
// 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) {
sendTxnEmail(removedUser, `kicked-from-${group.type}`, [
{name: 'GROUP_NAME', content: group.name},
{name: 'MESSAGE', content: message},
{name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'},
{name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'},
]);
}
}
/**
* @api {post} /groups/:groupId/removeMember/:memberId Remove a member from a group
* @apiVersion 3.0.0
* @apiName RemoveGroupMember
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
* @apiParam {UUID} memberId The _id of the member to remove
* @apiParam {string} message The message to send to the removed members, as a query string // TODO in req.body?
*
* @apiSuccess {Object} empty An empty object
*/
api.removeGroupMember = {
method: 'POST',
url: '/groups/:groupId/removeMember/:memberId',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat
if (!group) throw new NotFound(res.t('groupNotFound'));
let uuid = req.query.memberId;
if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself'));
let member = await User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec();
// We're removing the user from a guild or a party? is the user invited only?
let isInGroup = member.party._id === group._id ? 'party' : member.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary
let isInvited = member.invitations.party.id === group._id ? 'party' : _.findIndex(member.invitations.guilds, {id: group._id}) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary
if (isInGroup) {
group.memberCount -= 1;
if (group.quest && group.quest.leader === member._id) {
group.quest.key = null;
group.quest.leader = null; // TODO markmodified?
} else if (group.quest && group.quest.members) {
// remove member from quest
group.quest.members[member._id] = undefined;
}
if (isInGroup === 'guild') _.pull(member.guilds, group._id);
if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too?
member.newMessages.group._id = undefined;
if (group.quest && group.quest.active && group.quest.leader === member._id) {
member.items.quests[group.quest.key] += 1; // TODO why this?
}
} else if (isInvited) {
if (isInvited === 'guild') {
let i = _.findIndex(member.invitations.guilds, {id: group._id});
if (i !== -1) member.invitations.guilds.splice(i, 1);
}
if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified?
} else {
throw new NotFound(res.t('groupMemberNotFound'));
}
let message = req.query.message;
if (message) _sendMessageToRemoved(group, member, message);
await Q.all([
member.save(),
group.save(),
]);
res.respond(200, {});
},
};
async function _inviteByUUID (uuid, group, inviter, req, res) {
// @TODO: Add Push Notifications
let userToInvite = await User.findById(uuid).exec();
if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
}
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};
}
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},
];
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'},
);
}
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
* @apiVersion 3.0.0
* @apiName InviteToGroup
* @apiGroup Group
*
* @apiParam {string} groupId The group _id (or 'party')
*
* @apiParam {array} emails An array of emails addresses to invite (optional) (inside body)
* @apiParam {array} uuids An array of uuids to invite (optional) (inside body)
* @apiParam {string} inviter The inviters' name (optional) (inside body)
*
* @apiSuccess {Object} empty An empty object
*/
api.inviteToGroup = {
method: 'POST',
url: '/groups/:groupId/invite',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat TODO other fields too?
if (!group) throw new NotFound(res.t('groupNotFound'));
let uuids = req.body.uuids;
let emails = req.body.emails;
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);
},
};
export default api;