import cloneDeep from 'lodash/cloneDeep'; import forEach from 'lodash/forEach'; import isFunction from 'lodash/isFunction'; import nconf from 'nconf'; import get from 'lodash/get'; import { authWithHeaders } from '../../middlewares/auth'; import common from '../../../common'; import { BadRequest, NotAuthorized, } from '../../libs/errors'; import { basicFields as basicGroupFields, model as Group, } from '../../models/group'; import * as Tasks from '../../models/task'; import * as passwordUtils from '../../libs/password'; import { userActivityWebhook, } from '../../libs/webhook'; import { getUserInfo, sendTxn, } from '../../libs/email'; import * as inboxLib from '../../libs/inbox'; import * as userLib from '../../libs/user'; import { model as UserHistory } from '../../models/userHistory'; const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android']; const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL'); const DELETE_CONFIRMATION = 'DELETE'; /** * @apiDefine UserNotFound * @apiError (404) {NotFound} UserNotFound The specified user could not be found. */ const api = {}; /* NOTE this route has also an API v4 version */ /** * @api {get} /api/v3/user Get the authenticated user's profile * @apiName UserGet * @apiGroup User * * @apiDescription The user profile contains data related to the authenticated * user including (but not limited to): * Achievements; * Authentications (including types and timestamps); * Challenges memberships (Challenge IDs); * Flags (including armoire, tutorial, tour etc...); * Guilds memberships (Guild IDs); * History (including timestamps and values, only for Experience and summed To Do values); * Inbox; * Invitations (to parties/guilds); * Items (character's full inventory); * New Messages (flags for party/guilds that have new messages; also reported in Notifications); * Notifications; * Party (includes current quest information); * Preferences (user selected prefs); * Profile (name, photo url, blurb); * Purchased (includes subscription data and some gem-purchased items); * PushDevices (identifiers for mobile devices authorized); * Stats (standard RPG stats, class, buffs, xp, etc..); * Tags; * TasksOrder (list of all IDs for Dailys, Habits, Rewards and To Do's). * * @apiParam (Query) {String} [userFields] A list of comma-separated user fields to * be returned instead of the entire document. * Notifications are always returned. * * @apiExample {curl} Example use: * curl -i https://habitica.com/api/v3/user?userFields=achievements,items.mounts * * @apiSuccess {Object} data The user object * * @apiSuccessExample {json} Result: * { * "success": true, * "data": { * -- User data included here, for details of the user model see: * -- https://github.com/HabitRPG/habitica/tree/develop/website/server/models/user * } * } * */ api.getUser = { method: 'GET', middlewares: [authWithHeaders()], url: '/user', async handler (req, res) { await userLib.get(req, res, { isV3: true }); }, }; /** * @api {get} /api/v3/user/inventory/buy * Get equipment/gear items available for purchase for the authenticated user * @apiName UserGetBuyList * @apiGroup User * * @apiSuccessExample {json} Success-Response: * { * "success": true, * "data": [ * { * "text": "Training Sword", * "notes": "Practice weapon. Confers no benefit.", * "value": 1, * "type": "weapon", * "key": "weapon_warrior_0", * "set": "warrior-0", * "klass": "warrior", * "index": "0", * "str": 0, * "int": 0, * "per": 0, * "con": 0 * } * ] * } */ api.getBuyList = { method: 'GET', middlewares: [authWithHeaders()], url: '/user/inventory/buy', async handler (req, res) { const list = cloneDeep(common.updateStore(res.locals.user)); // return text and notes strings forEach(list, item => { forEach(item, (itemPropVal, itemPropKey) => { if ( isFunction(itemPropVal) && itemPropVal.i18nLangFunc ) item[itemPropKey] = itemPropVal(req.language); }); }); res.respond(200, list); }, }; /** * @api {get} /api/v3/user/in-app-rewards Get the in app items appearing in the user's reward column * @apiName UserGetInAppRewards * @apiGroup User * * @apiSuccessExample {json} Success-Response: * { * "success": true, * "data": [ * { * "key":"weapon_armoire_battleAxe", * "text":"Battle Axe", * "notes":"This fine iron axe is well-suited to battling your fiercest * foes or your most difficult tasks. Increases Intelligence by 6 and * Constitution by 8. Enchanted Armoire: Independent Item.", * "value":1, * "type":"weapon", * "locked":false, * "currency":"gems", * "purchaseType":"gear", * "class":"shop_weapon_armoire_battleAxe", * "path":"gear.flat.weapon_armoire_battleAxe", * "pinType":"gear" * } * ] * } */ api.getInAppRewardsList = { method: 'GET', middlewares: [authWithHeaders({ userFieldsToInclude: ['items', 'pinnedItems', 'unpinnedItems', 'pinnedItemsOrder', 'stats.class', 'achievements', 'purchased'] })], url: '/user/in-app-rewards', async handler (req, res) { const list = common.inAppRewards(res.locals.user); // return text and notes strings forEach(list, item => { forEach(item, (itemPropVal, itemPropKey) => { if ( isFunction(itemPropVal) && itemPropVal.i18nLangFunc ) item[itemPropKey] = itemPropVal(req.language); }); }); res.respond(200, list); }, }; /* NOTE this route has also an API v4 version */ /** * @api {put} /api/v3/user Update the user * @apiName UserUpdate * @apiGroup User * * @apiDescription Some of the user items can be updated, such as preferences, flags and stats. ^ * @apiParamExample {json} Request-Example: * { * "achievements.habitBirthdays": 2, * "profile.name": "MadPink", * "stats.hp": 53, * "flags.warnedLowHealth":false, * "preferences.allocationMode":"flat", * "preferences.hair.bangs": 3 * } * * @apiSuccess {Object} data The updated user object, the result is identical to the get user call * * @apiError (401) {NotAuthorized} messageUserOperationProtected Returned if the change * is not allowed. * * @apiErrorExample {json} Error-Response: * { * "success": false, * "error": "NotAuthorized", * "message": "path `stats.class` was not saved, as it's a protected path." * } */ api.updateUser = { method: 'PUT', middlewares: [authWithHeaders()], url: '/user', async handler (req, res) { await userLib.update(req, res, { isV3: true }); }, }; /** * @api {delete} /api/v3/user Delete an authenticated user's account * @apiName UserDelete * @apiGroup User * * @apiParam (Body) {String} password The user's password if the account uses local authentication, * otherwise the localized word "DELETE" * @apiParam (Body) {String} feedback User's optional feedback explaining reasons for deletion * * @apiSuccess {Object} data An empty Object * * @apiSuccessExample {json} Result: * { * "success": true, * "data": {} * } * * @apiError {BadRequest} MissingPassword Missing password. * @apiError {BadRequest} NotAuthorized Wrong password. * @apiError {BadRequest} NotAuthorized Please type DELETE in all capital letters to * delete your account. * @apiError {BadRequest} BadRequest Account deletion feedback is limited to 10,000 characters. * For lengthy feedback, email ${TECH_ASSISTANCE_EMAIL}. * @apiError {BadRequest} NotAuthorized You have an active subscription, * cancel your plan before deleting your account. * * @apiErrorExample {json} Example error: * { * "success": false, * "error": "BadRequest", * "message": "Invalid request parameters.", * "errors": [ * { * "message": "Missing password.", * "param": "password" * } * ] * } * */ api.deleteUser = { method: 'DELETE', middlewares: [authWithHeaders()], url: '/user', async handler (req, res) { const { user } = res.locals; const { plan } = user.purchased; const { password } = req.body; if (!password) throw new BadRequest(res.t('missingPassword')); if (user.auth.local.hashed_password && user.auth.local.email) { const isValidPassword = await passwordUtils.compare(user, password); if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword')); } else if ( (user.auth.facebook.id || user.auth.google.id || user.auth.apple.id) && password !== DELETE_CONFIRMATION ) { throw new NotAuthorized(res.t('incorrectDeletePhrase', { magicWord: DELETE_CONFIRMATION })); } const { feedback } = req.body; if (feedback && feedback.length > 10000) throw new BadRequest(`Account deletion feedback is limited to 10,000 characters. For lengthy feedback, email ${TECH_ASSISTANCE_EMAIL}.`); // @TODO localize this string if (plan && plan.customerId && !plan.dateTerminated) { throw new NotAuthorized(res.t('cannotDeleteActiveAccount')); } const types = ['party', 'guilds']; const groupFields = basicGroupFields.concat(' leader memberCount purchased'); const groupsUserIsMemberOf = await Group.getGroups({ user, types, groupFields }); const groupLeavePromises = groupsUserIsMemberOf.map(group => group.leave(user, 'remove-all')); await Promise.all(groupLeavePromises); await Tasks.Task.deleteMany({ userId: user._id, }).exec(); await user.deleteOne(); if (feedback) { sendTxn({ email: TECH_ASSISTANCE_EMAIL }, 'admin-feedback', [ { name: 'PROFILE_NAME', content: user.profile.name }, { name: 'USERNAME', content: user.auth.local.username }, { name: 'UUID', content: user._id }, { name: 'EMAIL', content: getUserInfo(user, ['email']).email }, { name: 'FEEDBACK_SOURCE', content: 'from deletion form' }, { name: 'FEEDBACK', content: feedback }, ]); } res.analytics.track('account delete', { user, uuid: user._id, hitType: 'event', category: 'behavior', }); res.respond(200, {}); }, }; function _cleanChecklist (task) { forEach(task.checklist, (c, i) => { c.text = `item ${i}`; }); } /** * @api {get} /api/v3/user/anonymized Get anonymized user data * @apiName UserGetAnonymized * @apiGroup User * * @apiDescription Returns the user's data without: * Authentication information, * NewMessages/Invitations/Inbox, * Profile, * Purchased information, * Contributor information, * Special items, * Webhooks, * Notifications. * * @apiSuccess {Object} data.user * @apiSuccess {Object} data.tasks * */ api.getUserAnonymized = { method: 'GET', middlewares: [authWithHeaders()], url: '/user/anonymized', async handler (req, res) { const user = await res.locals.user.toJSONWithInbox(); user.stats.toNextLevel = common.tnl(user.stats.lvl); user.stats.maxHealth = common.maxHealth; user.stats.maxMP = common.statsComputed(res.locals.user).maxMP; delete user.apiToken; if (user.auth) { delete user.auth.local; delete user.auth.facebook; delete user.auth.google; delete user.auth.apple; } delete user.newMessages; delete user.profile; delete user.purchased.plan; delete user.contributor; delete user.invitations; delete user.items.special.nyeReceived; delete user.items.special.valentineReceived; delete user.webhooks; delete user.achievements.challenges; delete user.notifications; delete user.secret; delete user.permissions; forEach(user.inbox.messages, msg => { msg.text = 'inbox message text'; }); forEach(user.tags, tag => { tag.name = 'tag'; tag.challenge = 'challenge'; }); const query = { userId: user._id, $or: [ { type: 'todo', completed: false }, { type: { $in: ['habit', 'daily', 'reward'] } }, ], }; const tasks = await Tasks.Task.find(query).exec(); forEach(tasks, task => { task.text = 'task text'; task.notes = 'task notes'; if (task.type === 'todo' || task.type === 'daily') { _cleanChecklist(task); } }); return res.respond(200, { user, tasks }); }, }; /** * @api {post} /api/v3/user/sleep Make the user start / stop sleeping (resting in the Inn) * @apiName UserSleep * @apiGroup User * * @apiDescription Toggles the sleep key under user preference true and false. * * @apiSuccess {boolean} data user.preferences.sleep * * @apiSuccessExample {json} Return-example * { * "success": true, * "data": false * } */ api.sleep = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/sleep', async handler (req, res) { const { user } = res.locals; const sleepRes = common.ops.sleep(user, req, res.analytics); await user.save(); res.respond(200, ...sleepRes); }, }; const buySpecialKeys = ['snowball', 'spookySparkles', 'shinySeed', 'seafoam']; const buyKnownKeys = ['armoire', 'mystery', 'potion', 'quest', 'special']; /** * @api {post} /api/v3/user/buy/:key Buy gear, armoire or potion * @apiDescription Under the hood uses UserBuyGear, UserBuyPotion and UserBuyArmoire * @apiName UserBuy * @apiGroup User * * @apiParam (Path) {String} key The item to buy * * @apiSuccess data User's data profile * @apiSuccess message Item purchased * * @apiSuccessExample {json} Purchased a rogue short sword for example: * { * "success": true, * "data": { * ---TRUNCATED USER RECORD--- * }, * "message": "Bought Short Sword" * } * * @apiError (400) {NotAuthorized} messageAlreadyOwnGear Already own equipment * @apiError (400) {NotAuthorized} messageNotEnoughGold Not enough gold for the purchase * * @apiErrorExample {json} NotAuthorized Already own * {"success":false,"error":"NotAuthorized","message":"You already own that piece of equipment"} * * @apiErrorExample {json} NotAuthorized Not enough gold * {"success":false,"error":"NotAuthorized","message":"Not Enough Gold"} */ api.buy = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/buy/:key', async handler (req, res) { const { user } = res.locals; // @TODO: Remove this when mobile passes type in body const type = req.params.key; if (buySpecialKeys.indexOf(type) !== -1) { req.type = 'special'; } else if (buyKnownKeys.indexOf(type) === -1) { req.type = 'marketGear'; } // @TODO: right now common follow express structure, but we should decouple the dependency if (req.body.type) req.type = req.body.type; let quantity = 1; if (req.body.quantity) quantity = req.body.quantity; req.quantity = quantity; if (OFFICIAL_PLATFORMS.indexOf(req.headers['x-client']) === -1) { res.analytics = undefined; } const buyRes = await common.ops.buy(user, req, res.analytics); await user.save(); if (type === 'armoire') { await UserHistory.beginUserHistoryUpdate(user._id, req.headers) .withArmoire(buyRes[0].armoire.dropKey || 'experience') .commit(); } res.respond(200, ...buyRes); }, }; /** * @api {post} /api/v3/user/buy-gear/:key Buy a piece of gear * @apiName UserBuyGear * @apiGroup User * * @apiParam (Path) {String} key The item to buy * * @apiSuccess {Object} data.items User's item inventory * @apiSuccess {Object} data.flags User's flags * @apiSuccess {Object} data.achievements User's achievements * @apiSuccess {Object} data.stats User's current stats * @apiSuccess {String} message Success message, item purchased * * @apiSuccessExample {json} Purchased a warrior's wooden shield for example: * { * "success": true, * "data": { * ---TRUNCATED USER RECORD--- * }, * "message": "Bought Wooden Shield" * } * * @apiError (400) {NotAuthorized} messageNotEnoughGold Not enough gold for the purchase * @apiError (400) {NotAuthorized} messageAlreadyOwnGear Already own equipment * @apiError (404) {NotFound} messageNotFound Item does not exist. * * @apiErrorExample {json} NotAuthorized Already own * {"success":false,"error":"NotAuthorized","message":"You already own that piece of equipment"} * * @apiErrorExample {json} NotAuthorized Not enough gold * {"success":false,"error":"NotAuthorized","message":"Not Enough Gold"} * * @apiErrorExample {json} NotFound Item not found * {"success":false,"error":"NotFound","message":"Item \"weapon_misspelled_1\" not found."} */ api.buyGear = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/buy-gear/:key', async handler (req, res) { const { user } = res.locals; const buyGearRes = await common.ops.buy(user, req, res.analytics); await user.save(); res.respond(200, ...buyGearRes); }, }; /** * @api {post} /api/v3/user/buy-armoire Buy an Enchanted Armoire item * @apiName UserBuyArmoire * @apiGroup User * * @apiSuccess {Object} data.items User's item inventory * @apiSuccess {Object} data.flags User's flags * @apiSuccess {Object} data.armoire Item given by the armoire * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Received a fish: * { * "success": true, * "data": { * ---DATA TRUNCATED--- * "armoire": { * "type": "food", * "dropKey": "Fish", * "dropArticle": "a ", * "dropText": "Fish" * } * }, * * @apiError (400) {NotAuthorized} messageNotEnoughGold Not enough gold for the purchase * * @apiErrorExample {json} NotAuthorized Not enough gold * {"success":false,"error":"NotAuthorized","message":"Not Enough Gold"} */ api.buyArmoire = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/buy-armoire', async handler (req, res) { const { user } = res.locals; req.type = 'armoire'; req.params.key = 'armoire'; if (OFFICIAL_PLATFORMS.indexOf(req.headers['x-client']) === -1) { res.analytics = undefined; } const buyArmoireResponse = await common.ops.buy(user, req, res.analytics); await user.save(); await UserHistory.beginUserHistoryUpdate(user._id, req.headers) .withArmoire(buyArmoireResponse[0].armoire.dropKey || 'experience') .commit(); res.respond(200, ...buyArmoireResponse); }, }; /** * @api {post} /api/v3/user/buy-health-potion Buy a health potion * @apiName UserBuyPotion * @apiGroup User * * @apiSuccess {Object} data User's current stats * @apiSuccess {String} message Success message * * @apiSuccessExample Example return: * { * "success": true, * "data": { * ---DATA TRUNCATED--- * }, * "message": "Bought Health Potion" * } * * @apiError (400) {NotAuthorized} messageNotEnoughGold Not enough gold for the purchase * @apiError (400) {NotAuthorized} messageHealthAlreadyMax Health is already full. * * @apiErrorExample {json} NotAuthorized Not enough gold * {"success":false,"error":"NotAuthorized","message":"Not Enough Gold"} * @apiErrorExample {json} NotAuthorized Already at max health * {"success":false,"error":"NotAuthorized","message":"You already have maximum health."} * */ api.buyHealthPotion = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/buy-health-potion', async handler (req, res) { const { user } = res.locals; req.type = 'potion'; req.params.key = 'potion'; const buyHealthPotionResponse = await common.ops.buy(user, req, res.analytics); await user.save(); res.respond(200, ...buyHealthPotionResponse); }, }; /** * @api {post} /api/v3/user/buy-mystery-set/:key Buy a Mystery Item set * @apiName UserBuyMysterySet * @apiDescription This buys a Mystery Item set using an Hourglass. * @apiGroup User * * @apiParam (Path) {String} key The mystery set to buy * * @apiSuccess {Object} data.items user.items * @apiSuccess {Object} data.purchasedPlanConsecutive user.purchased.plan.consecutive * @apiSuccess {String} message Success message * * @apiSuccessExample{json} Successful purchase * { * "success": true, * "data": { * ---DATA TRUNCATED--- * }, * "message": "Purchased an item set using a Mystic Hourglass!" * } * * @apiError (400) {NotAuthorized} notEnoughHourglasses Not enough Mystic Hourglasses. * @apiError (400) {NotFound} mysterySetNotFound Specified item does not exist or already owned. * * @apiErrorExample {json} Not enough hourglasses * {"success":false,"error":"NotAuthorized","message":"You don't have enough Mystic Hourglasses."} * @apiErrorExample {json} Already own, or doesn't exist * {"success":false,"error":"NotFound","message":"Mystery set not found, or set already owned."} */ api.buyMysterySet = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/buy-mystery-set/:key', async handler (req, res) { const { user } = res.locals; req.type = 'mystery'; const buyMysterySetRes = await common.ops.buy(user, req, res.analytics); await user.save(); res.respond(200, ...buyMysterySetRes); }, }; /** * @api {post} /api/v3/user/buy-quest/:key Buy a quest with gold * @apiName UserBuyQuest * @apiGroup User * * @apiParam (Path) {String} key The quest scroll to buy * * @apiSuccess {Object} data.quests User's quest list * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Success response: * { * "success": true, * "data": { * --- DATA TRUNCATED--- * }, * "message": "Bought Dilatory Distress, Part 1: Message in a Bottle" * } * * @apiError (400) {NotFound} questNotFound Specified quest does not exist * @apiError (400) {NotAuthorized} messageNotEnoughGold Not enough gold for the purchase * * @apiErrorExample {json} Quest chosen does not exist * {"success":false,"error":"NotFound","message":"Quest \"dilatoryDistress99\" not found."} * @apiErrorExample {json} You must first complete this quest's prerequisites * {"success":false,"error":"NotAuthorized","message":"You must first complete dilatoryDistress2."} * @apiErrorExample {json} NotAuthorized Not enough gold * {"success":false,"error":"NotAuthorized","message":"Not Enough Gold"} * */ api.buyQuest = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/buy-quest/:key', async handler (req, res) { const { user } = res.locals; req.type = 'quest'; const buyQuestRes = await common.ops.buy(user, req, res.analytics); await user.save(); res.respond(200, ...buyQuestRes); }, }; /** * @api {post} /api/v3/user/buy-special-spell/:key Buy special item (card, avatar transformation) * @apiDescription Includes gift cards (e.g., birthday card), and avatar Transformation * Items and their antidotes (e.g., Snowball item and Salt reward). * @apiName UserBuySpecialSpell * @apiGroup User * * @apiParam (Path) {String} key The special item to buy. Must be one of the keys * from "content.special", such as birthday, snowball, salt. * * @apiSuccess {Object} data.stats User's current stats * @apiSuccess {Object} data.items User's current inventory * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Purchased a greeting card: * { * "success": true, * "data": { * }, * "message": "Bought Greeting Card" * } * * @apiError (400) {NotAuthorized} messageNotEnoughGold Not enough gold for the purchase * * @apiErrorExample {json} Not enough gold * {"success":false,"error":"NotAuthorized","message":"Not Enough Gold"} * @apiErrorExample {json} Item name not found * {"success":false,"error":"NotFound","message":"Skill \"happymardigras\" not found."} */ api.buySpecialSpell = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/buy-special-spell/:key', async handler (req, res) { const { user } = res.locals; req.type = 'special'; const buySpecialSpellRes = await common.ops.buy(user, req); await user.save(); res.respond(200, ...buySpecialSpellRes); }, }; /** * @api {post} /api/v3/user/hatch/:egg/:hatchingPotion Hatch a pet * @apiName UserHatch * @apiGroup User * * @apiParam (Path) {String} egg The egg to use * @apiParam (Path) {String} hatchingPotion The hatching potion to use * @apiParamExample {URL} Example-URL * https://habitica.com/api/v3/user/hatch/Dragon/CottonCandyPink * * @apiSuccess {Object} data user.items * @apiSuccess {String} message * * @apiSuccessExample {json} Successfully hatched * { * "success": true, * "data": {}, * "message": "Your egg hatched! Visit your stable to equip your pet." * } * * @apiError {NotAuthorized} messageAlreadyPet Already have the specific pet combination * @apiError {NotFound} messageMissingEggPotion One or both of the ingredients are missing. * @apiError {NotFound} messageInvalidEggPotionCombo Cannot use that combination of egg and potion. * * @apiErrorExample {json} Already have that pet. * {"success":false,"error":"NotAuthorized","message":"You already have that pet. * Try hatching a different combination"} * @apiErrorExample {json} Either potion or egg (or both) not in inventory * {"success":false,"error":"NotFound","message":"You're missing either that egg or that potion"} * @apiErrorExample {json} Cannot use that combination * {"success":false,"error":"NotAuthorized","message":"You can't hatch Quest * Pet Eggs with Magic Hatching Potions! Try a different egg."} */ api.hatch = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/hatch/:egg/:hatchingPotion', async handler (req, res) { const { user } = res.locals; const hatchRes = common.ops.hatch(user, req, res.analytics); await user.save(); res.respond(200, ...hatchRes); // Send webhook const petKey = `${req.params.egg}-${req.params.hatchingPotion}`; userActivityWebhook.send(user, { type: 'petHatched', pet: petKey, message: hatchRes[1], }); }, }; /** * @api {post} /api/v3/user/equip/:type/:key Equip or unequip an item * @apiName UserEquip * @apiGroup User * * @apiParam (Path) {String="mount","pet","costume","equipped"} type The type of item * to equip or unequip. * @apiParam (Path) {String} key The item to equip or unequip * * @apiParamExample {URL} Example-URL * https://habitica.com/api/v3/user/equip/equipped/weapon_warrior_2 * * @apiSuccess {Object} data user.items * @apiSuccess {String} message Optional success message for unequipping an items * * @apiSuccessExample {json} Example return: * { * "success": true, * "data": {---DATA TRUNCATED---}, * "message": "Training Sword unequipped." * } * * @apiError {NotFound} notOwned Item is not in inventory, item doesn't * exist, or item is of the wrong type. * * @apiErrorExample {json} Item not owned or doesn't exist. * {"success":false,"error":"NotFound","message":"You do not own this item."} * {"success":false,"error":"NotFound","message":"You do not own this pet."} * {"success":false,"error":"NotFound","message":"You do not own this mount."} * */ api.equip = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/equip/:type/:key', async handler (req, res) { const { user } = res.locals; const equipRes = common.ops.equip(user, req); await user.save(); res.respond(200, ...equipRes); }, }; /** * @api {post} /api/v3/user/feed/:pet/:food Feed a pet * @apiName UserFeed * @apiGroup User * * @apiParam (Path) {String} pet * @apiParam (Path) {String} food * @apiParam (Query) {Number} [amount] The amount of food to feed. * Note: Pet can eat 50 units. * Preferred food offers 5 units per food, * other food 2 units. * * @apiParamExample {url} Example-URL * https://habitica.com/api/v3/user/feed/Armadillo-Shade/Chocolate * https://habitica.com/api/v3/user/feed/Armadillo-Shade/Chocolate?amount=9 * * @apiSuccess {Number} data The pet value * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * {"success":true,"data":10,"message":"Shade Armadillo * really likes the Chocolate!","notifications":[]} * * @apiError {NotFound} PetNotOwned :pet not found in user.items.pets * @apiError {BadRequest} InvalidPet Invalid pet name supplied. * @apiError {NotFound} FoodNotOwned :food not found in user.items.food * Note: also sent if food name is invalid. * @apiError {NotAuthorized} notEnoughFood :Not enough food to feed the pet as requested. * @apiError {NotAuthorized} tooMuchFood :You try to feed too much food. Action ancelled. * * */ api.feed = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/feed/:pet/:food', async handler (req, res) { const { user } = res.locals; const feedRes = common.ops.feed(user, req, res.analytics); await user.save(); res.respond(200, ...feedRes); // Send webhook const petValue = feedRes[0]; if (petValue === -1) { // evolved to mount userActivityWebhook.send(user, { type: 'mountRaised', pet: req.params.pet, message: feedRes[1], }); } }, }; /** * @api {post} /api/v3/user/change-class Change class * @apiDescription User must be at least level 10. If ?class is * defined and user.flags.classSelected is false it'll change the class. * If user.preferences.disableClasses it'll enable classes, otherwise it * sets user.flags.classSelected to false (costs 3 gems). * @apiName UserChangeClass * @apiGroup User * * @apiParam (Query) {String} class Query parameter - ?class={warrior|rogue|wizard|healer} * * @apiSuccess {Object} data.flags user.flags * @apiSuccess {Object} data.stats user.stats * @apiSuccess {Object} data.preferences user.preferences * @apiSuccess {Object} data.items user.items * * @apiError {NotAuthorized} Gems Not enough gems, if class was already * selected and gems needed to be paid. * @apiError {NotAuthorized} Level To change class you must be at least level 10. * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Not enough Gems"} */ api.changeClass = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/change-class', async handler (req, res) { const { user } = res.locals; const changeClassRes = await common.ops.changeClass(user, req, res.analytics); await user.save(); res.respond(200, ...changeClassRes); }, }; /** * @api {post} /api/v3/user/disable-classes Disable classes * @apiName UserDisableClasses * @apiGroup User * * @apiSuccess {Object} data.flags user.flags * @apiSuccess {Object} data.stats user.stats * @apiSuccess {Object} data.preferences user.preferences */ api.disableClasses = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/disable-classes', async handler (req, res) { const { user } = res.locals; const disableClassesRes = common.ops.disableClasses(user, req); await user.save(); res.respond(200, ...disableClassesRes); }, }; /** * @api {post} /api/v3/user/purchase/:type/:key Purchase Gem or Gem-purchasable item * @apiName UserPurchase * @apiGroup User * * @apiParam (Path) {String="gems","eggs","hatchingPotions","premiumHatchingPotions" ,"food","quests","gear","pets"} type Type of item to purchase. * @apiParam (Path) {String} key Item's key (use "gem" for purchasing gems) * * @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 {Number} data.balance user.balance * @apiSuccess {String} message Success message * * @apiError {NotAuthorized} NotAvailable Item is not available to be purchased * (not unlocked for the user). * @apiError {NotAuthorized} Gems Not enough gems * @apiError {NotFound} Key Key not found for Content type. * @apiError {NotFound} Type Type invalid. * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message": * "This item is not currently available for purchase."} */ api.purchase = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/purchase/:type/:key', async handler (req, res) { const { user } = res.locals; const type = get(req.params, 'type'); const key = get(req.params, 'key'); // Some groups limit their members ability to obtain gems // The check is async so it's done on the server only and not on the client, // resulting in a purchase that will seem successful until the request hit the server. if (type === 'gems' && key === 'gem') { const canGetGems = await user.canGetGems(); if (!canGetGems) throw new NotAuthorized(res.t('groupPolicyCannotGetGems')); } // Req is currently used as options. Slightly confusing, but this will solve that for now. let quantity = 1; if (req.body.quantity) quantity = req.body.quantity; req.quantity = quantity; const purchaseRes = await common.ops.buy(user, req, res.analytics); await user.save(); res.respond(200, ...purchaseRes); }, }; /** * @api {post} /api/v3/user/purchase-hourglass/:type/:key Purchase Hourglass-purchasable item * @apiName UserPurchaseHourglass * @apiDescription Purchases an Hourglass-purchasable item. * Does not include Mystery Item sets (use /api/v3/user/buy-mystery-set/:key). * @apiGroup User * * @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} Example error: * {"success":false,"error":"NotAuthorized","message":"You don't have enough Mystic Hourglasses."} */ api.userPurchaseHourglass = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/purchase-hourglass/:type/:key', async handler (req, res) { const { user } = res.locals; const quantity = req.body.quantity || 1; if (quantity < 1 || !Number.isInteger(quantity)) throw new BadRequest(res.t('invalidQuantity'), req.language); const purchaseHourglassRes = await common.ops.buy( user, req, res.analytics, { quantity, hourglass: true }, ); await user.save(); res.respond(200, ...purchaseHourglassRes); }, }; /** * @api {post} /api/v3/user/read-card/:cardType Read a card * @apiName UserReadCard * @apiGroup User * * @apiParam (Path) {String} cardType Type of card to read (e.g. - birthday, * greeting, nye, thankyou, valentine). * * @apiSuccess {Object} data.specialItems user.items.special * @apiSuccess {Boolean} data.cardReceived user.flags.cardReceived * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": { * "specialItems": { * "snowball": 0, * "spookySparkles": 0, * "shinySeed": 0, * "seafoam": 0, * "valentine": 0, * "valentineReceived": [], * "nye": 0, * "nyeReceived": [], * "greeting": 0, * "greetingReceived": [ * "MadPink" * ], * "thankyou": 0, * "thankyouReceived": [], * "birthday": 0, * "birthdayReceived": [] * }, * "cardReceived": false * }, * "message": "valentine has been read" * } * * @apiError {NotAuthorized} CardType Unknown card type. */ api.readCard = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/read-card/:cardType', async handler (req, res) { const { user } = res.locals; const readCardRes = common.ops.readCard(user, req); await user.save(); res.respond(200, ...readCardRes); }, }; /** * @api {post} /api/v3/user/open-mystery-item Open the Mystery Item box * @apiName UserOpenMysteryItem * @apiGroup User * * @apiSuccess {Object} data The item obtained * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * { "success": true, * "data": { * "mystery": "201612", * "value": 0, * "type": "armor", * "key": "armor_mystery_201612", * "set": "mystery-201612", * "klass": "mystery", * "index": "201612", * "str": 0, * "int": 0, * "per": 0, * "con": 0 * }, * "message": "Mystery item opened." * * @apiError {BadRequest} Empty No mystery items to open. * * @apiErrorExample {json} Example error: * {"success":false,"error":"BadRequest","message":"Mystery items are empty"} */ api.userOpenMysteryItem = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/open-mystery-item', async handler (req, res) { const { user } = res.locals; const openMysteryItemRes = common.ops.openMysteryItem(user, req, res.analytics); await user.save(); res.respond(200, ...openMysteryItemRes); }, }; /* @api {post} /api/v3/user/release-pets Release pets * @apiName UserReleasePets * @apiGroup User * * @apiSuccess {Object} data.items `user.items.pets` * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": { * }, * "message": "Pets released" * } * * @apiError {NotAuthorized} Gems Not enough gems * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Not enough Gems"} */ api.userReleasePets = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/release-pets', async handler (req, res) { const { user } = res.locals; const releasePetsRes = await common.ops.releasePets(user, req, res.analytics); await user.save(); res.respond(200, ...releasePetsRes); }, }; /** * @api {post} /api/v3/user/release-both Release pets and mounts and grants Triad Bingo * @apiName UserReleaseBoth * @apiGroup User * * @apiSuccess {Object} data.achievements * @apiSuccess {Object} data.items * @apiSuccess {Number} data.balance * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": { * "achievements": { * "ultimateGearSets": {}, * "challenges": [], * "quests": {}, * "perfect": 0, * "beastMaster": true, * "beastMasterCount": 1, * "mountMasterCount": 1, * "triadBingoCount": 1, * "mountMaster": true, * "triadBingo": true * }, * "items": {} * }, * "message": "Mounts and pets released" * } * * @apiError {NotAuthorized} Gems Not enough gems * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Not enough Gems"} */ api.userReleaseBoth = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/release-both', async handler (req, res) { const { user } = res.locals; const releaseBothRes = common.ops.releaseBoth(user, req, res.analytics); await user.save(); res.respond(200, ...releaseBothRes); }, }; /** * @api {post} /api/v3/user/release-mounts Release mounts * @apiName UserReleaseMounts * @apiGroup User * * @apiSuccess {Object} data user.items.mounts * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": { * }, * "items": {} * }, * "message": "Mounts released" * } * * @apiError {NotAuthorized} Gems Not enough gems * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Not enough Gems"} * */ api.userReleaseMounts = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/release-mounts', async handler (req, res) { const { user } = res.locals; const releaseMountsRes = await common.ops.releaseMounts(user, req, res.analytics); await user.save(); res.respond(200, ...releaseMountsRes); }, }; /** * @api {post} /api/v3/user/sell/:type/:key Sell a gold-sellable item owned by the user * @apiName UserSell * @apiGroup User * * @apiParam (Path) {String="eggs","hatchingPotions","food"} type The type of item to sell. * @apiParam (Path) {String} key The key of the item * @apiParam (Query) {Number} [amount] The amount to sell * * @apiSuccess {Object} data.stats * @apiSuccess {Object} data.items * * @apiError {NotFound} InvalidKey Key not found for user.items eggs * (either the key does not exist or the * user has none in inventory). * @apiError {NotAuthorized} InvalidType Type is not a valid type. * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Type is not sellable. * Must be one of the following eggs, hatchingPotions, food"} */ api.userSell = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/sell/:type/:key', async handler (req, res) { const { user } = res.locals; const sellRes = common.ops.sell(user, req); await user.save(); res.respond(200, ...sellRes); }, }; /** * @api {post} /api/v3/user/unlock Unlock item or set of items by purchase * @apiName UserUnlock * @apiGroup User * * @apiParam (Query) {String} path Full path to unlock. See "content" API call for list of items. * * @apiParamExample {curl} Example call: * curl -X POST https://habitica.com/api/v3/user/unlock?path=background.midnight_clouds * curl -X POST https://habitica.com/api/v3/user/unlock?path=hair.color.midnight * * @apiSuccess {Object} data.purchased * @apiSuccess {Object} data.items * @apiSuccess {Object} data.preferences * @apiSuccess {String} message "Items have been unlocked" * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": {}, * "message": "Items have been unlocked" * } * * @apiError {BadRequest} Path Path to unlock not specified * @apiError {NotAuthorized} Gems Not enough gems available. * @apiError {NotAuthorized} Unlocked Full set already unlocked. * * @apiErrorExample {json} Example error: * {"success":false,"error":"BadRequest","message":"Path string is required"} * {"success":false,"error":"NotAuthorized","message":"Full set already unlocked."} */ api.userUnlock = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/unlock', async handler (req, res) { const { user } = res.locals; const unlockRes = await common.ops.unlock(user, req, res.analytics); await user.save(); res.respond(200, ...unlockRes); }, }; /** * @api {post} /api/v3/user/revive Revive user from death * @apiName UserRevive * @apiGroup User * * @apiSuccess {Object} data user.items * @apiSuccess {String} message Success message * * * @apiError {NotAuthorized} NotDead Cannot revive player if player is not dead yet * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Cannot revive if not dead"} */ api.userRevive = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/revive', async handler (req, res) { const { user } = res.locals; const reviveRes = common.ops.revive(user, req, res.analytics); await user.save(); res.respond(200, ...reviveRes); }, }; /* NOTE this route has also an API v4 version */ /** * @api {post} /api/v3/user/rebirth Use Orb of Rebirth on user * @apiName UserRebirth * @apiGroup User * * @apiSuccess {Object} data.user * @apiSuccess {Array} data.tasks User's modified tasks (no rewards) * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": { * }, * "message": "You have been reborn!" * { * "type": "REBIRTH_ACHIEVEMENT", * "data": {}, * "id": "424d69fa-3a6d-47db-96a4-6db42ed77a43" * } * ] * } * * @apiError {NotAuthorized} Gems Not enough gems * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Not enough Gems"} */ api.userRebirth = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/rebirth', async handler (req, res) { await userLib.rebirth(req, res, { isV3: true }); }, }; /** * @api {post} /api/v3/user/block/:uuid Block / unblock a user from sending you a PM * @apiName BlockUser * @apiGroup User * * @apiParam (Path) {UUID} uuid The uuid of the user to block / unblock * * @apiSuccess {Array} data user.inbox.blocks * * @apiSuccessExample {json} Example return: * {"success":true,"data":["e4842579-g987-d2d2-8660-2f79e725fb79"],"notifications":[]} * * @apiError {BadRequest} InvalidUUID UUID is incorrect. * */ api.blockUser = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/block/:uuid', async handler (req, res) { const { user } = res.locals; const blockUserRes = common.ops.blockUser(user, req); await user.save(); res.respond(200, ...blockUserRes); }, }; /* NOTE this route has also an API v4 version */ /** * @api {delete} /api/v3/user/messages/:id Delete a message * @apiName deleteMessage * @apiGroup User * * @apiParam (Path) {UUID} id The id of the message to delete * * @apiSuccess {Object} data user.inbox.messages * @apiSuccessExample {json} Example return: * { * "success": true, * "data": { * "74d9a2e7-4c6e-4f3b-c3c4-517873f41592": { * "sort": 0, * "user": "MadPink", * "backer": {}, * "contributor": {}, * "uuid": "b0413351-405f-416f-9999-947ec1c85199", * "flagCount": 0, * "flags": {}, * "likes": {}, * "timestamp": 1487276826704, * "text": "Hi there!", * "id": "74d9a2e7-4c6e-4f3b-c3c4-517873f41592" * } * } * } */ api.deleteMessage = { method: 'DELETE', middlewares: [authWithHeaders()], url: '/user/messages/:id', async handler (req, res) { const { user } = res.locals; await inboxLib.deleteMessage(user, req.params.id); res.respond(200, ...[await inboxLib.getUserInbox(user, { asArray: false })]); }, }; /* NOTE this route has also an API v4 version */ /** * @api {delete} /api/v3/user/messages Delete all messages * @apiName clearMessages * @apiGroup User * * @apiSuccess {Object} data user.inbox.messages which should be empty * * @apiSuccessExample {json} Example return: * {"success":true,"data":{},"notifications":[]} */ api.clearMessages = { method: 'DELETE', middlewares: [authWithHeaders()], url: '/user/messages', async handler (req, res) { const { user } = res.locals; await inboxLib.clearPMs(user); res.respond(200, ...[]); }, }; /** * @api {post} /api/v3/user/mark-pms-read Mark Private Messages as read * @apiName markPmsRead * @apiGroup User * * @apiSuccess {Object} data user.inbox.newMessages * * @apiSuccessExample {json} Example return: * {"success":true,"data":[0,"Your private messages have been marked as read"],"notifications":[]} * */ api.markPmsRead = { method: 'POST', middlewares: [authWithHeaders({ userFieldsToInclude: ['inbox'] })], url: '/user/mark-pms-read', async handler (req, res) { const { user } = res.locals; const markPmsResponse = common.ops.markPmsRead(user); await user.save(); res.respond(200, markPmsResponse); }, }; /* NOTE this route has also an API v4 version */ /** * @api {post} /api/v3/user/reroll Reroll a user (reset tasks) using the Fortify Potion * @apiName UserReroll * @apiGroup User * * @apiSuccess {Object} data.user * @apiSuccess {Object} data.tasks User's modified tasks (no rewards) * @apiSuccess {Object} message Success message * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": { * }, * "message": "Fortify complete!" * } * * @apiError {NotAuthorized} Gems Not enough gems * * @apiErrorExample {json} Example error: * {"success":false,"error":"NotAuthorized","message":"Not enough Gems"} */ api.userReroll = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/reroll', async handler (req, res) { await userLib.reroll(req, res, { isV3: true }); }, }; /* NOTE this route has also an API v4 version */ /** * @api {post} /api/v3/user/reset Reset user * @apiName UserReset * @apiGroup User * * @apiSuccess {Object} data.user * @apiSuccess {Array} data.tasksToRemove IDs of removed tasks * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Example success: * { * "success": true, * "data": {--TRUNCATED--}, * "tasksToRemove": [ * "ebb8748c-0565-431e-9036-b908da25c6b4", * "12a1cecf-68eb-40a7-b282-4f388c32124c" * ] * }, * "message": "Reset complete!" * } */ api.userReset = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/reset', async handler (req, res) { await userLib.reset(req, res, { isV3: true }); }, }; /** * @api {post} /api/v3/user/custom-day-start Set Custom Day Start time for user. * @apiName setCustomDayStart * @apiGroup User * * @apiParam (Body) {number} [dayStart=0] The hour number 0-23 for day to begin. * If not supplied, will default to 0. * * @apiParamExample {json} Request-Example: * {"dayStart":2} * * @apiSuccess {Object} data An empty Object * @apiSuccess {String} message Success message * * @apiSuccessExample {json} Success-Example: * {"success":true,"data":{"message":"Your custom day start has changed."},"notifications":[]} * * @apiError {BadRequest} Validation Value provided is not a number, or is outside the range of 0-23 * * @apiErrorExample {json} Error-Example: * {"success":false,"error":"BadRequest","message":"User validation failed", * "errors":[{"message":"Path `preferences.dayStart` (25) is more than maximum allowed value (23)." * ,"path":"preferences.dayStart","value":25}]} */ api.setCustomDayStart = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/custom-day-start', async handler (req, res) { const { user } = res.locals; const { dayStart } = req.body; user.preferences.dayStart = dayStart; user.lastCron = new Date(); await user.save(); res.respond(200, { message: res.t('customDayStartHasChanged'), }); }, }; /** * @api {get} /user/toggle-pinned-item/:key Toggle an item to be pinned * @apiName togglePinnedItem * @apiGroup User * * @apiSuccess {Object} data Pinned items array * * @apiSuccessExample {json} Result: * { * "success": true, * "data": { * "pinnedItems": [ * "type": "gear", * "path": "gear.flat.weapon_1" * ] * } * } * */ api.togglePinnedItem = { method: 'GET', middlewares: [authWithHeaders()], url: '/user/toggle-pinned-item/:type/:path', async handler (req, res) { const { user } = res.locals; const path = get(req.params, 'path'); const type = get(req.params, 'type'); common.ops.pinnedGearUtils.togglePinnedItem(user, { type, path }, req); await user.save(); const userJson = user.toJSON(); res.respond(200, { pinnedItems: userJson.pinnedItems, unpinnedItems: userJson.unpinnedItems, }); }, }; /** * @api {post} /api/v3/user/move-pinned-item/:type/:path/move/to/:position * Move a pinned item in the rewards column to a new position after being sorted * @apiName MovePinnedItem * @apiGroup User * * @apiParam (Path) {String} path The unique item path used for pinning * @apiParam (Path) {Number} position Where to move the task. * 0 = top of the list ("push to top"). * -1 = bottom of the list ("push to bottom"). * * @apiSuccess {Array} data The new pinned items order. * * @apiSuccessExample {json} Example success: * {"success":true,"data":{"path":"quests.mayhemMistiflying3","type":"quests", * "_id": "5a32d357232feb3bc94c2bdf"},"notifications":[]} * * @apiUse TaskNotFound */ api.movePinnedItem = { method: 'POST', url: '/user/move-pinned-item/:path/move/to/:position', middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('path', res.t('taskIdRequired')).notEmpty(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; const { user } = res.locals; const { path } = req.params; let position = Number(req.params.position); // If something has been added or removed from the inAppRewards, we need // to reset pinnedItemsOrder to have the correct length. Since inAppRewards // Uses the current pinnedItemsOrder to return these in the right order, // the new reset array will be in the right order before we do the swap const currentPinnedItems = common.inAppRewards(user); if (user.pinnedItemsOrder.length !== currentPinnedItems.length) { user.pinnedItemsOrder = currentPinnedItems.map(item => item.path); } const officialItems = common.getOfficialPinnedItems(user); const itemExistInPinnedArray = user.pinnedItems.findIndex(item => item.path === path); const itemExistInOfficialItems = officialItems.findIndex(item => item.path === path); if (itemExistInPinnedArray === -1 && itemExistInOfficialItems === -1) { throw new BadRequest(res.t('wrongItemPath', { path }, req.language)); } // Adjust the order const currentIndex = user.pinnedItemsOrder.findIndex(item => item === path); const currentPinnedItemPath = user.pinnedItemsOrder[currentIndex]; if (currentIndex !== -1) { // Remove the one we will move user.pinnedItemsOrder.splice(currentIndex, 1); } else { // usually the array would be already fixed by the inAppRewards call // but it seems something didn't work out position = Math.min(position, user.pinnedItemsOrder.length - 1); } // reinsert the item in position (or just at the end) if (position === -1) { user.pinnedItemsOrder.push(currentPinnedItemPath); } else { user.pinnedItemsOrder.splice(position, 0, currentPinnedItemPath); } await user.save(); const userJson = user.toJSON(); res.respond(200, userJson.pinnedItemsOrder); }, }; /** * @api {post} /api/v3/user/stat-sync * Request a refresh of user stats, including processing of pending level-ups * @apiName StatSync * @apiGroup User * * @apiSuccess {Object} data The user object */ api.statSync = { method: 'POST', middlewares: [authWithHeaders()], url: '/user/stat-sync', async handler (req, res) { const { user } = res.locals; common.fns.updateStats(user, user.stats); await user.save(); res.respond(200, user); }, }; export default api;