mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 14:47:53 +01:00
Merge branch 'develop' of https://github.com/HabitRPG/habitica into autocomplete-username
# Conflicts: # package.json # website/client/components/chat/autoComplete.vue # website/client/components/chat/chatCard.vue # website/client/components/groups/chat.vue # website/server/controllers/api-v3/chat.js # website/server/controllers/api-v3/members.js # website/server/controllers/api-v4/members.js
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
BadRequest,
|
||||
} from '../../libs/errors';
|
||||
import * as passwordUtils from '../../libs/password';
|
||||
import { send as sendEmail } from '../../libs/email';
|
||||
import { sendTxn as sendTxnEmail } from '../../libs/email';
|
||||
import { validatePasswordResetCodeAndFindUser, convertToBcrypt} from '../../libs/password';
|
||||
import { encrypt } from '../../libs/encryption';
|
||||
import {
|
||||
@@ -98,6 +98,9 @@ api.loginLocal = {
|
||||
// load the entire user because we may have to save it to convert the password to bcrypt
|
||||
let user = await User.findOne(login).exec();
|
||||
|
||||
// if user is using social login, then user will not have a hashed_password stored
|
||||
if (!user || !user.auth.local.hashed_password) throw new NotAuthorized(res.t('invalidLoginCredentialsLong'));
|
||||
|
||||
let isValidPassword;
|
||||
|
||||
if (!user) {
|
||||
@@ -201,6 +204,8 @@ api.updateUsername = {
|
||||
} else {
|
||||
user.items.pets['Wolf-Veteran'] = 5;
|
||||
}
|
||||
|
||||
user.markModified('items.pets');
|
||||
}
|
||||
await user.save();
|
||||
|
||||
@@ -298,19 +303,9 @@ api.resetPassword = {
|
||||
|
||||
user.auth.local.passwordResetCode = passwordResetCode;
|
||||
|
||||
sendEmail({
|
||||
from: 'Habitica <admin@habitica.com>',
|
||||
to: email,
|
||||
subject: res.t('passwordResetEmailSubject'),
|
||||
text: res.t('passwordResetEmailText', {
|
||||
username: user.auth.local.username,
|
||||
passwordResetLink: link,
|
||||
}),
|
||||
html: res.t('passwordResetEmailHtml', {
|
||||
username: user.auth.local.username,
|
||||
passwordResetLink: link,
|
||||
}),
|
||||
});
|
||||
sendTxnEmail(user, 'reset-password', [
|
||||
{name: 'PASSWORD_RESET_LINK', content: link},
|
||||
]);
|
||||
|
||||
await user.save();
|
||||
} else {
|
||||
@@ -369,7 +364,7 @@ api.updateEmail = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/user/auth/reset-password-set-new-one Reser Password Set New one
|
||||
* @api {post} /api/v3/user/auth/reset-password-set-new-one Reset Password Set New one
|
||||
* @apiDescription Set a new password for a user that reset theirs. Not meant for public usage.
|
||||
* @apiName ResetPasswordSetNewOne
|
||||
* @apiGroup User
|
||||
|
||||
@@ -49,7 +49,7 @@ let api = {};
|
||||
* @apiSuccess {String} challenge.name Full name of challenge.
|
||||
* @apiSuccess {String} challenge.shortName A shortened name for the challenge, to be used as a tag.
|
||||
* @apiSuccess {Object} challenge.leader User details of challenge leader.
|
||||
* @apiSuccess {UUID} challenge.leader._id User id of challenge leader.
|
||||
* @apiSuccess {UUID} challenge.leader._id User ID of challenge leader.
|
||||
* @apiSuccess {Object} challenge.leader.profile Profile information of leader.
|
||||
* @apiSuccess {Object} challenge.leader.profile.name Display Name of leader.
|
||||
* @apiSuccess {String} challenge.updatedAt Timestamp of last update.
|
||||
@@ -364,12 +364,14 @@ api.getUserChallenges = {
|
||||
$and: [{$or: orOptions}],
|
||||
};
|
||||
|
||||
if (owned && owned === 'not_owned') {
|
||||
query.$and.push({leader: {$ne: user._id}});
|
||||
}
|
||||
if (owned) {
|
||||
if (owned === 'not_owned') {
|
||||
query.$and.push({leader: {$ne: user._id}});
|
||||
}
|
||||
|
||||
if (owned && owned === 'owned') {
|
||||
query.$and.push({leader: user._id});
|
||||
if (owned === 'owned') {
|
||||
query.$and.push({leader: user._id});
|
||||
}
|
||||
}
|
||||
|
||||
if (req.query.search) {
|
||||
@@ -400,7 +402,6 @@ api.getUserChallenges = {
|
||||
// .populate('leader', nameFields)
|
||||
const challenges = await mongoQuery.exec();
|
||||
|
||||
|
||||
let resChals = challenges.map(challenge => challenge.toJSON());
|
||||
|
||||
resChals = _.orderBy(resChals, [challenge => {
|
||||
@@ -410,7 +411,7 @@ api.getUserChallenges = {
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Promise.all(resChals.map((chal, index) => {
|
||||
return Promise.all([
|
||||
User.findById(chal.leader).select(nameFields).exec(),
|
||||
User.findById(chal.leader).select(`${nameFields} backer contributor`).exec(),
|
||||
Group.findById(chal.group).select(basicGroupFields).exec(),
|
||||
]).then(populatedData => {
|
||||
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
|
||||
@@ -424,11 +425,11 @@ api.getUserChallenges = {
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/challenges/groups/:groupId Get challenges for a group
|
||||
* @apiDescription Get challenges that the user is a member, public challenges and the ones from the user's groups.
|
||||
* @apiDescription Get challenges hosted in the specified group.
|
||||
* @apiName GetGroupChallenges
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam (Path) {UUID} groupId The group _id
|
||||
* @apiParam (Path) {UUID} groupId The group id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Array} data An array of challenges sorted with official challenges first, followed by the challenges in order from newest to oldest
|
||||
*
|
||||
@@ -441,7 +442,8 @@ api.getGroupChallenges = {
|
||||
method: 'GET',
|
||||
url: '/challenges/groups/:groupId',
|
||||
middlewares: [authWithHeaders({
|
||||
userFieldsToInclude: ['_id', 'party', 'guilds'],
|
||||
// Some fields (including _id) are always loaded (see middlewares/auth)
|
||||
userFieldsToInclude: ['party', 'guilds'], // Some fields are always loaded (see middlewares/auth)
|
||||
})],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
@@ -460,7 +462,7 @@ api.getGroupChallenges = {
|
||||
|
||||
const challenges = await Challenge.find({ group: groupId })
|
||||
.sort('-createdAt')
|
||||
// .populate('leader', nameFields) // Only populate the leader as the group is implicit
|
||||
// .populate('leader', nameFields) // Only populate the leader as the group is implicit // see below why we're not using populate
|
||||
.exec();
|
||||
|
||||
let resChals = challenges.map(challenge => challenge.toJSON());
|
||||
|
||||
@@ -2,6 +2,7 @@ import { authWithHeaders } from '../../middlewares/auth';
|
||||
import { model as Group } from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import { chatModel as Chat } from '../../models/message';
|
||||
import common from '../../../common';
|
||||
import {
|
||||
BadRequest,
|
||||
NotFound,
|
||||
@@ -31,12 +32,17 @@ const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email)
|
||||
|
||||
/**
|
||||
* @apiDefine GroupIdRequired
|
||||
* @apiError (404) {badRequest} groupIdRequired A group ID is required
|
||||
* @apiError (400) {badRequest} groupIdRequired A group ID is required
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine ChatIdRequired
|
||||
* @apiError (404) {badRequest} chatIdRequired A chat ID is required
|
||||
* @apiError (400) {badRequest} chatIdRequired A chat ID is required
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine MessageIdRequired
|
||||
* @apiError (400) {badRequest} messageIdRequired A message ID is required
|
||||
*/
|
||||
|
||||
let api = {};
|
||||
@@ -134,7 +140,7 @@ api.postChat = {
|
||||
{name: 'AUTHOR_USERNAME', content: user.profile.name},
|
||||
{name: 'AUTHOR_UUID', content: user._id},
|
||||
{name: 'AUTHOR_EMAIL', content: authorEmail},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/profile/${user._id}`},
|
||||
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'GROUP_TYPE', content: group.type},
|
||||
@@ -157,12 +163,12 @@ api.postChat = {
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
if (group.privacy !== 'private' && user.flags.chatRevoked) {
|
||||
if (group.privacy === 'public' && user.flags.chatRevoked) {
|
||||
throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
|
||||
}
|
||||
|
||||
// prevent banned words being posted, except in private guilds/parties and in certain public guilds with specific topics
|
||||
if (group.privacy !== 'private' && !guildsAllowingBannedWords[group._id]) {
|
||||
if (group.privacy === 'public' && !guildsAllowingBannedWords[group._id]) {
|
||||
let matchedBadWords = getBannedWordsFromText(req.body.message);
|
||||
if (matchedBadWords.length > 0) {
|
||||
throw new BadRequest(res.t('bannedWordUsed', {swearWordsUsed: matchedBadWords.join(', ')}));
|
||||
@@ -183,7 +189,42 @@ api.postChat = {
|
||||
client = client.replace('habitica-', '');
|
||||
}
|
||||
|
||||
const newChatMessage = group.sendChat(message, user, null, client);
|
||||
let flagCount = 0;
|
||||
if (group.privacy === 'public' && user.flags.chatShadowMuted) {
|
||||
flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE;
|
||||
let message = req.body.message;
|
||||
|
||||
// Email the mods
|
||||
let authorEmail = getUserInfo(user, ['email']).email;
|
||||
let groupUrl = getGroupUrl(group);
|
||||
|
||||
let report = [
|
||||
{name: 'MESSAGE_TIME', content: (new Date()).toString()},
|
||||
{name: 'MESSAGE_TEXT', content: message},
|
||||
|
||||
{name: 'AUTHOR_USERNAME', content: user.profile.name},
|
||||
{name: 'AUTHOR_UUID', content: user._id},
|
||||
{name: 'AUTHOR_EMAIL', content: authorEmail},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/profile/${user._id}`},
|
||||
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'GROUP_TYPE', content: group.type},
|
||||
{name: 'GROUP_ID', content: group._id},
|
||||
{name: 'GROUP_URL', content: groupUrl},
|
||||
];
|
||||
|
||||
sendTxn(FLAG_REPORT_EMAILS, 'shadow-muted-post-report-to-mods', report);
|
||||
|
||||
// Slack the mods
|
||||
slack.sendShadowMutedPostNotification({
|
||||
authorEmail,
|
||||
author: user,
|
||||
group,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
const newChatMessage = group.sendChat({message, user, flagCount, metaData: null, client});
|
||||
let toSave = [newChatMessage.save()];
|
||||
|
||||
if (group.type === 'party') {
|
||||
@@ -247,7 +288,7 @@ api.likeChat = {
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', apiError('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
@@ -286,7 +327,7 @@ api.likeChat = {
|
||||
* @apiSuccess {Object} data.likes The likes of the message
|
||||
* @apiSuccess {Object} data.flags The flags of the message
|
||||
* @apiSuccess {Number} data.flagCount The number of flags the message has
|
||||
* @apiSuccess {UUID} data.uuid The user id of the author of the message
|
||||
* @apiSuccess {UUID} data.uuid The User ID of the author of the message
|
||||
* @apiSuccess {String} data.user The username of the author of the message
|
||||
*
|
||||
* @apiUse GroupNotFound
|
||||
@@ -335,7 +376,7 @@ api.clearChatFlags = {
|
||||
let chatId = req.params.chatId;
|
||||
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', apiError('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
@@ -368,12 +409,12 @@ api.clearChatFlags = {
|
||||
{name: 'ADMIN_USERNAME', content: user.profile.name},
|
||||
{name: 'ADMIN_UUID', content: user._id},
|
||||
{name: 'ADMIN_EMAIL', content: adminEmailContent},
|
||||
{name: 'ADMIN_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
|
||||
{name: 'ADMIN_MODAL_URL', content: `/profile/${user._id}`},
|
||||
|
||||
{name: 'AUTHOR_USERNAME', content: message.user},
|
||||
{name: 'AUTHOR_UUID', content: message.uuid},
|
||||
{name: 'AUTHOR_EMAIL', content: authorEmail},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/profile/${message.uuid}`},
|
||||
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'GROUP_TYPE', content: group.type},
|
||||
@@ -471,7 +512,7 @@ api.deleteChat = {
|
||||
let chatId = req.params.chatId;
|
||||
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', apiError('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
@@ -135,6 +135,7 @@ api.modifyInventory = {
|
||||
|
||||
if (gear) {
|
||||
user.items.gear.owned = gear;
|
||||
user.markModified('items.gear.owned');
|
||||
}
|
||||
|
||||
[
|
||||
@@ -148,6 +149,7 @@ api.modifyInventory = {
|
||||
].forEach((type) => {
|
||||
if (req.body[type]) {
|
||||
user.items[type] = req.body[type];
|
||||
user.markModified(`items.${type}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ let api = {};
|
||||
* @apiError (401) {NotAuthorized} messageInsufficientGems User does not have enough gems (4)
|
||||
* @apiError (401) {NotAuthorized} partyMustbePrivate Party must have privacy set to private
|
||||
* @apiError (401) {NotAuthorized} messageGroupAlreadyInParty
|
||||
* @apiError (401) {NotAuthorized} cannotCreatePublicGuildWhenMuted You cannot create a public guild because your chat privileges have been revoked.
|
||||
* @apiError (401) {NotAuthorized} chatPrivilegesRevoked You cannot do this because your chat privileges have been removed...
|
||||
*
|
||||
* @apiSuccess (201) {Object} data The created group (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
|
||||
*
|
||||
@@ -117,7 +117,7 @@ api.createGroup = {
|
||||
group.leader = user._id;
|
||||
|
||||
if (group.type === 'guild') {
|
||||
if (group.privacy === 'public' && user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotCreatePublicGuildWhenMuted'));
|
||||
if (group.privacy === 'public' && user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
|
||||
if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems'));
|
||||
|
||||
group.balance = 1;
|
||||
@@ -375,7 +375,8 @@ api.getGroup = {
|
||||
method: 'GET',
|
||||
url: '/groups/:groupId',
|
||||
middlewares: [authWithHeaders({
|
||||
userFieldsToInclude: ['_id', 'party', 'guilds', 'contributor'],
|
||||
// Some fields (including _id, preferences) are always loaded (see middlewares/auth)
|
||||
userFieldsToInclude: ['party', 'guilds', 'contributor'],
|
||||
})],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
@@ -595,6 +596,7 @@ api.joinGroup = {
|
||||
inviter.items.quests.basilist = 0;
|
||||
}
|
||||
inviter.items.quests.basilist++;
|
||||
inviter.markModified('items.quests');
|
||||
}
|
||||
promises.push(inviter.save());
|
||||
}
|
||||
@@ -774,7 +776,7 @@ api.leaveGroup = {
|
||||
|
||||
if (group.type !== 'party') {
|
||||
let guildIndex = user.guilds.indexOf(group._id);
|
||||
user.guilds.splice(guildIndex, 1);
|
||||
if (guildIndex >= 0) user.guilds.splice(guildIndex, 1);
|
||||
}
|
||||
|
||||
let isMemberOfGroupPlan = await user.isMemberOfGroupPlan();
|
||||
@@ -890,6 +892,7 @@ api.removeGroupMember = {
|
||||
|
||||
if (group.quest && group.quest.active && group.quest.leader === member._id) {
|
||||
member.items.quests[group.quest.key] += 1;
|
||||
member.markModified('items.quests');
|
||||
}
|
||||
} else if (isInvited) {
|
||||
if (isInvited === 'guild') {
|
||||
@@ -941,11 +944,11 @@ api.removeGroupMember = {
|
||||
* {"name": "User2", "email": "user-2@example.com"}
|
||||
* ]
|
||||
* }
|
||||
* @apiParamExample {json} User Ids
|
||||
* @apiParamExample {json} User IDs
|
||||
* {
|
||||
* "uuids": ["user-id-of-existing-user", "user-id-of-another-existing-user"]
|
||||
* }
|
||||
* @apiParamExample {json} User Ids and Emails
|
||||
* @apiParamExample {json} User IDs and Emails
|
||||
* {
|
||||
* "emails": [
|
||||
* {"email": "user-1@example.com"},
|
||||
@@ -955,7 +958,7 @@ api.removeGroupMember = {
|
||||
* }
|
||||
*
|
||||
* @apiSuccess {Array} data The invites
|
||||
* @apiSuccess {Object} data[0] If the invitation was a user id, you'll receive back an object. You'll receive one Object for each succesful user id invite.
|
||||
* @apiSuccess {Object} data[0] If the invitation was a User ID, you'll receive back an object. You'll receive one Object for each succesful User ID invite.
|
||||
* @apiSuccess {String} data[1] If the invitation was an email, you'll receive back the email. You'll receive one String for each successful email invite.
|
||||
*
|
||||
* @apiSuccessExample {json} Successful Response with Emails
|
||||
@@ -966,13 +969,13 @@ api.removeGroupMember = {
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @apiSuccessExample {json} Successful Response with User Id
|
||||
* @apiSuccessExample {json} Successful Response with User ID
|
||||
* {
|
||||
* "data": [
|
||||
* { id: 'the-id-of-the-invited-user', name: 'The group name', inviter: 'your-user-id' }
|
||||
* ]
|
||||
* }
|
||||
* @apiSuccessExample {json} Successful Response with User Ids and Emails
|
||||
* @apiSuccessExample {json} Successful Response with User IDs and Emails
|
||||
* {
|
||||
* "data": [
|
||||
* "user-1@example.com",
|
||||
@@ -987,9 +990,9 @@ api.removeGroupMember = {
|
||||
* param `Array`.
|
||||
* @apiError (400) {BadRequest} UuidOrEmailOnly The `emails` and `uuids` params were both missing and/or a
|
||||
* key other than `emails` or `uuids` was provided in the body param.
|
||||
* @apiError (400) {BadRequest} CannotInviteSelf User id or email of invitee matches that of the inviter.
|
||||
* @apiError (400) {BadRequest} CannotInviteSelf User ID or email of invitee matches that of the inviter.
|
||||
* @apiError (400) {BadRequest} MustBeArray The `uuids` or `emails` body param was not an array.
|
||||
* @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined emails and user ids) can
|
||||
* @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined emails and User IDs) can
|
||||
* be sent out at a time.
|
||||
* @apiError (400) {BadRequest} ExceedsMembersLimit A max of 30 members can join a party.
|
||||
*
|
||||
@@ -1009,7 +1012,7 @@ api.inviteToGroup = {
|
||||
async handler (req, res) {
|
||||
const user = res.locals.user;
|
||||
|
||||
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted'));
|
||||
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
|
||||
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
} from '../../libs/errors';
|
||||
import _ from 'lodash';
|
||||
import apiError from '../../libs/apiError';
|
||||
import validator from 'validator';
|
||||
import {
|
||||
validateItemPath,
|
||||
castItemVal,
|
||||
} from '../../libs/items/utils';
|
||||
|
||||
|
||||
let api = {};
|
||||
|
||||
@@ -139,10 +145,10 @@ api.getHeroes = {
|
||||
// Note, while the following routes are called getHero / updateHero
|
||||
// they can be used by admins to get/update any user
|
||||
|
||||
const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked';
|
||||
const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked flags.chatShadowMuted';
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID or Username
|
||||
* @apiParam (Path) {UUID} heroId user ID
|
||||
* @apiName GetHero
|
||||
* @apiGroup Hall
|
||||
@@ -162,15 +168,23 @@ api.getHero = {
|
||||
url: '/hall/heroes/:heroId',
|
||||
middlewares: [authWithHeaders(), ensureAdmin],
|
||||
async handler (req, res) {
|
||||
let heroId = req.params.heroId;
|
||||
let validationErrors;
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty();
|
||||
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let hero = await User
|
||||
.findById(heroId)
|
||||
const heroId = req.params.heroId;
|
||||
|
||||
let query;
|
||||
if (validator.isUUID(heroId)) {
|
||||
query = {_id: heroId};
|
||||
} else {
|
||||
query = {'auth.local.lowerCaseUsername': heroId.toLowerCase()};
|
||||
}
|
||||
|
||||
const hero = await User
|
||||
.findOne(query)
|
||||
.select(heroAdminFields)
|
||||
.exec();
|
||||
|
||||
@@ -188,7 +202,7 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/hall/heroes/:heroId Update any user ("hero")
|
||||
* @apiParam (Path) {UUID} heroId user ID
|
||||
* @apiParam (Path) {UUID} heroId User ID
|
||||
* @apiName UpdateHero
|
||||
* @apiGroup Hall
|
||||
* @apiPermission Admin
|
||||
@@ -199,7 +213,10 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
|
||||
* {
|
||||
* "balance": 1000,
|
||||
* "auth": {"blocked": false},
|
||||
* "flags": {"chatRevoked": true},
|
||||
* "flags": {
|
||||
* "chatRevoked": true,
|
||||
* "chatShadowMuted": true
|
||||
* },
|
||||
* "purchased": {"ads": true},
|
||||
* "contributor": {
|
||||
* "admin": true,
|
||||
@@ -255,11 +272,12 @@ api.updateHero = {
|
||||
if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads;
|
||||
|
||||
// give them the Dragon Hydra pet if they're above level 6
|
||||
if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5;
|
||||
if (updateData.itemPath && updateData.itemVal &&
|
||||
updateData.itemPath.indexOf('items.') === 0 &&
|
||||
User.schema.paths[updateData.itemPath]) {
|
||||
_.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
|
||||
if (hero.contributor.level >= 6) {
|
||||
hero.items.pets['Dragon-Hydra'] = 5;
|
||||
hero.markModified('items.pets');
|
||||
}
|
||||
if (updateData.itemPath && updateData.itemVal && validateItemPath(updateData.itemPath)) {
|
||||
_.set(hero, updateData.itemPath, castItemVal(updateData.itemPath, updateData.itemVal)); // Sanitization at 5c30944 (deemed unnecessary)
|
||||
}
|
||||
|
||||
if (updateData.auth && updateData.auth.blocked === true) {
|
||||
@@ -271,6 +289,7 @@ api.updateHero = {
|
||||
}
|
||||
|
||||
if (updateData.flags && _.isBoolean(updateData.flags.chatRevoked)) hero.flags.chatRevoked = updateData.flags.chatRevoked;
|
||||
if (updateData.flags && _.isBoolean(updateData.flags.chatShadowMuted)) hero.flags.chatShadowMuted = updateData.flags.chatShadowMuted;
|
||||
|
||||
let savedHero = await hero.save();
|
||||
let heroJSON = savedHero.toJSON();
|
||||
|
||||
@@ -11,6 +11,9 @@ let api = {};
|
||||
* @apiGroup Inbox
|
||||
* @apiDescription Get inbox messages for a user
|
||||
*
|
||||
* @apiParam (Query) {Number} page Load the messages of the selected Page - 10 Messages per Page
|
||||
* @apiParam (Query) {GUID} conversation Loads only the messages of a conversation
|
||||
*
|
||||
* @apiSuccess {Array} data An array of inbox messages
|
||||
*/
|
||||
api.getInboxMessages = {
|
||||
@@ -19,8 +22,12 @@ api.getInboxMessages = {
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
const user = res.locals.user;
|
||||
const page = req.query.page;
|
||||
const conversation = req.query.conversation;
|
||||
|
||||
const userInbox = await inboxLib.getUserInbox(user);
|
||||
const userInbox = await inboxLib.getUserInbox(user, {
|
||||
page, conversation,
|
||||
});
|
||||
|
||||
res.respond(200, userInbox);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../libs/email';
|
||||
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
|
||||
import { achievements } from '../../../../website/common/';
|
||||
import {sentMessage} from '../../libs/inbox';
|
||||
import {highlightMentions} from '../../libs/highlightMentions';
|
||||
|
||||
let api = {};
|
||||
@@ -634,6 +635,7 @@ api.sendPrivateMessage = {
|
||||
|
||||
const sender = res.locals.user;
|
||||
const message = (await highlightMentions(req.body.message))[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;
|
||||
@@ -641,26 +643,7 @@ api.sendPrivateMessage = {
|
||||
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
|
||||
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));
|
||||
|
||||
const messageSent = await sender.sendMessage(receiver, { receiverMsg: message });
|
||||
|
||||
if (receiver.preferences.emailNotifications.newPM !== false) {
|
||||
sendTxnEmail(receiver, 'new-pm', [
|
||||
{name: 'SENDER', content: getUserInfo(sender, ['name']).name},
|
||||
]);
|
||||
}
|
||||
|
||||
if (receiver.preferences.pushNotifications.newPM !== false) {
|
||||
sendPushNotification(
|
||||
receiver,
|
||||
{
|
||||
title: res.t('newPM'),
|
||||
message: res.t('newPMInfo', {name: getUserInfo(sender, ['name']).name, message}),
|
||||
identifier: 'newPM',
|
||||
category: 'newPM',
|
||||
payload: {replyTo: sender._id},
|
||||
}
|
||||
);
|
||||
}
|
||||
const messageSent = await sentMessage(sender, receiver, message, res.t);
|
||||
|
||||
res.respond(200, {message: messageSent});
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authWithHeaders } from '../../middlewares/auth';
|
||||
let api = {};
|
||||
|
||||
// @TODO export this const, cannot export it from here because only routes are exported from controllers
|
||||
const LAST_ANNOUNCEMENT_TITLE = 'VALENTINE’S DAY CELEBRATION! INCLUDING CUPID AND ROSE QUARTZ HATCHING POTIONS';
|
||||
const LAST_ANNOUNCEMENT_TITLE = 'NEW PET COLLECTION BADGES!';
|
||||
const worldDmg = { // @TODO
|
||||
bailey: false,
|
||||
};
|
||||
@@ -30,23 +30,14 @@ api.getNews = {
|
||||
<div class="mr-3 ${baileyClass}"></div>
|
||||
<div class="media-body">
|
||||
<h1 class="align-self-center">${res.t('newStuff')}</h1>
|
||||
<h2>2/12/2019 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
|
||||
<h2>9/17/2019 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="promo_valentines center-block"></div>
|
||||
<h3>Habitica Celebrates Valentine's Day!</h3>
|
||||
<p>In honor of Habitica's holiday celebrating all forms of love, whether it's friendship, familial, or romantic, some of the shopkeepers are dressed up! Take a look around to enjoy their new festive decorations.</p>
|
||||
<div class="small mb-3">by Beffymaroo and Lemoness</div>
|
||||
<h3>Send a Valentine</h3>
|
||||
<p>Help motivate all of the lovely people in your life by sending them a caring Valentine. For the next week only, Valentines can be purchased for 10 Gold from the <a href='/shops/market'>Market</a>. For spreading love and joy throughout the community, both the giver AND the receiver get a coveted "Adoring Friends" badge. Hooray!</p>
|
||||
<p>While you're there, why not check out the other cards that are available to send to your party? Each one gives a special achievement of its own...</p>
|
||||
<div class="small mb-3">By Lemoness and SabreCat</div>
|
||||
<div class="promo_valentines_potions center-block"></div>
|
||||
<h3>Cupid and Rose Quartz Hatching Potions</h3>
|
||||
<p>There's a new pet breed in town! We're excited to introduce the new Rose Quartz Magic Hatching Potions, and to announce the return of Cupid Potions! Between now and February 28, you can buy these potions from <a href='/shops/market'>the Market</a> and use them to hatch any standard pet egg. (Magic Hatching Potions do not work on Quest Pet eggs.) Magic Potion Pets aren't picky, so they'll happily eat any kind of food that you feed them!</p>
|
||||
<p>After they're gone, it will be at least a year before the Cupid or Rose Quartz Hatching Potions are available again, so be sure to get them now!</p>
|
||||
<div class="small mb-3">by Vampitch,Willow the Witty, and SabreCat</div>
|
||||
<div class="promo_desert_pet_achievements center-block"></div>
|
||||
<p>We're releasing a new achievement so you can celebrate your successes in the world of Habitican pet collecting! Earn the Dust Devil and Arid Authority achievements by collecting Desert pets and mounts and you'll earn a nifty badge for your profile.</p>
|
||||
<p>If you already have all the Desert pets and/or mounts in your stable, you'll receive the badge automatically! Check your profile and celebrate your new achievement with pride.</p>
|
||||
<div class="small mb-3">by Piyo and SabreCat</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ api.inviteToQuest = {
|
||||
'party._id': group._id,
|
||||
_id: {$ne: user._id},
|
||||
})
|
||||
.select('auth.facebook auth.google auth.local preferences.emailNotifications profile.name pushDevices')
|
||||
.select('auth.facebook auth.google auth.local preferences.emailNotifications preferences.pushNotifications preferences.language profile.name pushDevices')
|
||||
.exec();
|
||||
|
||||
group.markModified('quest');
|
||||
@@ -124,12 +124,11 @@ api.inviteToQuest = {
|
||||
sendPushNotification(
|
||||
member,
|
||||
{
|
||||
title: res.t('questInvitationTitle'),
|
||||
message: res.t('questInvitationInfo', {quest: quest.text(req.language)}),
|
||||
title: res.t('questInvitationTitle', member.preferences.language),
|
||||
message: res.t('questInvitationInfo', {quest: quest.text(member.preferences.language)}, member.preferences.language),
|
||||
identifier: 'questInvitation',
|
||||
category: 'questInvitation',
|
||||
}
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -363,17 +362,29 @@ api.cancelQuest = {
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: basicGroupFields.concat(' quest')});
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
|
||||
if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest'));
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
|
||||
|
||||
let questName = questScrolls[group.quest.key].text('en');
|
||||
const newChatMessage = group.sendChat({
|
||||
message: `\`${user.profile.name} cancelled the party quest ${questName}.\``,
|
||||
info: {
|
||||
type: 'quest_cancel',
|
||||
user: user.profile.name,
|
||||
quest: group.quest.key,
|
||||
},
|
||||
});
|
||||
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
let [savedGroup] = await Promise.all([
|
||||
group.save(),
|
||||
newChatMessage.save(),
|
||||
User.update(
|
||||
{'party._id': groupId},
|
||||
Group.cleanQuestParty(),
|
||||
@@ -405,7 +416,7 @@ api.abortQuest = {
|
||||
url: '/groups/:groupId/quests/abort',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
// Abort a quest AFTER it has begun (see questCancel for BEFORE)
|
||||
// Abort a quest AFTER it has begun
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
@@ -422,7 +433,14 @@ api.abortQuest = {
|
||||
if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
|
||||
|
||||
let questName = questScrolls[group.quest.key].text('en');
|
||||
const newChatMessage = group.sendChat(`\`${user.profile.name} aborted the party quest ${questName}.\``);
|
||||
const newChatMessage = group.sendChat({
|
||||
message: `\`${common.i18n.t('chatQuestAborted', {username: user.profile.name, questName}, 'en')}\``,
|
||||
info: {
|
||||
type: 'quest_abort',
|
||||
user: user.profile.name,
|
||||
quest: group.quest.key,
|
||||
},
|
||||
});
|
||||
await newChatMessage.save();
|
||||
|
||||
let memberUpdates = User.update({
|
||||
|
||||
@@ -244,7 +244,7 @@ api.createChallengeTasks = {
|
||||
|
||||
// If the challenge does not exist, or if it exists but user is not the leader -> throw error
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
|
||||
let tasks = await createTasks(req, res, {user, challenge});
|
||||
|
||||
@@ -285,7 +285,8 @@ api.getUserTasks = {
|
||||
method: 'GET',
|
||||
url: '/tasks/user',
|
||||
middlewares: [authWithHeaders({
|
||||
userFieldsToInclude: ['_id', 'tasksOrder', 'preferences'],
|
||||
// Some fields (including _id, preferences) are always loaded (see middlewares/auth)
|
||||
userFieldsToInclude: ['tasksOrder'],
|
||||
})],
|
||||
async handler (req, res) {
|
||||
let types = Tasks.tasksTypes.map(type => `${type}s`);
|
||||
@@ -453,7 +454,7 @@ api.updateTask = {
|
||||
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
@@ -637,6 +638,18 @@ api.scoreTask = {
|
||||
|
||||
if (task.group && task.group.taskId) {
|
||||
await handleSharedCompletion(task);
|
||||
try {
|
||||
const groupTask = await Tasks.Task.findOne({
|
||||
_id: task.group.taskId,
|
||||
}).exec();
|
||||
|
||||
if (groupTask) {
|
||||
const groupDelta = groupTask.group.assignedUsers ? delta / groupTask.group.assignedUsers.length : delta;
|
||||
await groupTask.scoreChallengeTask(groupDelta, direction);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save results and handle request
|
||||
@@ -796,7 +809,7 @@ api.addChecklistItem = {
|
||||
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
@@ -912,7 +925,7 @@ api.updateChecklistItem = {
|
||||
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
@@ -977,7 +990,7 @@ api.removeChecklistItem = {
|
||||
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
@@ -1297,7 +1310,7 @@ api.deleteTask = {
|
||||
} else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (task.userId && task.challenge.id && !task.challenge.broken) {
|
||||
|
||||
@@ -201,11 +201,14 @@ api.assignTask = {
|
||||
|
||||
let promises = [];
|
||||
|
||||
// User is claiming the task
|
||||
if (user._id === assignedUserId) {
|
||||
let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text});
|
||||
const newMessage = group.sendChat(message);
|
||||
promises.push(newMessage.save());
|
||||
if (user._id !== assignedUserId) {
|
||||
const taskText = task.text;
|
||||
const managerName = user.profile.name;
|
||||
|
||||
assignedUser.addNotification('GROUP_TASK_ASSIGNED', {
|
||||
message: res.t('youHaveBeenAssignedTask', {managerName, taskText}),
|
||||
taskId: task._id,
|
||||
});
|
||||
}
|
||||
|
||||
promises.push(group.syncTask(task, assignedUser));
|
||||
@@ -261,6 +264,15 @@ api.unassignTask = {
|
||||
|
||||
await group.unlinkTask(task, assignedUser);
|
||||
|
||||
let notificationIndex = assignedUser.notifications.findIndex(function findNotification (notification) {
|
||||
return notification && notification.data && notification.type === 'GROUP_TASK_ASSIGNED' && notification.data.taskId === task._id;
|
||||
});
|
||||
|
||||
if (notificationIndex !== -1) {
|
||||
assignedUser.notifications.splice(notificationIndex, 1);
|
||||
await assignedUser.save();
|
||||
}
|
||||
|
||||
res.respond(200, task);
|
||||
},
|
||||
};
|
||||
@@ -308,6 +320,9 @@ api.approveTask = {
|
||||
|
||||
if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
|
||||
if (task.group.approval.approved === true) throw new NotAuthorized(res.t('canOnlyApproveTaskOnce'));
|
||||
if (!task.group.approval.requested) {
|
||||
throw new NotAuthorized(res.t('taskApprovalWasNotRequested'));
|
||||
}
|
||||
|
||||
task.group.approval.dateApproved = new Date();
|
||||
task.group.approval.approvingUser = user._id;
|
||||
|
||||
@@ -983,12 +983,15 @@ api.purchase = {
|
||||
* @apiParam (Path) {String="pets","mounts"} type The type of item to purchase
|
||||
* @apiParam (Path) {String} key Ex: {Phoenix-Base}. The key for the mount/pet
|
||||
*
|
||||
* @apiParam (Body) {Integer} [quantity=1] Count of items to buy. Defaults to 1 and is ignored for items where quantity is irrelevant.
|
||||
*
|
||||
* @apiSuccess {Object} data.items user.items
|
||||
* @apiSuccess {Object} data.purchasedPlanConsecutive user.purchased.plan.consecutive
|
||||
* @apiSuccess {String} message Success message
|
||||
*
|
||||
* @apiError {NotAuthorized} NotAvailable Item is not available to be purchased or is not valid.
|
||||
* @apiError {NotAuthorized} Hourglasses User does not have enough Mystic Hourglasses.
|
||||
* @apiError {BadRequest} Quantity Quantity to purchase must be a number.
|
||||
* @apiError {NotFound} Type Type invalid.
|
||||
*
|
||||
* @apiErrorExample {json}
|
||||
@@ -1000,7 +1003,9 @@ api.userPurchaseHourglass = {
|
||||
url: '/user/purchase-hourglass/:type/:key',
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let purchaseHourglassRes = common.ops.buy(user, req, res.analytics);
|
||||
const quantity = req.body.quantity || 1;
|
||||
if (quantity < 1 || !Number.isInteger(quantity)) throw new BadRequest(res.t('invalidQuantity'), req.language);
|
||||
let purchaseHourglassRes = common.ops.buy(user, req, res.analytics, {quantity, hourglass: true});
|
||||
await user.save();
|
||||
res.respond(200, ...purchaseHourglassRes);
|
||||
},
|
||||
@@ -1421,7 +1426,7 @@ api.deleteMessage = {
|
||||
|
||||
await inboxLib.deleteMessage(user, req.params.id);
|
||||
|
||||
res.respond(200, ...[await inboxLib.getUserInbox(user, false)]);
|
||||
res.respond(200, ...[await inboxLib.getUserInbox(user, {asArray: false})]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1539,7 +1544,7 @@ api.userReset = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/user/custom-day-start Set preferences.dayStart for user
|
||||
* @api {post} /api/v3/user/custom-day-start Set preferences.dayStart (Custom Day Start time) for user
|
||||
* @apiName setCustomDayStart
|
||||
* @apiGroup User
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user