mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 22:57:21 +01:00
* create Admin Panel page with initial content from Hall's admin section * reorganise Admin Panel form and add more accordians * add lastCron to fields returned by api.getHeroes * improve timestamps and authentication section * add party and quest info to Admin Panel, add party to heroAdminFields * move Admin Panel menu item to top of menu, make invisible to non-admins * remove code used for displaying all Heroes * add avatar appearance and drops section in Admin Panel * allow logged-in user to be the default hero loaded * add time zones to timestamp/authentication section * rename Items to Update Items This will allow a new Items section to be added. * add read-only Items display with button to copy data to Update Items section * remove never-used allItemsPaths code that had been copied from Hall * update tests for the attributes added to heroAdminFields * supply names for items and also set information for gear/equipment * remove code that loads subsections of content We use enough of the content that it's easier to load it all and access it through the content object, especially when we're looping through different item types. * add gear names and set details to Avatar Costume/Battle Gear section * make the wiki URLs clickable and make minor item format improvements * add gear sets for Check-In Incentives and animal ears and tails * add gear set for Gold-Purchasable Quest Lines Also merges the existing Mystery of the Masterclassers quest set into it. * fix error with Kickstarter gear set and include wiki link * improve description of check-in incentive gear set * fix description of Items section * fix lint warnings * update another test for the attributes added to heroAdminFields * allow "@" to be included when specifying Username to load * create GetHeroParty API v3 route to fetch a given user's party data Only some data from the party will be loaded (e.g., not private data such as name, description). Includes tests for the route. See the next commit for front-end changes that use this. * display data from a given user's party in admin panel Only some data from the party will be loaded (e.g., not private data such as name, description). Also adds support for finding and displaying errors from the user's data. * use new error handling method for other sections - Time zone differences - Cron bugs - Privilege removal (mute/block) - not a bug but needs to be highlighted * redirect non-admin users away from admin-only page (WIP) This needs more work. Currently, admin users are also redirected if they access the page by direct URL or after reload. * clarify source of items from Check-In Incentives and Lunar Battle quests * replace non-standard form fields with HTML forms * add user's language, remove unused export blocks * convert functions to filters: formatDate, formatTimeZone * improve display of minutes portion of time zone in Admin Panel * move basic details about user to a new component * move Timestamp/Cron/Auth/etc details to a new component - WIP, has errors The automatic expand and error warnings don't reset themselves when you fetch data for a new user. * replace non-standard form fields with HTML forms Most of this was done in26fdcbbee5* move Timestamp/Cron/Auth/etc details to a new component (fixed) * move Avatar and Drops section to a new component * move Party and Quest section to a new component * move Contributor Details to new component, add checkbox for admin, add preview This adds a markdown-enabled preview of the Contributions textarea. It also removes the code that automatically set contributor.admin to true when the Tier was above 7. That feature wasn't secure because the Tier can be accidentally changed if you scroll while the cursor is over the Tier form field (we accidentally demoted a Socialite once by doing that and if we'd scrolled in the other direction we would have given her admin privileges). Instead there's now a checkbox for giving moderator-level privileges. We'll want that anyway when we move to a system of selected privileges for each admin instead of all admin privileges being given to all mods/staff. There's also a commented-out checkbox for giving Bailey CMS privileges, for when we're ready to use that. The User model doesn't yet have support for it. * move Privileges and Gems section to a new component * rename formatItems to getItemDescription; make other minor fixes * remove an outdated test description This "pended" explanation probably wasn't needed after "x" was removed from "describe" in2ab76db27c* add newsPoster Bailey CMS permission to User model and Admin Panel * move formatDate from mixins to filters * make lint fixes * remove development comments from hall.js I'll be handling the TODO comment and I've left in my "XXX" marker to remind me * fix bug in Hall's castItemVal: mounts are null not false * move Items section to a new component and delete Update Items section The Update Items section is no longer needed because the new Items component has in-place editing. * remove unused imports * add "secret" field to "Privileges, Gem Balance" section. Also move the markdownPreview style from contributorDetails.vue to index.vue since it's used in two components now. * show non-Standard never-owned Pets and Mounts in Items section * redirect non-admin users away from admin-only page This completes the work started in commita4f9c754adIt now allows admins to access the page when coming from another page on the site or from a direct link, including if the admin user isn't logged in yet. * display memberCount for party * add secret.text field to Contributor Details This is in addition to showing it in the Privileges section because the secret text could be about either troublesome behaviour or contributions. * allow user to be loaded into Admin Panel via a URL This includes: - router config has a child route for the admin panel with a Username/ID as a parameter - loadHero code moved from top-level index page into a new "user support" index page - links in the Hall changed to point to admin panel route - admin panel link added to admin section of user profile modal * keep list of known titles on their own lines * sort heroFields alphabetically No actual changes. * return all flags for use in Admin Panel and fix Hall tests for flags Future Admin Panel changes will display more flags. NB 'flags' wasn't in the tests before, even though two optional flags were being fetched. The tests weren't failing because the test users hadn't been given data for those optional flags. The primary reason for this change now is to fix the tests. * show part of the API Token in the Admin Panel * send full hero object into cronAndAuth.vue This is a prelude to allowing this component to change the hero. * split heroAdminFields string into two: one for fetching data and one for showing it This is because apiToken must be fetched but not shown, while apiTokenObscured is calculated (not fetched) and shown. * let admin change a user's API Token * restore sanity * remove code to show obscured version of API Token It will return with tighter permissions for viewing it. * add Custom Day Start time (CDS) to Timestamps, Time Zone... section * commit lint's automatic fixes - one for admin-panel changes in hall.js The other fixes aren't related to this PR but I figured they may as well go live. * apply fixes from paglias's comments, excluding style/CSS changesd The comments that this PR fixes start at https://github.com/HabitRPG/habitica/pull/12035#pullrequestreview-500422316 Style fixes will be in a future commit. * fix styles/CSS * allow profile modal to close when using admin panel link Also removes an empty components block. * prevent Admin Panel being used without new userSupport privilege Also adds initial support for other contributor.priv privileges and changes Debug Menu to add userSupport privilege * don't do this: this.hero = { ...hero }; * enhance quest error messages * redirect to admin-panel home page when using "Save and Clear Data" The user's ID / name is still in the form for easy refetching. * create ensurePriv function, use in api.getHeroParty * fix lint problems and integration tests * add page title to top-level Admin Panel Also add more details to a router comment (consistent with a similar comment) in case it helps anyone. * fix tests * display Moderation Notes above Contributions * lint fix * remove placeholder code for new privileges I had planned to have each of these implemented in stages, but paglias wanted it all done at once. I'm afraid that's too big a project for me to take on in a single PR so I'm cancelling the plans for adjusting the privileges. * Improve permission handling * Don't report timezone error on first day * fix lint error * . * Fix lint error * fix failing tests * Fix more tests * . * .. * ... * fix(admin): always include permissions when querying user also remove unnecessary failing test case * permission improvements * show transactions in admin panel * fix lint errors * fix permission check * fix(panel): missing mixin, handle empty perms object Co-authored-by: Alys <alice.harris@oldgods.net> Co-authored-by: SabreCat <sabe@habitica.com>
778 lines
27 KiB
JavaScript
778 lines
27 KiB
JavaScript
import escapeRegExp from 'lodash/escapeRegExp';
|
|
import { authWithHeaders } from '../../middlewares/auth';
|
|
import {
|
|
model as User,
|
|
publicFields as memberFields,
|
|
nameFields,
|
|
} from '../../models/user';
|
|
import {
|
|
KNOWN_INTERACTIONS,
|
|
} from '../../models/user/methods';
|
|
import { model as Group } from '../../models/group';
|
|
import { model as Challenge } from '../../models/challenge';
|
|
import {
|
|
NotFound,
|
|
NotAuthorized,
|
|
} from '../../libs/errors';
|
|
import * as Tasks from '../../models/task';
|
|
import {
|
|
getUserInfo,
|
|
sendTxn as sendTxnEmail,
|
|
} from '../../libs/email';
|
|
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
|
|
import common from '../../../common';
|
|
import { sentMessage } from '../../libs/inbox';
|
|
import {
|
|
sanitizeText as sanitizeMessageText,
|
|
} from '../../models/message';
|
|
import highlightMentions from '../../libs/highlightMentions';
|
|
import { handleGetMembersForChallenge } from '../../libs/challenges/handleGetMembersForChallenge';
|
|
|
|
const { achievements } = common;
|
|
|
|
const api = {};
|
|
|
|
/**
|
|
* @api {get} /api/v3/members/:memberId Get a member profile
|
|
* @apiName GetMember
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Path) {UUID} memberId The member's id
|
|
*
|
|
* @apiSuccess {Object} data The member object
|
|
*
|
|
* @apiSuccess {Object} data.inbox Basic information about person's inbox
|
|
* @apiSuccess {Object} data.stats Includes current stats and buffs
|
|
* @apiSuccess {Object} data.profile Includes name
|
|
* @apiSuccess {Object} data.preferences Includes info about appearance and public prefs
|
|
* @apiSuccess {Object} data.party Includes basic info about current party and quests
|
|
* @apiSuccess {Object} data.items Basic inventory information includes quests,
|
|
* food, potions, eggs, gear, special items
|
|
* @apiSuccess {Object} data.achievements Lists current achievements
|
|
* @apiSuccess {Object} data.auth Includes latest timestamps
|
|
*
|
|
* @apiSuccessExample {json} Success-Response:
|
|
* {
|
|
* "success": true,
|
|
* "data": {
|
|
* "_id": "99999999-9999-9999-9999-8f14c101aeff",
|
|
* "inbox": {
|
|
* "optOut": false
|
|
* },
|
|
* "stats": {
|
|
* ---INCLUDES STATS AND BUFFS---
|
|
* },
|
|
* "profile": {
|
|
* "name": "Ezra"
|
|
* },
|
|
* "preferences": {
|
|
* ---INCLUDES INFO ABOUT APPEARANCE AND PUBLIC PREFS---
|
|
* },
|
|
* "party": {
|
|
* "_id": "12345678-0987-abcd-82a6-837c81db4c1e",
|
|
* "quest": {
|
|
* "RSVPNeeded": false,
|
|
* "progress": {}
|
|
* },
|
|
* },
|
|
* "items": {
|
|
* "lastDrop": {
|
|
* "count": 0,
|
|
* "date": "2017-01-15T02:41:35.009Z"
|
|
* },
|
|
* ----INCLUDES QUESTS, FOOD, POTIONS, EGGS, GEAR, CARDS, SPECIAL ITEMS (E.G. SNOWBALLS)----
|
|
* }
|
|
* },
|
|
* "achievements": {
|
|
* "partyUp": true,
|
|
* "habitBirthdays": 2,
|
|
* },
|
|
* "auth": {
|
|
* "timestamps": {
|
|
* "loggedin": "2017-03-05T12:30:54.545Z",
|
|
* "created": "2017-01-12T03:30:11.842Z"
|
|
* }
|
|
* },
|
|
* "id": "99999999-9999-9999-9999-8f14c101aeff"
|
|
* }
|
|
* }
|
|
*)
|
|
*
|
|
* @apiUse UserNotFound
|
|
*/
|
|
api.getMember = {
|
|
method: 'GET',
|
|
url: '/members/:memberId',
|
|
middlewares: [],
|
|
async handler (req, res) {
|
|
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const { memberId } = req.params;
|
|
|
|
const member = await User
|
|
.findById(memberId)
|
|
.select(memberFields)
|
|
.exec();
|
|
|
|
if (!member) throw new NotFound(res.t('userWithIDNotFound', { userId: memberId }));
|
|
|
|
if (!member.flags.verifiedUsername) member.auth.local.username = null;
|
|
|
|
// manually call toJSON with minimize: true so empty paths aren't returned
|
|
const memberToJSON = member.toJSON({ minimize: true });
|
|
User.addComputedStatsToJSONObj(memberToJSON.stats, member);
|
|
|
|
res.respond(200, memberToJSON);
|
|
},
|
|
};
|
|
|
|
api.getMemberByUsername = {
|
|
method: 'GET',
|
|
url: '/members/username/:username',
|
|
middlewares: [],
|
|
async handler (req, res) {
|
|
req.checkParams('username', res.t('invalidReqParams')).notEmpty();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
let username = req.params.username.toLowerCase();
|
|
if (username[0] === '@') username = username.slice(1, username.length);
|
|
|
|
const member = await User
|
|
.findOne({ 'auth.local.lowerCaseUsername': username, 'flags.verifiedUsername': true })
|
|
.select(memberFields)
|
|
.exec();
|
|
|
|
if (!member) throw new NotFound(res.t('userNotFound'));
|
|
|
|
// manually call toJSON with minimize: true so empty paths aren't returned
|
|
const memberToJSON = member.toJSON({ minimize: true });
|
|
User.addComputedStatsToJSONObj(memberToJSON.stats, member);
|
|
|
|
res.respond(200, memberToJSON);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/members/:memberId/achievements Get member achievements object
|
|
* @apiName GetMemberAchievements
|
|
* @apiGroup Member
|
|
* @apiDescription Get a list of achievements
|
|
* of the requested member, grouped by basic / seasonal / special.
|
|
*
|
|
* @apiParam (Path) {UUID} memberId The member's id
|
|
*
|
|
* @apiSuccess {Object} data The achievements object
|
|
*
|
|
* @apiSuccess {Object} data.basic The basic achievements object
|
|
* @apiSuccess {Object} data.seasonal The seasonal achievements object
|
|
* @apiSuccess {Object} data.special The special achievements object
|
|
*
|
|
* @apiSuccess {String} data.label The label for that category
|
|
* @apiSuccess {Object} data.achievements The achievements in that category
|
|
*
|
|
* @apiSuccess {String} data.achievements.title The localized title string
|
|
* @apiSuccess {String} data.achievements.text The localized description string
|
|
* @apiSuccess {Boolean} data.achievements.earned Whether the user has earned the achievement
|
|
* @apiSuccess {Number} data.achievements.index The unique index assigned
|
|
* to the achievement (only for sorting purposes).
|
|
* @apiSuccess {Anything} data.achievements.value The value related to the achievement
|
|
* (if applicable)
|
|
* @apiSuccess {Number} data.achievements.optionalCount The count related to the achievement
|
|
* (if applicable)
|
|
*
|
|
* @apiSuccessExample {json} Successful Response
|
|
* {
|
|
* basic: {
|
|
* label: "Basic",
|
|
* achievements: {
|
|
* streak: {
|
|
* title: "0 Streak Achievements",
|
|
* text: "Has performed 0 21-day streaks on Dailies",
|
|
* icon: "achievement-thermometer",
|
|
* earned: false,
|
|
* value: 0,
|
|
* index: 60,
|
|
* optionalCount: 0
|
|
* },
|
|
* perfect: {
|
|
* title: "5 Perfect Days",
|
|
* text: "Completed all active Dailies on 5 days. With this achievement
|
|
* you get a +level/2 buff to all attributes for the next day.
|
|
* Levels greater than 100 don't have any additional effects on buffs.",
|
|
* icon: "achievement-perfect",
|
|
* earned: true,
|
|
* value: 5,
|
|
* index: 61,
|
|
* optionalCount: 5
|
|
* }
|
|
* }
|
|
* },
|
|
* seasonal: {
|
|
* label: "Seasonal",
|
|
* achievements: {
|
|
* habiticaDays: {
|
|
* title: "Habitica Naming Day",
|
|
* text: "Celebrated 0 Naming Days! Thanks for being a fantastic user.",
|
|
* icon: "achievement-habiticaDay",
|
|
* earned: false,
|
|
* value: 0,
|
|
* index: 72,
|
|
* optionalCount: 0
|
|
* }
|
|
* }
|
|
* },
|
|
* special: {
|
|
* label: "Special",
|
|
* achievements: {
|
|
* habitSurveys: {
|
|
* title: "Helped Habitica Grow",
|
|
* text: "Helped Habitica grow on 0 occasions, either by filling out
|
|
* a survey or helping with a major testing effort. Thank you!",
|
|
* icon: "achievement-tree",
|
|
* earned: false,
|
|
* value: 0,
|
|
* index: 88,
|
|
* optionalCount: 0
|
|
* }
|
|
* }
|
|
* }
|
|
* }
|
|
*
|
|
* @apiError (400) {BadRequest} MemberIdRequired The `id` param is required
|
|
* and must be a valid `UUID`.
|
|
* @apiError (404) {NotFound} UserWithIdNotFound The `id` param did not
|
|
* belong to an existing member.
|
|
*/
|
|
api.getMemberAchievements = {
|
|
method: 'GET',
|
|
url: '/members/:memberId/achievements',
|
|
middlewares: [],
|
|
async handler (req, res) {
|
|
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const { memberId } = req.params;
|
|
|
|
const member = await User
|
|
.findById(memberId)
|
|
.select(memberFields)
|
|
.exec();
|
|
|
|
if (!member) throw new NotFound(res.t('userWithIDNotFound', { userId: memberId }));
|
|
|
|
const achievsObject = achievements.getAchievementsForProfile(member, req.language);
|
|
|
|
res.respond(200, achievsObject);
|
|
},
|
|
};
|
|
|
|
// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge
|
|
|
|
// @TODO: This violates the Liskov substitution principle.
|
|
// We should create factory functions. See Webhooks for a good example
|
|
function _getMembersForItem (type) {
|
|
// check for allowed `type`
|
|
if (['group-members', 'group-invites'].indexOf(type) === -1) {
|
|
throw new Error('Type must be one of "group-members", "group-invites"');
|
|
}
|
|
|
|
return async function handleGetMembersForItem (req, res) {
|
|
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
|
req.checkQuery('lastId').optional().notEmpty().isUUID();
|
|
// Allow an arbitrary number of results (up to 60)
|
|
req.checkQuery('limit', res.t('groupIdRequired')).optional().notEmpty().isInt({ min: 1, max: 60 });
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const { groupId } = req.params;
|
|
const { lastId } = req.query;
|
|
const { user } = res.locals;
|
|
|
|
const group = await Group.getGroup({ user, groupId, fields: '_id type' });
|
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
|
|
const query = {};
|
|
let fields = nameFields;
|
|
// add computes stats to the member info when items and stats are available
|
|
let addComputedStats = false;
|
|
|
|
if (type === 'group-members') {
|
|
if (group.type === 'guild') {
|
|
query.guilds = group._id;
|
|
|
|
if (req.query.includeAllPublicFields === 'true') {
|
|
fields = memberFields;
|
|
addComputedStats = true;
|
|
}
|
|
} else {
|
|
query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party'
|
|
|
|
if (req.query.includeAllPublicFields === 'true') {
|
|
fields = memberFields;
|
|
addComputedStats = true;
|
|
}
|
|
}
|
|
|
|
if (req.query.search) {
|
|
// Creates a RegExp expression when querying for profile.name and auth.local.username
|
|
const escapedSearch = escapeRegExp(req.query.search);
|
|
query.$or = [
|
|
{ 'profile.name': { $regex: new RegExp(escapedSearch, 'i') } },
|
|
{ 'auth.local.username': { $regex: new RegExp(req.query.search, 'i') } },
|
|
];
|
|
}
|
|
} else if (type === 'group-invites') {
|
|
if (group.type === 'guild') { // eslint-disable-line no-lonely-if
|
|
query['invitations.guilds.id'] = group._id;
|
|
|
|
if (req.query.includeAllPublicFields === 'true') {
|
|
fields = memberFields;
|
|
addComputedStats = true;
|
|
}
|
|
} else {
|
|
query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party'
|
|
// @TODO invitations are now stored like this: `'invitations.parties': []`
|
|
// Probably need a database index for it.
|
|
if (req.query.includeAllPublicFields === 'true') {
|
|
fields = memberFields;
|
|
addComputedStats = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastId) query._id = { $gt: lastId };
|
|
|
|
const limit = req.query.limit ? Number(req.query.limit) : 30;
|
|
|
|
const members = await User
|
|
.find(query)
|
|
.sort({ _id: 1 })
|
|
.limit(limit)
|
|
.select(fields)
|
|
.lean()
|
|
.exec();
|
|
|
|
// manually call toJSON with minimize: true so empty paths aren't returned
|
|
members.forEach(member => User.transformJSONUser(member, addComputedStats));
|
|
res.respond(200, members);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @api {get} /api/v3/groups/:groupId/members Get members for a group
|
|
* @apiDescription With a limit of 30 member per request (by default).
|
|
* To get all members run requests against this routes (updating the lastId query parameter)
|
|
* until you get less than 30 results (or the specified limit).
|
|
* @apiName GetMembersForGroup
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Path) {UUID} groupId The group id ('party' for the user party is accepted)
|
|
* @apiParam (Query) {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 (Query) {Number} limit=30 BETA Query parameter
|
|
* to specify the number of results to return. Max is 60.
|
|
* @apiParam (Query) {Boolean} includeAllPublicFields If set to `true`
|
|
* then all public fields for members
|
|
* will be returned (similar to when making
|
|
* a request for a single member).
|
|
* @apiParam (Query) {Boolean} includeTasks If set to `true`, then
|
|
* response should include all tasks per user
|
|
* related to the challenge
|
|
*
|
|
* @apiSuccess {Array} data An array of members, sorted by _id
|
|
*
|
|
* @apiSuccessExample {json} Success-Response:
|
|
* {
|
|
* "success": true,
|
|
* "data": [
|
|
* {
|
|
* "_id": "00000001-1111-9999-9000-111111111111",
|
|
* "profile": {
|
|
* "name": "Jiminy"
|
|
* },
|
|
* "id": "00000001-1111-9999-9000-111111111111"
|
|
* },
|
|
* }
|
|
*
|
|
*
|
|
* @apiUse ChallengeNotFound
|
|
* @apiUse GroupNotFound
|
|
*/
|
|
api.getMembersForGroup = {
|
|
method: 'GET',
|
|
url: '/groups/:groupId/members',
|
|
middlewares: [authWithHeaders()],
|
|
handler: _getMembersForItem('group-members'),
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/groups/:groupId/invites Get invites for a group
|
|
* @apiDescription With a limit of 30 member per request (by default). To get all invites run
|
|
* requests against this routes (updating the lastId query parameter)
|
|
* until you get less than 30 results.
|
|
* @apiName GetInvitesForGroup
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Path) {UUID} groupId The group id ('party' for the user party is accepted)
|
|
* @apiParam (Query) {UUID} lastId Query parameter to specify the last invite
|
|
* returned in a previous request to this route and
|
|
* get the next batch of results.
|
|
* @apiParam (Query) {Number} limit=30 BETA Query parameter
|
|
* to specify the number of results to return. Max is 60.
|
|
* @apiParam (Query) {Boolean} includeAllPublicFields If set to `true`
|
|
* then all public fields for members
|
|
* will be returned (similar to when making
|
|
* a request for a single member).
|
|
*
|
|
* @apiSuccess {array} data An array of invites, sorted by _id
|
|
*
|
|
* @apiSuccessExample {json} Success-Response:
|
|
* {
|
|
* "success": true,
|
|
* "data": [
|
|
* {
|
|
* "_id": "99f3cb9d-4af8-4ca4-9b82-6b2a6bf59b7a",
|
|
* "profile": {
|
|
* "name": "DoomSmoocher"
|
|
* },
|
|
* "id": "99f3cb9d-4af8-4ca4-9b82-6b2a6bf59b7a"
|
|
* }
|
|
* ]
|
|
* }
|
|
*
|
|
*
|
|
* @apiUse ChallengeNotFound
|
|
* @apiUse GroupNotFound
|
|
*/
|
|
api.getInvitesForGroup = {
|
|
method: 'GET',
|
|
url: '/groups/:groupId/invites',
|
|
middlewares: [authWithHeaders()],
|
|
handler: _getMembersForItem('group-invites'),
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/challenges/:challengeId/members Get members for a challenge
|
|
* @apiDescription With a limit of 30 member per request (by default).
|
|
* To get all members run requests against this routes (updating the lastId query parameter)
|
|
* until you get less than 30 results.
|
|
* BETA You can also use ?includeAllMembers=true. This option is currently in BETA
|
|
* and may be removed in future.
|
|
* Its use is discouraged and its performances are not optimized especially for large challenges.
|
|
*
|
|
* @apiName GetMembersForChallenge
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Path) {UUID} challengeId The challenge id
|
|
* @apiParam (Query) {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 (Query) {Number} limit=30 BETA Query parameter to
|
|
* specify the number of results to return. Max is 60.
|
|
* @apiParam (Query) {Boolean} includeTasks BETA Query parameter - If 'true'
|
|
* then include challenge tasks of each member
|
|
* @apiParam (Query) {Boolean} includeAllPublicFields If set to `true`
|
|
* then all public fields for members
|
|
* will be returned (similar to when making
|
|
* a request for a single member).
|
|
|
|
* @apiSuccess {Array} data An array of members, sorted by _id
|
|
*
|
|
* @apiUse ChallengeNotFound
|
|
* @apiUse GroupNotFound
|
|
*/
|
|
api.getMembersForChallenge = {
|
|
method: 'GET',
|
|
url: '/challenges/:challengeId/members',
|
|
middlewares: [authWithHeaders()],
|
|
handler: handleGetMembersForChallenge,
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/challenges/:challengeId/members/:memberId Get a challenge member progress
|
|
* @apiName GetChallengeMemberProgress
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Path) {UUID} challengeId The challenge _id
|
|
* @apiParam (Path) {UUID} memberId The member _id
|
|
*
|
|
* @apiSuccess {Object} data Return an object with member _id, profile.name
|
|
* and a tasks object with the challenge tasks for the member.
|
|
*
|
|
* @apiSuccessExample {json} Success-Response:
|
|
* {
|
|
* "data": {
|
|
* "_id": "b0413351-405f-416f-8787-947ec1c85199",
|
|
* "profile": {"name": "MadPink"},
|
|
* "tasks": [
|
|
* {
|
|
* "_id": "9cd37426-0604-48c3-a950-894a6e72c156",
|
|
* "text": "Make sure the place where you sleep is quiet, dark, and cool.",
|
|
* "updatedAt": "2017-06-17T17:44:15.916Z",
|
|
* "createdAt": "2017-06-17T17:44:15.916Z",
|
|
* "reminders": [],
|
|
* "group": {
|
|
* "approval": {
|
|
* "requested": false,
|
|
* "approved": false,
|
|
* "required": false
|
|
* },
|
|
* "assignedUsers": []
|
|
* },
|
|
* "challenge": {
|
|
* "taskId": "6d3758b1-071b-4bfa-acd6-755147a7b5f6",
|
|
* "id": "4db6bd82-b829-4bf2-bad2-535c14424a3d",
|
|
* "shortName": "Take This June 2017"
|
|
* },
|
|
* "attribute": "str",
|
|
* "priority": 1,
|
|
* "value": 0,
|
|
* "notes": "",
|
|
* "type": "todo",
|
|
* "checklist": [],
|
|
* "collapseChecklist": false,
|
|
* "completed": false,
|
|
* },
|
|
* "startDate": "2016-09-01T05:00:00.000Z",
|
|
* "everyX": 1,
|
|
* "frequency": "weekly",
|
|
* "id": "b207a15e-8bfd-4aa7-9e64-1ba89699da06"
|
|
* }
|
|
* ]
|
|
* }
|
|
*
|
|
* @apiUse ChallengeNotFound
|
|
* @apiUse UserNotFound
|
|
*/
|
|
api.getChallengeMemberProgress = {
|
|
method: 'GET',
|
|
url: '/challenges/:challengeId/members/:memberId',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
|
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const { user } = res.locals;
|
|
const { challengeId } = req.params;
|
|
const { memberId } = req.params;
|
|
|
|
const member = await User.findById(memberId).select(`${nameFields} challenges`).exec();
|
|
if (!member) throw new NotFound(res.t('userWithIDNotFound', { userId: memberId }));
|
|
const challenge = await Challenge.findById(challengeId).exec();
|
|
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
|
// optionalMembership is set to true because even if you're
|
|
// not member of the group you may be able to access the challenge
|
|
// for example if you've been booted from it, are the leader or a site admin
|
|
const group = await Group.getGroup({
|
|
user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true,
|
|
});
|
|
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
|
if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound'));
|
|
|
|
const challengeTasks = await Tasks.Task.find({
|
|
userId: member._id,
|
|
'challenge.id': challenge._id,
|
|
})
|
|
.select('-tags -checklist') // We don't want to return tags and checklists publicly
|
|
.lean()
|
|
.exec();
|
|
|
|
// manually call toJSON with minimize: true so empty paths aren't returned
|
|
const response = member.toJSON({ minimize: true });
|
|
delete response.challenges;
|
|
response.tasks = challengeTasks;
|
|
res.respond(200, response);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/members/:toUserId/objections/:interaction Get objections to interaction
|
|
* @apiDescription Get any objections that would occur
|
|
* if the given interaction was attempted - BETA.
|
|
*
|
|
* @apiVersion 3.0.0
|
|
* @apiName GetObjectionsToInteraction
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Path) {UUID} toUserId The user to interact with
|
|
* @apiParam (Path) {String="send-private-message","transfer-gems"} interaction Name of the
|
|
* interaction
|
|
* to query.
|
|
*
|
|
* @apiSuccess {Array} data Return an array of objections,
|
|
* if the interaction would be blocked; otherwise an empty array.
|
|
*/
|
|
api.getObjectionsToInteraction = {
|
|
method: 'GET',
|
|
url: '/members/:toUserId/objections/:interaction',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
req.checkParams('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
|
|
req.checkParams('interaction', res.t('interactionRequired')).notEmpty().isIn(KNOWN_INTERACTIONS);
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const sender = res.locals.user;
|
|
const receiver = await User.findById(req.params.toUserId).exec();
|
|
if (!receiver) throw new NotFound(res.t('userWithIDNotFound', { userId: req.params.toUserId }));
|
|
|
|
const { interaction } = req.params;
|
|
const response = sender.getObjectionsToInteraction(interaction, receiver);
|
|
|
|
res.respond(200, response.map(res.t));
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/members/send-private-message Send a private message to a member
|
|
* @apiName SendPrivateMessage
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Body) {String} message The message
|
|
* @apiParam (Body) {UUID} toUserId The id of the user to contact
|
|
*
|
|
* @apiSuccess {Object} data.message The message just sent
|
|
*
|
|
* @apiUse UserNotFound
|
|
*/
|
|
api.sendPrivateMessage = {
|
|
method: 'POST',
|
|
url: '/members/send-private-message',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
req.checkBody('message', res.t('messageRequired')).notEmpty();
|
|
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const sender = res.locals.user;
|
|
const sanitizedMessageText = sanitizeMessageText(req.body.message);
|
|
const message = (await highlightMentions(sanitizedMessageText))[0];
|
|
|
|
const receiver = await User.findById(req.body.toUserId).exec();
|
|
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
|
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
|
|
|
|
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
|
|
if (objections.length > 0 && !sender.hasPermission('moderator')) throw new NotAuthorized(res.t(objections[0]));
|
|
|
|
const messageSent = await sentMessage(sender, receiver, message, res.t);
|
|
|
|
res.respond(200, { message: messageSent });
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/members/transfer-gems Send a gem gift to a member
|
|
* @apiName TransferGems
|
|
* @apiGroup Member
|
|
*
|
|
* @apiParam (Body) {String} message The message to the user
|
|
* @apiParam (Body) {UUID} toUserId The user to send the gift to
|
|
* @apiParam (Body) {Integer} gemAmount The number of gems to send
|
|
*
|
|
* @apiSuccess {Object} data An empty Object
|
|
*
|
|
* @apiUse UserNotFound
|
|
*/
|
|
api.transferGems = {
|
|
method: 'POST',
|
|
url: '/members/transfer-gems',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
|
|
req.checkBody('gemAmount', res.t('gemAmountRequired')).notEmpty().isInt();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const sender = res.locals.user;
|
|
const receiver = await User.findById(req.body.toUserId).exec();
|
|
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
|
|
|
const objections = sender.getObjectionsToInteraction('transfer-gems', receiver);
|
|
if (objections.length > 0) throw new NotAuthorized(res.t(objections[0]));
|
|
|
|
const { gemAmount } = req.body;
|
|
const amount = gemAmount / 4;
|
|
|
|
if (amount <= 0 || sender.balance < amount) {
|
|
throw new NotAuthorized(res.t('badAmountOfGemsToSend'));
|
|
}
|
|
|
|
await receiver.updateBalance(amount, 'gift_receive', sender._id, sender.profile.name);
|
|
await sender.updateBalance(-amount, 'gift_send', sender._id, receiver.profile.name);
|
|
// @TODO necessary? Also saved when sending the inbox message
|
|
const promises = [receiver.save(), sender.save()];
|
|
await Promise.all(promises);
|
|
|
|
// generate the message in both languages, so both users can understand it
|
|
const receiverLang = receiver.preferences.language;
|
|
const senderLang = sender.preferences.language;
|
|
const [receiverMsg, senderMsg] = [receiverLang, senderLang].map(lang => {
|
|
let messageContent = res.t('privateMessageGiftGemsMessage', {
|
|
receiverName: receiver.profile.name,
|
|
senderName: sender.profile.name,
|
|
gemAmount,
|
|
}, lang);
|
|
messageContent = `\`${messageContent}\` `;
|
|
|
|
if (req.body.message) {
|
|
messageContent += req.body.message;
|
|
}
|
|
return messageContent;
|
|
});
|
|
|
|
await sender.sendMessage(receiver, {
|
|
senderMsg,
|
|
receiverMsg,
|
|
});
|
|
|
|
const byUsername = getUserInfo(sender, ['name']).name;
|
|
|
|
if (receiver.preferences.emailNotifications.giftedGems !== false) {
|
|
sendTxnEmail(receiver, 'gifted-gems', [
|
|
{ name: 'GIFTER', content: byUsername },
|
|
{ name: 'X_GEMS_GIFTED', content: gemAmount },
|
|
]);
|
|
}
|
|
if (receiver.preferences.pushNotifications.giftedGems !== false) {
|
|
sendPushNotification(receiver,
|
|
{
|
|
title: res.t('giftedGems', receiverLang),
|
|
message: res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername }, receiverLang),
|
|
identifier: 'giftedGems',
|
|
payload: { replyTo: sender._id },
|
|
});
|
|
}
|
|
|
|
res.respond(200, {});
|
|
|
|
if (res.analytics) {
|
|
res.analytics.track('transfer gems', {
|
|
uuid: sender._id,
|
|
hitType: 'event',
|
|
category: 'behavior',
|
|
headers: req.headers,
|
|
quantity: gemAmount,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
export default api;
|