Merge pull request #6504 from HabitRPG/api-v3-members

[API v3] Members routes
This commit is contained in:
Matteo Pagliazzi
2016-01-17 18:40:17 +01:00
17 changed files with 841 additions and 54 deletions
+3
View File
@@ -15,6 +15,7 @@
"cantDetachFb": "Account lacks another authentication method, can't detach Facebook.",
"onlySocialAttachLocal": "Local auth can only be added to a social account.",
"invalidReqParams": "Invalid request parameters.",
"memberIdRequired": "\"member\" must be a valid UUID.",
"taskIdRequired": "\"taskId\" must be a valid UUID.",
"taskNotFound": "Task not found.",
"invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".",
@@ -36,6 +37,8 @@
"onlyLeaderCanRemoveMember": "Only group leader can remove a member!",
"memberCannotRemoveYourself": "You cannot remove yourself!",
"groupMemberNotFound": "User not found among group's members",
"challengeMemberNotFound": "User not found among challenge's members",
"mustBeGroupMember": "Must be member of the group.",
"keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"",
"keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"",
"canOnlyInviteEmailUuid": "Can only invite using uuids or emails.",
@@ -0,0 +1,125 @@
import {
generateUser,
generateGroup,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('GET /challenges/:challengeId/members', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('validates optional req.query.lastId to be an UUID', async () => {
await expect(user.get(`/challenges/${generateUUID()}/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('fails if challenge doesn\'t exists', async () => {
await expect(user.get(`/challenges/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
it('fails if user doesn\'t have access to the challenge', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let challenge = await user.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let anotherUser = await generateUser();
await expect(anotherUser.get(`/challenges/${challenge._id}/members`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
it('works with challenges belonging to public guild', async () => {
let leader = await generateUser({balance: 4});
let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
let challenge = await leader.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let res = await user.get(`/challenges/${challenge._id}/members`);
expect(res[0]).to.eql({
_id: leader._id,
profile: {name: leader.profile.name},
});
expect(res[0]).to.have.all.keys(['_id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
it('populates only some fields', async () => {
let anotherUser = await generateUser({balance: 3});
let group = await generateGroup(anotherUser, {type: 'guild', privacy: 'public', name: generateUUID()});
let challenge = await anotherUser.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let res = await user.get(`/challenges/${challenge._id}/members`);
expect(res[0]).to.eql({
_id: anotherUser._id,
profile: {name: anotherUser.profile.name},
});
expect(res[0]).to.have.all.keys(['_id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
it('returns only first 30 members', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let challenge = await user.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let usersToGenerate = [];
for (let i = 0; i < 31; i++) {
usersToGenerate.push(generateUser({challenges: [challenge._id]}));
}
await Promise.all(usersToGenerate);
let res = await user.get(`/challenges/${challenge._id}/members`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});
it('supports using req.query.lastId to get more members', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let challenge = await user.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let usersToGenerate = [];
for (let i = 0; i < 57; i++) {
usersToGenerate.push(generateUser({challenges: [challenge._id]}));
}
let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader)
let expectedIds = [user._id].concat(generatedUsers.map(generatedUser => generatedUser._id));
let res = await user.get(`/challenges/${challenge._id}/members`);
expect(res.length).to.equal(30);
let res2 = await user.get(`/challenges/${challenge._id}/members?lastId=${res[res.length - 1]._id}`);
expect(res2.length).to.equal(28);
let resIds = res.concat(res2).map(member => member._id);
expect(resIds).to.eql(expectedIds.sort());
});
});
@@ -0,0 +1,126 @@
import {
generateUser,
generateGroup,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('GET /challenges/:challengeId/members/:memberId', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('validates req.params.memberId to be an UUID', async () => {
await expect(user.get(`/challenges/invalidUUID/members/${generateUUID()}`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('validates req.params.memberId to be an UUID', async () => {
await expect(user.get(`/challenges/${generateUUID()}/members/invalidUUID`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('fails if member doesn\'t exists', async () => {
let userId = generateUUID();
await expect(user.get(`/challenges/${generateUUID()}/members/${userId}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithIDNotFound', {userId}),
});
});
it('fails if challenge doesn\'t exists', async () => {
let member = await generateUser();
await expect(user.get(`/challenges/${generateUUID()}/members/${member._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
it('fails if user doesn\'t have access to the challenge', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let challenge = await user.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let anotherUser = await generateUser();
let member = await generateUser();
await expect(anotherUser.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
it('fails if member is not part of the challenge', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let challenge = await user.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let member = await generateUser();
await expect(user.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeMemberNotFound'),
});
});
it('works with challenges belonging to a public guild', async () => {
let groupLeader = await generateUser({balance: 4});
let group = await generateGroup(groupLeader, {type: 'guild', privacy: 'public', name: generateUUID()});
let challenge = await groupLeader.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let taskText = 'Test Text';
await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]);
let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`);
expect(memberProgress).to.have.all.keys(['_id', 'profile', 'tasks']);
expect(memberProgress.profile).to.have.all.keys(['name']);
expect(memberProgress.tasks.length).to.equal(1);
});
it('returns the member tasks for the challenges', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let challenge = await user.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: 'Test Text'}]);
let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`);
let chalTasks = await user.get(`/tasks/challenge/${challenge._id}`);
expect(memberProgress.tasks.length).to.equal(chalTasks.length);
expect(memberProgress.tasks[0].challenge.id).to.equal(challenge._id);
expect(memberProgress.tasks[0].challenge.taskId).to.equal(chalTasks[0]._id);
});
it('returns the tasks without the tags', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let challenge = await user.post('/challenges', {
name: 'test chal',
shortName: 'test-chal',
groupId: group._id,
});
let taskText = 'Test Text';
await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]);
let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`);
expect(memberProgress.tasks[0]).not.to.have.key('tags');
});
});
@@ -0,0 +1,101 @@
import {
generateUser,
generateGroup,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('GET /groups/:groupId/invites', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('validates optional req.query.lastId to be an UUID', async () => {
await expect(user.get(`/groups/groupId/invites?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('fails if group doesn\'t exists', async () => {
await expect(user.get(`/groups/${generateUUID()}/invites`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
it('fails if user doesn\'t have access to the group', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let anotherUser = await generateUser();
await expect(anotherUser.get(`/groups/${group._id}/invites`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
it('works when passing party as req.params.groupId', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let invited = await generateUser();
await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]});
let res = await user.get(`/groups/party/invites`);
expect(res).to.be.an('array');
expect(res.length).to.equal(1);
expect(res[0]).to.eql({
_id: invited._id,
profile: {name: invited.profile.name},
});
});
it('populates only some fields', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let invited = await generateUser();
await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]});
let res = await user.get(`/groups/party/invites`);
expect(res[0]).to.have.all.keys(['_id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
it('returns only first 30 invites', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let invitesToGenerate = [];
for (let i = 0; i < 31; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)});
let res = await user.get(`/groups/party/invites`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});
it('supports using req.query.lastId to get more invites', async () => {
let leader = await generateUser({balance: 4});
let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
let invitesToGenerate = [];
for (let i = 0; i < 32; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate); // Group has 32 invites
let expectedIds = generatedInvites.map(generatedInvite => generatedInvite._id);
await user.post(`/groups/${group._id}/invite`, {uuids: expectedIds});
let res = await user.get(`/groups/${group._id}/invites`);
expect(res.length).to.equal(30);
let res2 = await user.get(`/groups/${group._id}/invites?lastId=${res[res.length - 1]._id}`);
expect(res2.length).to.equal(2);
let resIds = res.concat(res2).map(invite => invite._id);
expect(resIds).to.eql(expectedIds.sort());
});
});
@@ -0,0 +1,95 @@
import {
generateUser,
generateGroup,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('GET /groups/:groupId/members', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('validates optional req.query.lastId to be an UUID', async () => {
await expect(user.get(`/groups/groupId/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('fails if group doesn\'t exists', async () => {
await expect(user.get(`/groups/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
it('fails if user doesn\'t have access to the group', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let anotherUser = await generateUser();
await expect(anotherUser.get(`/groups/${group._id}/members`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
it('works when passing party as req.params.groupId', async () => {
await generateGroup(user, {type: 'party', name: generateUUID()});
let res = await user.get(`/groups/party/members`);
expect(res).to.be.an('array');
expect(res.length).to.equal(1);
expect(res[0]).to.eql({
_id: user._id,
profile: {name: user.profile.name},
});
});
it('populates only some fields', async () => {
await generateGroup(user, {type: 'party', name: generateUUID()});
let res = await user.get(`/groups/party/members`);
expect(res[0]).to.have.all.keys(['_id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
it('returns only first 30 members', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let usersToGenerate = [];
for (let i = 0; i < 31; i++) {
usersToGenerate.push(generateUser({party: {_id: group._id}}));
}
await Promise.all(usersToGenerate);
let res = await user.get(`/groups/party/members`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});
it('supports using req.query.lastId to get more members', async () => {
let leader = await generateUser({balance: 4});
let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
let usersToGenerate = [];
for (let i = 0; i < 57; i++) {
usersToGenerate.push(generateUser({guilds: [group._id]}));
}
let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader)
let expectedIds = [leader._id].concat(generatedUsers.map(generatedUser => generatedUser._id));
let res = await user.get(`/groups/${group._id}/members`);
expect(res.length).to.equal(30);
let res2 = await user.get(`/groups/${group._id}/members?lastId=${res[res.length - 1]._id}`);
expect(res2.length).to.equal(28);
let resIds = res.concat(res2).map(member => member._id);
expect(resIds).to.eql(expectedIds.sort());
});
});
@@ -0,0 +1,49 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('GET /members/:memberId', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.memberId', async () => {
await expect(user.get(`/members/invalidUUID`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns a member public data only', async () => {
let member = await generateUser({ // make sure user has all the fields that can be returned by the getMember call
contributor: {level: 1},
backer: {tier: 3},
preferences: {
costume: false,
background: 'volcano',
},
});
let memberRes = await user.get(`/members/${member._id}`);
expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys
'_id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items',
]);
expect(Object.keys(memberRes.auth)).to.eql(['timestamps']);
expect(Object.keys(memberRes.preferences).sort()).to.eql(['size', 'hair', 'skin', 'shirt',
'costume', 'sleep', 'background'].sort());
});
it('handles non-existing members', async () => {
let dummyId = generateUUID();
await expect(user.get(`/members/${dummyId}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithIDNotFound', {userId: dummyId}),
});
});
});
@@ -15,4 +15,6 @@ describe('GET /tags/:tagId', () => {
expect(tag).to.deep.equal(createdTag);
});
it('handles non-existing tags');
});
-2
View File
@@ -41,7 +41,6 @@ export async function resetHabiticaDB () {
name: 'HabitRPG',
type: 'guild',
privacy: 'public',
members: [],
}, (insertErr) => {
if (insertErr) return reject(insertErr);
@@ -89,4 +88,3 @@ export function connectToMongo () {
});
});
}
+22 -15
View File
@@ -38,8 +38,9 @@ api.createChallenge = {
let groupId = req.body.groupId;
let prize = req.body.prize;
let group = await Group.getGroup(user, groupId, '-chat');
let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember'));
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
throw new NotAuthorized(res.t('onlyGroupLeaderChal'));
@@ -77,9 +78,15 @@ api.createChallenge = {
req.body.official = user.contributor.admin && req.body.official;
let challenge = new Challenge(Challenge.sanitize(req.body));
let results = await Q.all(challenge.save(), group.save());
let savedChal = results[0];
// First validate challenge so we don't save group if it's invalid (only runs sync validators)
let challengeValidationErrors = challenge.validateSync();
if (challengeValidationErrors) throw challengeValidationErrors;
let results = await Q.all([challenge.save({
validateBeforeSave: false, // already validate
}), group.save()]);
let savedChal = results[0];
await savedChal.syncToUser(user); // (it also saves the user)
res.respond(201, savedChal);
},
@@ -100,9 +107,9 @@ api.getChallenges = {
async handler (req, res) {
let user = res.locals.user;
let groups = user.guilds || [];
let groups = user.guilds.slice(0); // slice is used to clone the array so we don't modify it directly
if (user.party._id) groups.push(user.party._id);
groups.push('habitrpg'); // Public challenges
groups.push('habitrpg'); // tavern challenges
let challenges = await Challenge.find({
$or: [
@@ -128,6 +135,8 @@ api.getChallenges = {
* @apiName GetChallenge
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The challenge _id
*
* @apiSuccess {object} challenge The challenge object
*/
api.getChallenge = {
@@ -140,16 +149,14 @@ api.getChallenge = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.local.user;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findOne({_id: challengeId}).exec(); // TODO populate
let challenge = await Challenge.findById(challengeId).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
// If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error
// TODO support challenges in groups I'm a member of
if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens
throw new NotFound(res.t('challengeNotFound'));
}
let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
res.respond(200, challenge);
},
@@ -168,7 +175,7 @@ function _closeChal (challenge, broken = {}) {
Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(),
// Set the challenge tag to non-challenge status and remove the challenge from the user's challenges
User.update({
challenges: {$in: [challenge._id]},
challenges: challenge._id,
'tags._id': challenge._id,
}, {
$set: {'tags.$.challenge': false},
@@ -233,7 +240,7 @@ api.deleteChallenge = {
let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
res.respond(200, {});
// Close channel in background
@@ -264,7 +271,7 @@ api.selectChallengeWinner = {
let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
let winner = await User.findOne({_id: req.params.winnerId}).exec();
if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId}));
+5 -5
View File
@@ -7,7 +7,7 @@ import {
} from '../../libs/api-v3/errors';
import _ from 'lodash';
import { sendTxn } from '../../libs/api-v3/email';
import nconf from 'nconf';
import nconf from 'nconf';
let api = {};
@@ -33,7 +33,7 @@ api.getChat = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, req.params.groupId, 'chat');
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
res.respond(200, group.chat);
@@ -67,7 +67,7 @@ api.postChat = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, groupId);
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party' && user.flags.chatRevoked) {
@@ -118,7 +118,7 @@ api.likeChat = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, groupId);
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: req.params.chatId});
@@ -165,7 +165,7 @@ api.flagChat = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, groupId);
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: req.params.chatId});
+9 -8
View File
@@ -93,7 +93,7 @@ api.getGroups = {
types.forEach(type => {
switch (type) {
case 'party':
queries.push(Group.getGroup(user, 'party', groupFields));
queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields, populateLeader: true}));
break;
case 'privateGuilds':
queries.push(Group.find({
@@ -109,7 +109,7 @@ api.getGroups = {
}).select(groupFields).sort(sort).exec()); // TODO use lean?
break;
case 'tavern':
queries.push(Group.getGroup(user, 'habitrpg', groupFields));
queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields, populateLeader: true}));
break;
}
});
@@ -149,7 +149,7 @@ api.getGroup = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, req.params.groupId);
let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true});
if (!group) throw new NotFound(res.t('groupNotFound'));
res.respond(200, group);
@@ -178,7 +178,7 @@ api.updateGroup = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, req.params.groupId);
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'));
@@ -214,7 +214,8 @@ api.joinGroup = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, req.params.groupId, '-chat', true); // Do not fetch chat and work even if the user is not yet a member of the group
// 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;
@@ -288,7 +289,7 @@ api.leaveGroup = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat
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
@@ -344,7 +345,7 @@ api.removeGroupMember = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat
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;
@@ -523,7 +524,7 @@ api.inviteToGroup = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat TODO other fields too?
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;
+229
View File
@@ -0,0 +1,229 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import cron from '../../middlewares/api-v3/cron';
import {
model as User,
publicFields as memberFields,
nameFields,
} from '../../models/user';
import { model as Group } from '../../models/group';
import { model as Challenge } from '../../models/challenge';
import {
NotFound,
} from '../../libs/api-v3/errors';
import * as Tasks from '../../models/task';
let api = {};
/**
* @api {get} /members/:memberId Get a member profile
* @apiVersion 3.0.0
* @apiName GetMember
* @apiGroup Member
*
* @apiParam {UUID} memberId The member's id
*
* @apiSuccess {object} member The member object
*/
api.getMember = {
method: 'GET',
url: '/members/:memberId',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let memberId = req.params.memberId;
let member = await User
.findById(memberId)
.select(memberFields)
.exec();
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
// manually call toJSON with minimize: true so empty paths aren't returned
res.respond(200, member.toJSON({minimize: true}));
},
};
// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge
// type is `invites` or `members`
function _getMembersForItem (type) {
if (['group-members', 'group-invites', 'challenge-members'].indexOf(type) === -1) {
throw new Error('Type must be one of "group-members", "group-invites", "challenge-members"');
}
return async function handleGetMembersForItem (req, res) {
if (type === 'challenge-members') {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
} else {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
}
req.checkQuery('lastId').optional().notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let groupId = req.params.groupId;
let challengeId = req.params.challengeId;
let lastId = req.query.lastId;
let user = res.locals.user;
let challenge;
let group;
if (type === 'challenge-members') {
challenge = await Challenge.findById(challengeId).select('_id type leader groupId').exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
} else {
group = await Group.getGroup({user, groupId, fields: '_id type'});
if (!group) throw new NotFound(res.t('groupNotFound'));
}
let query = {};
let fields = nameFields;
if (type === 'challenge-members') {
query.challenges = challenge._id;
} else if (type === 'group-members') {
if (group.type === 'guild') {
query.guilds = group._id;
} else {
query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party'
if (req.query.includeAllPublicFields === 'true') {
fields = memberFields;
}
}
} else if (type === 'group-invites') {
if (group.type === 'guild') { // eslint-disable-line no-lonely-if
query['invitations.guilds.id'] = group._id;
} else {
query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party'
}
}
if (lastId) query._id = {$gt: lastId};
let members = await User
.find(query)
.sort({_id: 1})
.limit(30)
.select(fields)
.exec();
// manually call toJSON with minimize: true so empty paths aren't returned
res.respond(200, members.map(member => member.toJSON({minimize: true})));
};
}
/**
* @api {get} /groups/:groupId/members Get members for a group with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
* @apiVersion 3.0.0
* @apiName GetMembersForGroup
* @apiGroup Member
*
* @apiParam {UUID} groupId The group id
* @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
* @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member)
*
* @apiSuccess {array} members An array of members, sorted by _id
*/
api.getMembersForGroup = {
method: 'GET',
url: '/groups/:groupId/members',
middlewares: [authWithHeaders(), cron],
handler: _getMembersForItem('group-members'),
};
/**
* @api {get} /groups/:groupId/invites Get invites for a group with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
* @apiVersion 3.0.0
* @apiName GetInvitesForGroup
* @apiGroup Member
*
* @apiParam {UUID} groupId The group id
* @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results
*
* @apiSuccess {array} invites An array of invites, sorted by _id
*/
api.getInvitesForGroup = {
method: 'GET',
url: '/groups/:groupId/invites',
middlewares: [authWithHeaders(), cron],
handler: _getMembersForItem('group-invites'),
};
/**
* @api {get} /challenges/:challengeId/members Get members for a challenge with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
* @apiVersion 3.0.0
* @apiName GetMembersForChallenge
* @apiGroup Member
*
* @apiParam {UUID} challengeId The challenge id
* @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
*
* @apiSuccess {array} members An array of members, sorted by _id
*/
api.getMembersForChallenge = {
method: 'GET',
url: '/challenges/:challengeId/members',
middlewares: [authWithHeaders(), cron],
handler: _getMembersForItem('challenge-members'),
};
/**
* @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress
* @apiVersion 3.0.0
* @apiName GetChallenge
* @apiGroup Challenge
*
* @apiParam {UUID} challengeId The challenge _id
* @apiParam {UUID} member The member _id
*
* @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member
*/
api.getChallengeMemberProgress = {
method: 'GET',
url: '/challenges/:challengeId/members/:memberId',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let memberId = req.params.memberId;
let member = await User.findById(memberId).select(`${nameFields} challenges`).exec();
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
let challenge = await Challenge.findById(challengeId).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound'));
let chalTasks = await Tasks.Task.find({
userId: memberId,
'challenge.id': challengeId,
})
.select('-tags') // We don't want to return the tags publicly TODO same for other data?
.exec();
// manually call toJSON with minimize: true so empty paths aren't returned
let response = member.toJSON({minimize: true});
delete response.challenges;
response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true}));
res.respond(200, response);
},
};
export default api;
+5 -5
View File
@@ -84,15 +84,15 @@ api.createUserTasks = {
*/
api.createChallengeTasks = {
method: 'POST',
url: '/tasks/challenge/:challengeId',
url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural?
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let reqValidationErrors = req.validationErrors();
if (reqValidationErrors) throw reqValidationErrors;
let user = res.local.user;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findOne({_id: challengeId}).exec();
@@ -188,13 +188,13 @@ api.getChallengeTasks = {
url: '/tasks/challenge/:challengeId',
middlewares: [authWithHeaders(), cron],
async handler (req, res) {
req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.local.user;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec();
+29 -4
View File
@@ -20,10 +20,9 @@ let schema = new Schema({
rewards: [{type: String, ref: 'Task'}],
},
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set?
timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable?
memberCount: {type: Number, default: 0},
challengeCount: {type: Number, default: 0},
prize: {type: Number, default: 0, min: 0}, // TODO no update?
});
@@ -31,11 +30,37 @@ schema.plugin(baseModel, {
noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'],
});
// Returns true if user has access to the challenge (can join)
schema.methods.hasAccess = function hasAccessToChallenge (user) {
let userGroups = user.guilds.slice(0);
if (user.party._id) userGroups.push(user.party._id);
userGroups.push('habitrpg'); // tavern challenges
return this.leader === user._id || userGroups.indexOf(this.groupId) !== -1;
};
// Returns true if user can view the challenge
// Different from hasAccess because challenges of public guilds can be viewed by everyone
schema.methods.canView = function canViewChallenge (user, group) {
if (user.contributor.admin) return true;
if (group.type === 'guild' && group.privacy === 'public') return true;
return this.hasAccess(user);
};
// Returns true if user is a member of the challenge
schema.methods.isMember = function isChallengeMember (user) {
return user.challenges.indexOf(this._id) !== -1;
};
// Returns true if the user can modify (close, selectWinner, ...) the challenge
schema.methods.canModify = function canModifyChallenge (user) {
return user.contributor.admin || this.leader === user._id;
};
// Takes a Task document and return a plain object of attributes that can be synced to the user
function _syncableAttrs (task) {
let t = task.toObject(); // lodash doesn't seem to like _.omit on Document
// only sync/compare important attrs
let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt?
let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt?
if (t.type !== 'reward') omitAttrs.push('value');
return _.omit(t, omitAttrs);
}
@@ -143,7 +168,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) {
tasksOrderList.$each.unshift(userTask._id);
}
toSave.push(userTask);
toSave.push(userTask.save());
});
// Update the user
+25 -9
View File
@@ -1,5 +1,8 @@
import mongoose from 'mongoose';
import { model as User} from './user';
import {
model as User,
nameFields,
} from './user';
import shared from '../../../common';
import _ from 'lodash';
import { model as Challenge} from './challenge';
@@ -40,7 +43,6 @@ export let schema = new Schema({
balance: {type: Number, default: 0},
logo: String,
leaderMessage: String,
// challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID}))
quest: {
key: String,
active: {type: Boolean, default: false},
@@ -57,6 +59,7 @@ export let schema = new Schema({
// 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them.
// TODO when booting user, remove from .joined and check again if we can now start the quest
// TODO as long as quests are party only we can keep it here
// TODO are we sure we need this type of default for this to work?
members: {type: Schema.Types.Mixed, default: () => {
return {};
}},
@@ -125,8 +128,8 @@ schema.post('remove', function postRemoveGroup (group) {
firebase.deleteGroup(group._id);
});
// TODO populate (invites too), isMember?
schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) {
schema.statics.getGroup = function getGroup (options = {}) {
let {user, groupId, fields, optionalMembership = false, populateLeader = false} = options;
let query;
// When optionalMembership is true it's not required for the user to be a member of the group
@@ -142,10 +145,22 @@ schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMemb
let mQuery = this.findOne(query);
if (fields) mQuery.select(fields);
return mQuery.exec(); // TODO catch errors here?
if (populateLeader === true) mQuery.populate('leader', nameFields);
return mQuery.exec();
// TODO purge chat flags info? in tojson?
};
// Return true if user is a member of the group
schema.methods.isMember = function isGroupMember (user) {
if (this._id === 'habitrpg') {
return true; // everyone is considered part of the tavern
} else if (this.type === 'party') {
return user.party._id === this._id ? true : false;
} else { // guilds
return user.guilds.indexOf(this._id) !== -1;
}
};
export function chatDefaults (msg, user) {
let message = {
id: shared.uuid(),
@@ -504,6 +519,7 @@ schema.methods.leave = function leaveGroup (user, keep) {
});
};
export const INVITES_LIMIT = 100;
export let model = mongoose.model('Group', schema);
// initialize tavern if !exists (fresh installs)
@@ -512,12 +528,12 @@ model.count({_id: 'habitrpg'}, (err, ct) => {
if (ct > 0) return;
new model({ // eslint-disable-line babel/new-cap
_id: 'habitrpg', // TODO hmm this will probably break everything
_id: 'habitrpg',
leader: '9', // TODO change this user id
name: 'HabitRPG',
type: 'guild',
privacy: 'public',
}).save();
}).save({
validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise
}); // TODO catch/log?
});
export const INVITES_LIMIT = 100;
+16 -6
View File
@@ -338,7 +338,6 @@ export let schema = new Schema({
orderAscending: {type: String, default: 'ascending'},
quest: {
key: String,
// TODO why are we storing quest progress here too and not only on party object?
progress: {
up: {type: Number, default: 0},
down: {type: Number, default: 0},
@@ -473,23 +472,34 @@ export let schema = new Schema({
},
}, {
strict: true,
minimize: false, // So empty objects are returned
minimize: false, // So empty objects are returned TODO make sure it's in every model
});
schema.plugin(baseModel, {
// TODO revisit a lot of things are missing
noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance'],
// TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated
noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password',
'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest',
'invitations', 'balance', 'backer', 'contributor'],
private: ['auth.local.hashed_password', 'auth.local.salt'],
toJSONTransform: function userToJSON (doc) {
// FIXME? Is this a reference to `doc.filters` or just disabled code? Remove?
// TODO this works?
doc.filters = {};
doc._tmp = this._tmp; // be sure to send down drop notifs
// doc.filters = {};
// doc._tmp = this._tmp; // be sure to send down drop notifs
return doc;
},
});
// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private)
// TODO is all party data meant to be public?
export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt
preferences.costume preferences.sleep preferences.background profile stats achievements party
backer contributor auth.timestamps items`;
// The minimum amount of data needed when populating multiple users
export let nameFields = `profile.name`;
schema.post('init', function postInitUser (doc) {
shared.wrap(doc);
});