Merged in develop

This commit is contained in:
Keith Holliday
2017-06-27 22:23:13 -06:00
897 changed files with 50434 additions and 41464 deletions
+56
View File
@@ -13,3 +13,59 @@
/**
* @apiDefine Query Query Parameters
*/
/**
* @apiDefine Admin Moderators
* Contributors of tier 8 or higher can use this route.
*/
/**
* @apiDefine NoAuthHeaders Missing authentication headers
*
* @apiError (401) {NotAuthorized} NoAuthHeaders Missing authentication headers
*
* @apiErrorExample Missing authentication headers
* {
* "success": false,
* "error": "NotAuthorized",
* "message": "Missing authentication headers."
* }
*/
/**
* @apiDefine NoAccount There is no account that uses those credentials.
*
* @apiError (401) {NotAuthorized} NoAccount There is no account that uses those credentials
*
* @apiErrorExample No account
* {
* "success": false,
* "error": "NotAuthorized",
* "message": "There is no account that uses those credentials."
* }
*/
/**
* @apiDefine NotAdmin You don't have admin access.
*
* @apiError (401) {NotAuthorized} NotAdmin User is not an admin
*
* @apiErrorExample No admin access
* {
* "success": false,
* "error": "NotAuthorized",
* "message": "You don't have admin access."
* }
*/
/**
* @apiDefine NoUser No user
* @apiError (404) {NotFound} NoUser The specified user could not be found.
*
* @apiErrorExample No user
* {
* "success": false,
* "error": "NotFound",
* "message": "User with id \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\" not found."
* }
*/
@@ -228,6 +228,12 @@ api.createChallenge = {
let challengeValidationErrors = challenge.validateSync();
if (challengeValidationErrors) throw challengeValidationErrors;
// Add achievement if user's first challenge
if (!user.achievements.joinedChallenge) {
user.achievements.joinedChallenge = true;
user.addNotification('CHALLENGE_JOINED_ACHIEVEMENT');
}
let results = await Bluebird.all([challenge.save({
validateBeforeSave: false, // already validate
}), group.save()]);
@@ -286,6 +292,12 @@ api.joinChallenge = {
challenge.memberCount += 1;
// Add achievement if user's first challenge
if (!user.achievements.joinedChallenge) {
user.achievements.joinedChallenge = true;
user.addNotification('CHALLENGE_JOINED_ACHIEVEMENT');
}
// Add all challenge's tasks to user's tasks and save the challenge
let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
+1 -3
View File
@@ -117,8 +117,6 @@ function textContainsBannedWords (message) {
* @apiParam (Body) {String} message Message The message to post
* @apiParam (Query) {UUID} previousMsg The previous chat message's UUID which will force a return of the full group chat
*
* @apiSuccess data An array of <a href='https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js#L51' target='_blank'>chat messages</a> if a new message was posted after previousMsg, otherwise the posted message
*
* @apiUse GroupNotFound
* @apiUse GroupIdRequired
* @apiError (400) {NotFound} ChatPriviledgesRevoked Your chat privileges have been revoked
@@ -143,7 +141,7 @@ api.postChat = {
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.privacy !== 'private' && user.flags.chatRevoked) {
throw new NotFound('Your chat privileges have been revoked.');
throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
}
if (group._id === TAVERN_ID && textContainsBannedWords(req.body.message)) {
+19 -12
View File
@@ -396,7 +396,7 @@ api.getGroup = {
* @apiUse groupIdRequired
* @apiUse GroupNotFound
*
* @apiPermission GroupLeader
* @apiPermission GroupLeader, Admin
*/
api.updateGroup = {
method: 'PUT',
@@ -409,11 +409,13 @@ api.updateGroup = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let optionalMembership = Boolean(user.contributor.admin);
let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership});
let group = await Group.getGroup({user, groupId: req.params.groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
if (group.leader !== user._id && group.type === 'party') throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
else if (group.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
if (req.body.leader !== user._id && group.hasNotCancelled()) throw new NotAuthorized(res.t('cannotChangeLeaderWithActiveGroupPlan'));
@@ -472,7 +474,7 @@ api.joinGroup = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
// Works even if the user is not yet a member of the group
// Works even if the user is not yet a member of the group
let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group
if (!group) throw new NotFound(res.t('groupNotFound'));
@@ -760,7 +762,7 @@ function _sendMessageToRemoved (group, removedUser, message, isInGroup) {
*
* @apiSuccess {Object} data An empty object
*
* @apiPermission GroupLeader
* @apiPermission GroupLeader, Admin
*
* @apiUse groupIdRequired
* @apiUse GroupNotFound
@@ -777,13 +779,18 @@ api.removeGroupMember = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let optionalMembership = Boolean(user.contributor.admin);
let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership, fields: '-chat'}); // Do not fetch chat
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat
if (!group) throw new NotFound(res.t('groupNotFound'));
let uuid = req.params.memberId;
if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
if (group.leader !== user._id && group.type === 'party') throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
if (group.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
if (group.leader === uuid && user.contributor.admin) throw new NotAuthorized(res.t('cannotRemoveCurrentLeader'));
if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself'));
let member = await User.findOne({_id: uuid}).exec();
@@ -946,12 +953,12 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
let userToContact = await User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
]})
.select({_id: true, 'preferences.emailNotifications': true})
.exec();
.select({_id: true, 'preferences.emailNotifications': true})
.exec();
if (userToContact) {
userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res);
+100 -16
View File
@@ -6,22 +6,56 @@ import {
} from '../../libs/errors';
import _ from 'lodash';
/**
* @apiDefine Admin Moderators
* Contributors of tier 8 or higher can use this route.
*/
let api = {};
/**
* @api {get} /api/v3/hall/patrons Get all patrons
* @apiDescription Only the first 50 patrons are returned. More can be accessed passing ?page=n
* @apiDescription Returns an array of objects containing the patrons who backed Habitica's
* original kickstarter. The array is sorted by the backer tier in descending order.
* By default, only the first 50 patrons are returned. More can be accessed by passing ?page=n
* @apiName GetPatrons
* @apiGroup Hall
*
* @apiParam {Number} page Query Parameter - The result page. Default is 0
*
* @apiParam (Query) {Number} [page=0] The result page.
* @apiSuccess {Array} data An array of patrons
*
* @apiSuccessExample {json} Example response
* {
* "success": true,
* "data": [
* {
* "_id": "3adb52a9-0dfb-4752-81f2-a62d911d1bf5",
* "profile": {
* "name": "mattboch"
* },
* "contributor": {},
* "backer": {
* "tier": 800,
* "npc": "Beast Master"
* }
* },
* {
* "_id": "9da65443-ed43-4c21-804f-d260c1361596",
* "profile": {
* "name": "ʎǝlᴉɐq s,┴I"
* },
* "contributor": {
* "text": "Pollen Purveyor",
* "admin": true,
* "level": 8
* },
* "backer": {
* "npc": "Town Crier",
* "tier": 800,
* "tokensApplied": true
* }
* }
* ]
* }
*
*
* @apiUse NoAuthHeaders
* @apiUse NoAccount
*/
api.getPatrons = {
method: 'GET',
@@ -56,7 +90,32 @@ api.getPatrons = {
* @apiName GetHeroes
* @apiGroup Hall
*
* @apiSuccess {Array} data An array of heroes
* @apiDescription Returns an array of objects containing the heroes who have
* contributed for Habitica. The array is sorted by the contribution level in descending order.
*
* @apiSuccess {Array} heroes An array of heroes
*
* @apiSuccessExample {json} Example response:
* {
* "success": true,
* "data": [
* {
* "_id": "e6e01d2a-c2fa-4b9f-9c0f-7865b777e7b5",
* "profile": {
* "name": "test2"
* },
* "contributor": {
* "admin": false,
* "level": 2,
* "text": "Linguist"
* },
* "backer": {}
* }
* ]
* }
*
* @apiUse NoAuthHeaders
* @apiUse NoAccount
*/
api.getHeroes = {
method: 'GET',
@@ -83,14 +142,19 @@ const heroAdminFields = 'contributor balance profile.name purchased items auth f
/**
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID
* @apiParam {UUID} heroId user ID
* @apiName GetHero
* @apiGroup Hall
* @apiPermission Admin
*
* @apiDescription Returns the profile of the given user
*
* @apiSuccess {Object} data The user object
*
* @apiPermission Admin
*
* @apiUse UserNotFound
* @apiUse NoAuthHeaders
* @apiUse NoAccount
* @apiUse NoUser
* @apiUse NotAdmin
*/
api.getHero = {
method: 'GET',
@@ -123,15 +187,35 @@ 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")
* @apiDescription Must be an admin to make this request.
* @apiParam {UUID} heroId user ID
* @apiName UpdateHero
* @apiGroup Hall
* @apiPermission Admin
*
* @apiDescription Update user's gem balance, contributions & contribution tier and admin status. Grant items, block / unblock user's account and revoke / unrevoke chat privileges.
*
* @apiExample Example Body:
* {
* "balance": 1000,
* "auth": {"blocked": false},
* "flags": {"chatRevoked": true},
* "purchased": {"ads": true},
* "contributor": {
* "admin": true,
* "contributions": "Improving API documentation",
* "level": 5,
* "text": "Scribe, Blacksmith"
* },
* "itemPath": "items.pets.BearCub-Skeleton",
* "itemVal": 1
* }
*
* @apiSuccess {Object} data The updated user object
*
* @apiPermission Admin
*
* @apiUse UserNotFound
* @apiUse NoAuthHeaders
* @apiUse NoAccount
* @apiUse NoUser
* @apiUse NotAdmin
*/
api.updateHero = {
method: 'PUT',
+40 -12
View File
@@ -4,6 +4,9 @@ import {
publicFields as memberFields,
nameFields,
} from '../../models/user';
import {
KNOWN_INTERACTIONS,
} from '../../models/user/methods';
import { model as Group } from '../../models/group';
import { model as Challenge } from '../../models/challenge';
import {
@@ -385,6 +388,39 @@ api.getChallengeMemberProgress = {
},
};
/**
* @api {get} /api/v3/members/:toUserId/objections/:interaction Get any objections that would occur if the given interaction was attempted - BETA
* @apiVersion 3.0.0
* @apiName GetObjectionsToInteraction
* @apiGroup Member
*
* @apiParam {UUID} toUserId The user to interact with
* @apiParam {String="send-private-message","transfer-gems"} interaction Name of the interaction to query
*
* @apiSuccess {Array} data Return an array of objections, if the interaction would be blocked; otherwise an empty array
*/
api.getObjectionsToInteraction = {
method: 'GET',
url: '/members/:toUserId/objections/:interaction',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
req.checkParams('interaction', res.t('interactionRequired')).notEmpty().isIn(KNOWN_INTERACTIONS);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let sender = res.locals.user;
let receiver = await User.findById(req.params.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userWithIDNotFound', {userId: req.params.toUserId}));
let interaction = req.params.interaction;
let response = sender.getObjectionsToInteraction(interaction, receiver);
res.respond(200, response.map(res.t));
},
};
/**
* @api {posts} /api/v3/members/send-private-message Send a private message to a member
* @apiName SendPrivateMessage
@@ -410,17 +446,11 @@ api.sendPrivateMessage = {
let sender = res.locals.user;
let message = req.body.message;
let receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
let userBlockedSender = receiver.inbox.blocks.indexOf(sender._id) !== -1;
let userIsBlockBySender = sender.inbox.blocks.indexOf(receiver._id) !== -1;
let userOptedOutOfMessaging = receiver.inbox.optOut;
if (userBlockedSender || userIsBlockBySender || userOptedOutOfMessaging) {
throw new NotAuthorized(res.t('notAuthorizedToSendMessageToThisUser'));
}
let objections = sender.getObjectionsToInteraction('send-private-message', receiver);
if (objections.length > 0) throw new NotAuthorized(res.t(objections[0]));
await sender.sendMessage(receiver, { receiverMsg: message });
@@ -472,13 +502,11 @@ api.transferGems = {
if (validationErrors) throw validationErrors;
let sender = res.locals.user;
let receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
if (receiver._id === sender._id) {
throw new NotAuthorized(res.t('cannotSendGemsToYourself'));
}
let objections = sender.getObjectionsToInteraction('transfer-gems', receiver);
if (objections.length > 0) throw new NotAuthorized(res.t(objections[0]));
let gemAmount = req.body.gemAmount;
let amount = gemAmount / 4;
+1 -1
View File
@@ -39,7 +39,7 @@ function canStartQuestAutomatically (group) {
let api = {};
/**
* @api {post} /api/v3/groups/:groupId/quests/invite Invite users to a quest
* @api {post} /api/v3/groups/:groupId/quests/invite/:questKey Invite users to a quest
* @apiName InviteToQuest
* @apiGroup Quest
*
+2 -2
View File
@@ -107,8 +107,8 @@ api.getSeasonalShopItems = {
let resObject = {
identifier: 'seasonalShop',
text: res.t('seasonalShop'),
notes: res.t('seasonalShopClosedText'),
imageName: 'seasonalshop_closed',
notes: res.t('seasonalShopSummerText'),
imageName: 'seasonalshop_open',
categories: shops.getSeasonalShopCategories(user, req.language),
};
+162 -15
View File
@@ -333,7 +333,7 @@ api.deleteUser = {
await user.remove();
if (feedback) {
txnEmail(TECH_ASSISTANCE_EMAIL, 'admin-feedback', [
txnEmail({email: TECH_ASSISTANCE_EMAIL}, 'admin-feedback', [
{name: 'PROFILE_NAME', content: user.profile.name},
{name: 'UUID', content: user._id},
{name: 'EMAIL', content: getUserInfo(user, ['email']).email},
@@ -1037,8 +1037,8 @@ api.buySpecialSpell = {
*
* @apiParam {String} egg The egg to use
* @apiParam {String} hatchingPotion The hatching potion to use
* @apiParamExample {URL}
* /api/v3/user/hatch/Dragon/CottonCandyPink
* @apiParamExample {URL} Example-URL
* https://habitica.com/api/v3/user/hatch/Dragon/CottonCandyPink
*
* @apiSuccess {Object} data user.items
* @apiSuccess {String} message
@@ -1081,8 +1081,8 @@ api.hatch = {
* @apiParam {String="mount","pet","costume","equipped"} type The type of item to equip
* @apiParam {String} key The item to equip
*
* @apiParamExample {URL}
* /api/v3/user/equip/equipped/weapon_warrior_2
* @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
@@ -1122,7 +1122,7 @@ api.equip = {
* @apiParam {String} pet
* @apiParam {String} food
*
* @apiParamExample {url}
* @apiParamExample {url} Example-URL
* https://habitica.com/api/v3/user/feed/Armadillo-Shade/Chocolate
*
* @apiSuccess {Number} data The pet value
@@ -1206,12 +1206,20 @@ api.disableClasses = {
* @apiName UserPurchase
* @apiGroup User
*
* @apiParam {String} type Type of item to purchase. Must be one of: gems, eggs, hatchingPotions, food, quests, or gear
* @apiParam {String="gems","eggs","hatchingPotions","premiumHatchingPotions",food","quests","gear"} type Type of item to purchase.
* @apiParam {String} key Item's key (use "gem" for purchasing gems)
*
* @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}
* {"success":false,"error":"NotAuthorized","message":"This item is not currently available for purchase."}
*/
api.purchase = {
method: 'POST',
@@ -1230,12 +1238,19 @@ api.purchase = {
* @apiName UserPurchaseHourglass
* @apiGroup User
*
* @apiParam {String} type The type of item to purchase (pets or mounts)
* @apiParam {String} key Ex: {MantisShrimp-Base}. The key for the mount/pet
* @apiParam {String="pets","mounts"} type The type of item to purchase
* @apiParam {String} key Ex: {Phoenix-Base}. The key for the mount/pet
*
* @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 {NotFound} Type Type invalid.
*
* @apiErrorExample {json}
* {"success":false,"error":"NotAuthorized","message":"You don't have enough Mystic Hourglasses."}
*/
api.userPurchaseHourglass = {
method: 'POST',
@@ -1254,11 +1269,40 @@ api.userPurchaseHourglass = {
* @apiName UserReadCard
* @apiGroup User
*
* @apiParam {String} cardType Type of card to read
* @apiParam {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}
* {
* "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',
@@ -1279,6 +1323,28 @@ api.readCard = {
*
* @apiSuccess {Object} data The item obtained
* @apiSuccess {String} message Success message
*
* @apiSuccessExample {json}
* { "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}
* {"success":false,"error":"BadRequest","message":"Mystery items are empty"}
*/
api.userOpenMysteryItem = {
method: 'POST',
@@ -1298,6 +1364,19 @@ api.userOpenMysteryItem = {
*
* @apiSuccess {Object} data.items `user.items.pets`
* @apiSuccess {String} message Success message
*
* @apiSuccessExample {json}
* {
* "success": true,
* "data": {
* },
* "message": "Pets released"
* }
*
* @apiError {NotAuthorized} Not enough gems
*
* @apiErrorExample {json}
* {"success":false,"error":"NotAuthorized","message":"Not enough Gems"}
*/
api.userReleasePets = {
method: 'POST',
@@ -1315,11 +1394,38 @@ api.userReleasePets = {
* @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}
* {
* "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} Not enough gems
*
* @apiErrorExample {json}
* {"success":false,"error":"NotAuthorized","message":"Not enough Gems"}
*/
api.userReleaseBoth = {
method: 'POST',
@@ -1340,6 +1446,22 @@ api.userReleaseBoth = {
*
* @apiSuccess {Object} data user.items.mounts
* @apiSuccess {String} message Success message
*
* @apiSuccessExample {json}
* {
* "success": true,
* "data": {
* },
* "items": {}
* },
* "message": "Mounts released"
* }
*
* @apiError {NotAuthorized} Not enough gems
*
* @apiErrorExample {json}
* {"success":false,"error":"NotAuthorized","message":"Not enough Gems"}
*
*/
api.userReleaseMounts = {
method: 'POST',
@@ -1358,12 +1480,17 @@ api.userReleaseMounts = {
* @apiName UserSell
* @apiGroup User
*
* @apiParam {String} type The type of item to sell. Must be one of: eggs, hatchingPotions, or food
* @apiParam {String="eggs","hatchingPotions","food"} type The type of item to sell.
* @apiParam {String} key The key of the item
*
* @apiSuccess {Object} data.stats
* @apiSuccess {Object} data.items
* @apiSuccess {String} message Success message
*
* @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}
* {"success":false,"error":"NotAuthorized","message":"Type is not sellable. Must be one of the following eggs, hatchingPotions, food"}
*/
api.userSell = {
method: 'POST',
@@ -1382,12 +1509,31 @@ api.userSell = {
* @apiName UserUnlock
* @apiGroup User
*
* @apiParam {String} path Query parameter. The path to unlock
* @apiParam {String} path Query parameter. Full path to unlock. See "content" API call for list of items.
*
* @apiParamExample {curl}
* curl -x POST http://habitica.com/api/v3/user/unlock?path=background.midnight_clouds
* curl -x POST http://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
* @apiSuccess {String} message "Items have been unlocked"
*
* @apiSuccessExample {json}
* {
* "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}
* {"success":false,"error":"BadRequest","message":"Path string is required"}
8 {"success":false,"error":"NotAuthorized","message":"Full set already unlocked."}
*/
api.userUnlock = {
method: 'POST',
@@ -1664,3 +1810,4 @@ api.setCustomDayStart = {
};
module.exports = api;
+3 -1
View File
@@ -161,11 +161,12 @@ api.checkout = async function checkout (options = {}) {
* @param options.user The user object who is canceling
* @param options.groupId The id of the group that is canceling
* @param options.headers The request headers
* @param options.cancellationReason A text string to control sending an email
*
* @return undefined
*/
api.cancelSubscription = async function cancelSubscription (options = {}) {
let {user, groupId, headers} = options;
let {user, groupId, headers, cancellationReason} = options;
let billingAgreementId;
let planId;
@@ -218,6 +219,7 @@ api.cancelSubscription = async function cancelSubscription (options = {}) {
nextBill: moment(lastBillingDate).add({ days: subscriptionLength }),
paymentMethod: this.constants.PAYMENT_METHOD,
headers,
cancellationReason,
});
};
+6 -3
View File
@@ -111,7 +111,7 @@ function performSleepTasks (user, tasksByType, now) {
let thatDay = moment(now).subtract({days: 1});
if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) {
// TODO also untick checklists if the Daily was due on previous missed days, if two or more days were missed at once -- https://github.com/HabitRPG/habitrpg/pull/7218#issuecomment-219256016
// TODO also untick checklists if the Daily was due on previous missed days, if two or more days were missed at once -- https://github.com/HabitRPG/habitica/pull/7218#issuecomment-219256016
if (daily.checklist) {
daily.checklist.forEach(box => box.completed = false);
}
@@ -206,8 +206,11 @@ export function cron (options = {}) {
let perfect = true;
// Reset Gold-to-Gems cap if it's the start of the month
if (user.purchased && user.purchased.plan && !moment(user.purchased.plan.dateUpdated).startOf('month').isSame(moment().startOf('month'))) {
let dateUpdatedFalse = !moment(user.purchased.plan.dateUpdated).startOf('month').isSame(moment().startOf('month')) || !user.purchased.plan.dateUpdated;
if (user.purchased && user.purchased.plan && dateUpdatedFalse) {
user.purchased.plan.gemsBought = 0;
if (!user.purchased.plan.dateUpdated) user.purchased.plan.dateUpdated = moment();
}
if (user.isSubscribed()) {
@@ -395,7 +398,7 @@ export function cron (options = {}) {
// preen user history so that it doesn't become a performance problem
// also for subscribed users but differently
// TODO also do while resting in the inn. Note that later we'll be allowing the value/color of tasks to change while sleeping (https://github.com/HabitRPG/habitrpg/issues/5232), so the code in performSleepTasks() might be best merged back into here for that. Perhaps wait until then to do preen history for sleeping users.
// TODO also do while resting in the inn. Note that later we'll be allowing the value/color of tasks to change while sleeping (https://github.com/HabitRPG/habitica/issues/5232), so the code in performSleepTasks() might be best merged back into here for that. Perhaps wait until then to do preen history for sleeping users.
preenUserHistory(user, tasksByType, user.preferences.timezoneOffset);
if (perfect && atLeastOneDailyDue) {
+64 -13
View File
@@ -20,6 +20,7 @@ import {
import slack from './slack';
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL');
const JOINED_GROUP_PLAN = 'joined group plan';
let api = {};
@@ -81,8 +82,22 @@ api.addSubscriptionToGroupUsers = async function addSubscriptionToGroupUsers (gr
* @return undefined
*/
api.addSubToGroupUser = async function addSubToGroupUser (member, group) {
// These EMAIL_TEMPLATE constants are used to pass strings into templates that are
// stored externally and so their values must not be changed.
const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GOOGLE = 'Google_subscription';
const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_IOS = 'iOS_subscription';
const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GROUP_PLAN = 'group_plan_free_subscription';
const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_LIFETIME_FREE = 'lifetime_free_subscription';
const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL = 'normal_subscription';
const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_UNKNOWN = 'unknown_type_of_subscription';
const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NONE = 'no_subscription';
// When changing customerIdsToIgnore or paymentMethodsToIgnore, the code blocks below for
// the `group-member-join` email template will probably need to be changed.
let customerIdsToIgnore = [this.constants.GROUP_PLAN_CUSTOMER_ID, this.constants.UNLIMITED_CUSTOMER_ID];
let paymentMethodsToIgnore = [this.constants.GOOGLE_PAYMENT_METHOD, this.constants.IOS_PAYMENT_METHOD];
let previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NONE;
let leader = await User.findById(group.leader).exec();
let data = {
user: {},
@@ -112,26 +127,52 @@ api.addSubToGroupUser = async function addSubToGroupUser (member, group) {
},
};
let memberPlan = member.purchased.plan;
if (member.isSubscribed()) {
let memberPlan = member.purchased.plan;
let customerHasCancelledGroupPlan = memberPlan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID && !member.hasNotCancelled();
let ignorePaymentPlan = paymentMethodsToIgnore.indexOf(memberPlan.paymentMethod) !== -1;
let ignoreCustomerId = customerIdsToIgnore.indexOf(memberPlan.customerId) !== -1;
if (ignorePaymentPlan) {
txnEmail(TECH_ASSISTANCE_EMAIL, 'admin-user-subscription-details', [
txnEmail({email: TECH_ASSISTANCE_EMAIL}, 'admin-user-subscription-details', [
{name: 'PROFILE_NAME', content: member.profile.name},
{name: 'UUID', content: member._id},
{name: 'EMAIL', content: getUserInfo(member, ['email']).email},
{name: 'PAYMENT_METHOD', content: memberPlan.paymentMethod},
{name: 'PURCHASED_PLAN', content: JSON.stringify(memberPlan)},
{name: 'ACTION_NEEDED', content: 'User has joined group plan. Tell them to cancel subscription then give them free sub.'},
{name: 'ACTION_NEEDED', content: 'User has joined group plan and has been told to cancel their subscription then email us. Ensure they do that then give them free sub.'},
// TODO User won't get email instructions if they've opted out of all emails. See if we can make this email an exception and if not, report here whether they've opted out.
]);
}
if ((ignorePaymentPlan || ignoreCustomerId) && !customerHasCancelledGroupPlan) return;
if ((ignorePaymentPlan || ignoreCustomerId) && !customerHasCancelledGroupPlan) {
// member has been added to group plan but their subscription will not be changed
// automatically so they need a special message in the email
if (memberPlan.paymentMethod === this.constants.GOOGLE_PAYMENT_METHOD) {
previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GOOGLE;
} else if (memberPlan.paymentMethod === this.constants.IOS_PAYMENT_METHOD) {
previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_IOS;
} else if (memberPlan.customerId === this.constants.UNLIMITED_CUSTOMER_ID) {
previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_LIFETIME_FREE;
} else if (memberPlan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) {
previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GROUP_PLAN;
} else {
// this triggers a generic message in the email template in case we forget
// to update this code for new special cases
previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_UNKNOWN;
}
txnEmail(member, 'group-member-join', [
{name: 'LEADER', content: leader.profile.name},
{name: 'GROUP_NAME', content: group.name},
{name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: previousSubscriptionType},
]);
return;
}
if (member.hasNotCancelled()) await member.cancelSubscription();
if (member.hasNotCancelled()) {
await member.cancelSubscription({cancellationReason: JOINED_GROUP_PLAN});
previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL;
}
let today = new Date();
plan = member.purchased.plan.toObject();
@@ -154,16 +195,20 @@ api.addSubToGroupUser = async function addSubToGroupUser (member, group) {
}).value();
}
// save unused hourglass and mystery items
plan.consecutive.trinkets = memberPlan.consecutive.trinkets;
plan.mysteryItems = memberPlan.mysteryItems;
member.purchased.plan = plan;
member.items.mounts['Jackalope-RoyalPurple'] = true;
data.user = member;
await this.createSubscription(data);
let leader = await User.findById(group.leader).exec();
txnEmail(data.user, 'group-member-joining', [
txnEmail(data.user, 'group-member-join', [
{name: 'LEADER', content: leader.profile.name},
{name: 'GROUP_NAME', content: group.name},
{name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: previousSubscriptionType},
]);
};
@@ -405,17 +450,18 @@ api.createSubscription = async function createSubscription (data) {
});
};
// Sets their subscription to be cancelled later
// Cancels a subscription or group plan, setting termination to happen later
api.cancelSubscription = async function cancelSubscription (data) {
let plan;
let group;
let cancelType = 'unsubscribe';
let groupId;
let emailType = 'cancel-subscription';
let emailType;
let emailMergeData = [];
let sendEmail = true;
// If we are buying a group subscription
if (data.groupId) {
// cancelling a group plan
let groupFields = basicGroupFields.concat(' purchased');
group = await Group.getGroup({user: data.user, groupId: data.groupId, populateLeader: false, groupFields});
@@ -434,21 +480,26 @@ api.cancelSubscription = async function cancelSubscription (data) {
await this.cancelGroupUsersSubscription(group);
} else {
// cancelling a user subscription
plan = data.user.purchased.plan;
emailType = 'cancel-subscription';
// When cancelling because the user joined a group plan, no cancel-subscription email is sent
// because the group-member-join email says the subscription is cancelled.
if (data.cancellationReason && data.cancellationReason === JOINED_GROUP_PLAN) sendEmail = false;
}
let customerId = plan.customerId;
let now = moment();
let defaultRemainingDays = 30;
if (plan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) {
defaultRemainingDays = 2;
sendEmail = false; // because group-member-cancel email has already been sent
}
let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days', true) : defaultRemainingDays;
if (plan.extraMonths < 0) plan.extraMonths = 0;
let extraDays = Math.ceil(30.5 * plan.extraMonths);
let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`;
let nowStr = `${now.format('MM')}/${now.format('DD')}/${now.format('YYYY')}`;
let nowStrFormat = 'MM/DD/YYYY';
plan.dateTerminated =
@@ -465,7 +516,7 @@ api.cancelSubscription = async function cancelSubscription (data) {
await data.user.save();
}
if (customerId !== this.constants.GROUP_PLAN_CUSTOMER_ID) txnEmail(data.user, emailType, emailMergeData);
if (sendEmail) txnEmail(data.user, emailType, emailMergeData);
if (group) {
cancelType = 'group-unsubscribe';
+20 -2
View File
@@ -176,8 +176,18 @@ api.subscribeSuccess = async function subscribeSuccess (options = {}) {
});
};
/**
* Cancel a PayPal Subscription
*
* @param options
* @param options.user The user object who is canceling
* @param options.groupId The id of the group that is canceling
* @param options.cancellationReason A text string to control sending an email
*
* @return undefined
*/
api.subscribeCancel = async function subscribeCancel (options = {}) {
let {groupId, user} = options;
let {groupId, user, cancellationReason} = options;
let customerId;
if (groupId) {
@@ -212,6 +222,7 @@ api.subscribeCancel = async function subscribeCancel (options = {}) {
groupId,
paymentMethod: this.constants.PAYMENT_METHOD,
nextBill: nextBillingDate,
cancellationReason,
});
};
@@ -220,7 +231,14 @@ api.ipn = async function ipnApi (options = {}) {
let {txn_type, recurring_payment_id} = options;
if (['recurring_payment_profile_cancel', 'subscr_cancel'].indexOf(txn_type) === -1) return;
let ipnAcceptableTypes = [
'recurring_payment_profile_cancel',
'recurring_payment_failed',
'recurring_payment_expired',
'subscr_cancel',
'subscr_failed'];
if (ipnAcceptableTypes.indexOf(txn_type) === -1) return;
// @TODO: Should this request billing date?
let user = await User.findOne({ 'purchased.plan.customerId': recurring_payment_id }).exec();
if (user) {
+3 -1
View File
@@ -198,11 +198,12 @@ api.editSubscription = async function editSubscription (options, stripeInc) {
* @param options
* @param options.user The user object who is purchasing
* @param options.groupId The id of the group purchasing a subscription
* @param options.cancellationReason A text string to control sending an email
*
* @return undefined
*/
api.cancelSubscription = async function cancelSubscription (options, stripeInc) {
let {groupId, user} = options;
let {groupId, user, cancellationReason} = options;
let customerId;
// @TODO: We need to mock this, but curently we don't have correct Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
@@ -252,6 +253,7 @@ api.cancelSubscription = async function cancelSubscription (options, stripeInc)
groupId,
nextBill,
paymentMethod: this.constants.PAYMENT_METHOD,
cancellationReason,
});
};
+1 -1
View File
@@ -81,7 +81,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) {
// Add challenge to user.challenges
if (!_.includes(user.challenges, challenge._id)) {
// using concat because mongoose's protection against concurrent array modification isn't working as expected.
// see https://github.com/HabitRPG/habitrpg/pull/7787#issuecomment-232972394
// see https://github.com/HabitRPG/habitica/pull/7787#issuecomment-232972394
user.challenges = user.challenges.concat([challenge._id]);
}
// Sync tags
+2 -2
View File
@@ -288,7 +288,7 @@ schema.statics.getGroups = async function getGroups (options = {}) {
};
// When converting to json remove chat messages with more than 1 flag and remove all flags info
// unless the user is an admin
// unless the user is an admin or said chat is posted by that user
// Not putting into toJSON because there we can't access user
// It also removes the _meta field that can be stored inside a chat message
schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
@@ -298,7 +298,7 @@ schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
_.remove(toJSON.chat, chatMsg => {
chatMsg.flags = {};
if (chatMsg._meta) chatMsg._meta = undefined;
return chatMsg.flagCount >= 2;
return user._id !== chatMsg.uuid && chatMsg.flagCount >= 2;
});
}
+1
View File
@@ -229,6 +229,7 @@ export let DailySchema = new Schema(_.defaults({
default () {
return moment().startOf('day').toDate();
},
required: true,
},
repeat: { // used only for 'weekly' frequency,
m: {type: Boolean, default: true},
+69 -6
View File
@@ -5,7 +5,7 @@ import {
chatDefaults,
TAVERN_ID,
} from '../group';
import { defaults } from 'lodash';
import { defaults, map, flatten, flow, compact, uniq, partialRight } from 'lodash';
import { model as UserNotification } from '../userNotification';
import schema from './schema';
import payments from '../../libs/payments';
@@ -36,6 +36,56 @@ schema.methods.getGroups = function getUserGroups () {
return userGroups;
};
/* eslint-disable no-unused-vars */ // The checks below all get access to sndr and rcvr, but not all use both
const INTERACTION_CHECKS = Object.freeze({
always: [
// Revoked chat privileges block all interactions to prevent the evading of harassment protections
// See issue #7971 for some discussion
(sndr, rcvr) => sndr.flags.chatRevoked && 'chatPrivilegesRevoked',
// Direct user blocks prevent all interactions
(sndr, rcvr) => rcvr.inbox.blocks.includes(sndr._id) && 'notAuthorizedToSendMessageToThisUser',
(sndr, rcvr) => sndr.inbox.blocks.includes(rcvr._id) && 'notAuthorizedToSendMessageToThisUser',
],
'send-private-message': [
// Private messaging has an opt-out, which does not affect other interactions
(sndr, rcvr) => rcvr.inbox.optOut && 'notAuthorizedToSendMessageToThisUser',
// We allow a player to message themselves so they can test how PMs work or send their own notes to themselves
],
'transfer-gems': [
// Unlike private messages, gems can't be sent to oneself
(sndr, rcvr) => rcvr._id === sndr._id && 'cannotSendGemsToYourself',
],
});
/* eslint-enable no-unused-vars */
export const KNOWN_INTERACTIONS = Object.freeze(Object.keys(INTERACTION_CHECKS).filter(key => key !== 'always'));
// Get an array of error message keys that would be thrown if the given interaction was attempted
schema.methods.getObjectionsToInteraction = function getObjectionsToInteraction (interaction, receiver) {
if (!KNOWN_INTERACTIONS.includes(interaction)) {
throw new Error(`Unknown kind of interaction: "${interaction}", expected one of ${KNOWN_INTERACTIONS.join(', ')}`);
}
let sender = this;
let checks = [
INTERACTION_CHECKS.always,
INTERACTION_CHECKS[interaction],
];
let executeChecks = partialRight(map, (check) => check(sender, receiver));
return flow(
flatten,
executeChecks,
compact, // Remove passed checks (passed checks return falsy; failed checks return message keys)
uniq
)(checks);
};
/**
* Sends a message to a this. Archives a copy in sender's inbox.
@@ -108,23 +158,36 @@ schema.methods.addComputedStatsToJSONObj = function addComputedStatsToUserJSONOb
return statsObject;
};
/**
* Cancels a subscription.
*
* @param options
* @param options.user The user object who is purchasing
* @param options.groupId The id of the group purchasing a subscription
* @param options.headers The request headers (only for Amazon subscriptions)
* @param options.cancellationReason A text string to control sending an email
*
* @return a Promise from api.cancelSubscription()
*/
// @TODO: There is currently a three way relation between the user, payment methods and the payment helper
// This creates some odd Dependency Injection issues. To counter that, we use the user as the third layer
// To negotiate between the payment providers and the payment helper (which probably has too many responsiblities)
// In summary, currently is is best practice to use this method to cancel a user subscription, rather than calling the
// payment helper.
schema.methods.cancelSubscription = async function cancelSubscription () {
schema.methods.cancelSubscription = async function cancelSubscription (options = {}) {
let plan = this.purchased.plan;
options.user = this;
if (plan.paymentMethod === amazonPayments.constants.PAYMENT_METHOD) {
return await amazonPayments.cancelSubscription({user: this});
return await amazonPayments.cancelSubscription(options);
} else if (plan.paymentMethod === stripePayments.constants.PAYMENT_METHOD) {
return await stripePayments.cancelSubscription({user: this});
return await stripePayments.cancelSubscription(options);
} else if (plan.paymentMethod === paypalPayments.constants.PAYMENT_METHOD) {
return await paypalPayments.subscribeCancel({user: this});
return await paypalPayments.subscribeCancel(options);
}
// Android and iOS subscriptions cannot be cancelled by Habitica.
return await payments.cancelSubscription({user: this});
return await payments.cancelSubscription(options);
};
schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) {
+9 -2
View File
@@ -111,8 +111,11 @@ let schema = new Schema({
birthday: Number,
partyUp: Boolean,
partyOn: Boolean,
congrats: Number,
getwell: Number,
royallyLoyal: Boolean,
joinedGuild: Boolean,
joinedChallenge: Boolean,
},
backer: {
@@ -122,7 +125,7 @@ let schema = new Schema({
},
contributor: {
// 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801
// 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitica/issues/3801
level: {
type: Number,
min: 0,
@@ -288,6 +291,10 @@ let schema = new Schema({
thankyouReceived: Array,
birthday: {type: Number, default: 0},
birthdayReceived: Array,
congrats: {type: Number, default: 0},
congratsReceived: Array,
getwell: {type: Number, default: 0},
getwellReceived: Array,
},
// -------------- Animals -------------------
@@ -410,7 +417,7 @@ let schema = new Schema({
skin: {type: String, default: '915533'},
shirt: {type: String, default: 'blue'},
timezoneOffset: {type: Number, default: 0},
sound: {type: String, default: 'rosstavoTheme', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme', 'rosstavoTheme', 'dewinTheme', 'airuTheme']},
sound: {type: String, default: 'rosstavoTheme', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme', 'rosstavoTheme', 'dewinTheme', 'airuTheme', 'beatscribeNesTheme', 'arashiTheme']},
chair: {type: String, default: 'none'},
timezoneOffsetAtLastCron: Number,
language: String,
@@ -20,6 +20,7 @@ const NOTIFICATION_TYPES = [
'BOSS_DAMAGE', // Not used currently but kept to avoid validation errors
'GUILD_PROMPT',
'GUILD_JOINED_ACHIEVEMENT',
'CHALLENGE_JOINED_ACHIEVEMENT',
];
const Schema = mongoose.Schema;