diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c934ad7446..c87946e717 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -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\".", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index fa3a71b818..7eaeb940d5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -167,7 +167,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}, diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 48dc2e440d..7c2d42bf62 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -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 = {}; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js new file mode 100644 index 0000000000..7bc719d7d4 --- /dev/null +++ b/website/src/controllers/api-v3/members.js @@ -0,0 +1,157 @@ +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 { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +// TODO allow only to select nameFields instead of all publicFields? +/** + * @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})); + + res.respond(200, member); + }, +}; + +// TODO allow to get more members' fields (the same as in api.getMember) for parties? +/** + * @api {get} /groups/:groupId/members Get members for a groups 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], + async handler (req, res) { + 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 lastId = req.query.lastId; + let user = res.locals.user; + + let group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let query = {}; + let fields = nameFields; + + 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; + } + } + + if (lastId) query._id = {$gt: lastId}; + + let members = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(fields) + .exec(); + + res.respond(200, members); + }, +}; + +// TODO very similar to getInvitesForGroup might be worth abstracting some logic +/** + * @api {get} /groups/:groupId/invites Get invites for a groups 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], + async handler (req, res) { + 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 lastId = req.query.lastId; + let user = res.locals.user; + + let group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let query = {}; + + if (group.type === 'guild') { + 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 invites = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(nameFields) + .exec(); + + res.respond(200, invites); + }, +}; + +export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 27ca80e73f..4770c5803d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -125,7 +125,6 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -// TODO populate (invites too), isMember? schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) { let query; diff --git a/website/src/models/user.js b/website/src/models/user.js index 8dff4bcafc..0738d48e02 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -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}, @@ -489,6 +488,15 @@ schema.plugin(baseModel, { }, }); +// 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); });