Merge branch 'develop' into phillip/chat-skill-merge

This commit is contained in:
SabreCat
2023-07-10 15:12:12 -05:00
648 changed files with 24087 additions and 9026 deletions

View File

@@ -282,6 +282,8 @@ api.postChat = {
analyticsObject.groupName = group.name;
}
res.analytics.track('group chat', analyticsObject);
if (chatUpdated) {
res.respond(200, { chat: chatRes.chat });
} else {

View File

@@ -1,5 +1,6 @@
import _ from 'lodash';
import nconf from 'nconf';
import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth';
import {
model as Group,
@@ -165,6 +166,7 @@ api.createGroup = {
hitType: 'event',
category: 'behavior',
owner: true,
groupId: savedGroup._id,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
@@ -214,6 +216,7 @@ api.createGroupPlan = {
hitType: 'event',
category: 'behavior',
owner: true,
groupId: savedGroup._id,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
@@ -717,6 +720,25 @@ api.joinGroup = {
}
}
const analyticsObject = {
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: false,
groupId: group._id,
groupType: group.type,
privacy: group.privacy,
headers: req.headers,
invited: isUserInvited,
};
if (group.type === 'party') {
analyticsObject.seekingParty = Boolean(user.party.seeking);
}
if (group.privacy === 'public') {
analyticsObject.groupName = group.name;
}
user.party.seeking = undefined;
if (inviter) promises.push(inviter.save());
promises = await Promise.all(promises);
@@ -731,21 +753,6 @@ api.joinGroup = {
response.leader = leader.toJSON({ minimize: true });
}
const analyticsObject = {
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: false,
groupType: group.type,
privacy: group.privacy,
headers: req.headers,
invited: isUserInvited,
};
if (group.privacy === 'public') {
analyticsObject.groupName = group.name;
}
res.analytics.track('join group', analyticsObject);
res.respond(200, response);
@@ -1201,16 +1208,6 @@ api.inviteToGroup = {
results.push(...usernameResults);
}
const analyticsObject = {
uuid: user._id,
hitType: 'event',
category: 'behavior',
groupType: group.type,
headers: req.headers,
};
res.analytics.track('group invite', analyticsObject);
res.respond(200, results);
},
};
@@ -1358,4 +1355,88 @@ api.getGroupPlans = {
},
};
/**
* @api {get} /api/v3/looking-for-party Get users in search of parties
* @apiName GetLookingForParty
* @apiGroup Group
*
* @apiParam (Query) {Number} [page] Page number, defaults to 0
*
* @apiSuccess {Object[]} data An array of users looking for a party
*
* @apiError (400) {BadRequest} notPartyLeader You are not the leader of a Party.
*/
api.getLookingForParty = {
method: 'GET',
url: '/looking-for-party',
middlewares: [authWithHeaders()],
async handler (req, res) {
const USERS_PER_PAGE = 30;
const { user } = res.locals;
req.checkQuery('page').optional().isInt({ min: 0 }, apiError('queryPageInteger'));
const PAGE = req.query.page || 0;
const PAGE_START = USERS_PER_PAGE * PAGE;
const partyLed = await Group
.findOne({
type: 'party',
leader: user._id,
})
.select('_id')
.exec();
if (!partyLed) {
throw new BadRequest(apiError('notPartyLeader'));
}
const seekers = await User
.find({
'party.seeking': { $exists: true },
'auth.timestamps.loggedin': {
$gt: moment().subtract(7, 'days').toDate(),
},
})
// eslint-disable-next-line no-multi-str
.select('_id auth.blocked auth.local.username auth.timestamps backer contributor.level \
flags.chatRevoked flags.classSelected inbox.blocks invitations.party items.gear.costume \
items.gear.equipped loginIncentives party._id preferences.background preferences.chair \
preferences.costume preferences.hair preferences.shirt preferences.size preferences.skin \
preferences.language profile.name stats.buffs stats.class stats.lvl')
.sort('-auth.timestamps.loggedin')
.exec();
const filteredSeekers = seekers.filter(seeker => {
if (seeker.party._id) return false;
if (seeker.invitations.party.id) return false;
if (seeker.flags.chatRevoked) return false;
if (seeker.auth.blocked) return false;
if (seeker.inbox.blocks.indexOf(user._id) !== -1) return false;
if (user.inbox.blocks.indexOf(seeker._id) !== -1) return false;
return true;
}).slice(PAGE_START, PAGE_START + USERS_PER_PAGE);
const cleanedSeekers = filteredSeekers.map(seeker => ({
_id: seeker._id,
auth: {
local: {
username: seeker.auth.local.username,
},
timestamps: seeker.auth.timestamps,
},
backer: seeker.backer,
contributor: seeker.contributor,
flags: seeker.flags,
invited: false,
items: seeker.items,
loginIncentives: seeker.loginIncentives,
preferences: seeker.preferences,
profile: seeker.profile,
stats: seeker.stats,
}));
res.respond(200, cleanedSeekers);
},
};
export default api;

View File

@@ -277,6 +277,9 @@ api.updateHero = {
if (updateData.purchased.plan.gemsBought) {
hero.purchased.plan.gemsBought = updateData.purchased.plan.gemsBought;
}
if (updateData.purchased.plan.perkMonthCount) {
hero.purchased.plan.perkMonthCount = updateData.purchased.plan.perkMonthCount;
}
if (updateData.purchased.plan.consecutive) {
if (updateData.purchased.plan.consecutive.trinkets) {
const changedHourglassTrinkets = updateData.purchased.plan.consecutive.trinkets

View File

@@ -93,7 +93,7 @@ api.inviteToQuest = {
user.party.quest.RSVPNeeded = false;
user.party.quest.key = questKey;
await User.update({
await User.updateMany({
'party._id': group._id,
_id: { $ne: user._id },
}, {
@@ -101,7 +101,7 @@ api.inviteToQuest = {
'party.quest.RSVPNeeded': true,
'party.quest.key': questKey,
},
}, { multi: true }).exec();
}).exec();
_.each(members, member => {
group.quest.members[member._id] = null;
@@ -409,10 +409,9 @@ api.cancelQuest = {
const [savedGroup] = await Promise.all([
group.save(),
newChatMessage.save(),
User.update(
User.updateMany(
{ 'party._id': groupId },
Group.cleanQuestParty(),
{ multi: true },
).exec(),
]);
@@ -467,12 +466,11 @@ api.abortQuest = {
});
await newChatMessage.save();
const memberUpdates = User.update({
const memberUpdates = User.updateMany({
'party._id': groupId,
}, Group.cleanQuestParty(),
{ multi: true }).exec();
}, Group.cleanQuestParty()).exec();
const questLeaderUpdate = User.update({
const questLeaderUpdate = User.updateOne({
_id: group.quest.leader,
}, {
$inc: {

View File

@@ -227,7 +227,7 @@ api.deleteTag = {
const tagFound = find(user.tags, tag => tag.id === req.params.tagId);
if (!tagFound) throw new NotFound(res.t('tagNotFound'));
await user.update({
await user.updateOne({
$pull: { tags: { id: tagFound.id } },
}).exec();
@@ -237,13 +237,13 @@ api.deleteTag = {
user._v += 1;
// Remove from all the tasks TODO test
await Tasks.Task.update({
await Tasks.Task.updateMany({
userId: user._id,
}, {
$pull: {
tags: tagFound.id,
},
}, { multi: true }).exec();
}).exec();
res.respond(200, {});
},

View File

@@ -840,7 +840,7 @@ api.moveTask = {
// Cannot send $pull and $push on same field in one single op
const pullQuery = { $pull: {} };
pullQuery.$pull[`tasksOrder.${task.type}s`] = task.id;
await owner.update(pullQuery).exec();
await owner.updateOne(pullQuery).exec();
let position = to;
if (to === -1) position = order.length - 1; // push to bottom
@@ -850,7 +850,7 @@ api.moveTask = {
$each: [task._id],
$position: position,
};
await owner.update(updateQuery).exec();
await owner.updateOne(updateQuery).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
@@ -1434,7 +1434,7 @@ api.deleteTask = {
const pullQuery = { $pull: {} };
pullQuery.$pull[`tasksOrder.${task.type}s`] = task._id;
const taskOrderUpdate = (challenge || user).update(pullQuery).exec();
const taskOrderUpdate = (challenge || user).updateOne(pullQuery).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook

View File

@@ -68,6 +68,7 @@ api.createGroupTasks = {
category: 'behavior',
taskType: task.type,
groupID: group._id,
headers: req.headers,
});
});
},
@@ -255,6 +256,7 @@ api.assignTask = {
category: 'behavior',
taskType: task.type,
groupID: group._id,
headers: req.headers,
});
},
};

View File

@@ -23,6 +23,7 @@ import {
import * as inboxLib from '../../libs/inbox';
import * as userLib from '../../libs/user';
const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android'];
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
const DELETE_CONFIRMATION = 'DELETE';
@@ -494,6 +495,9 @@ api.buy = {
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();
@@ -584,6 +588,9 @@ api.buyArmoire = {
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();
res.respond(200, ...buyArmoireResponse);
@@ -975,7 +982,7 @@ api.disableClasses = {
* @apiGroup User
*
* @apiParam (Path) {String="gems","eggs","hatchingPotions","premiumHatchingPotions"
,"food","quests","gear"} type Type of item to purchase.
,"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.

View File

@@ -75,12 +75,14 @@ api.checkout = {
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
const { orderReferenceId, gift, gemsBlock } = req.body;
const {
orderReferenceId, gift, gemsBlock, sku,
} = req.body;
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
await amzLib.checkout({
gemsBlock, gift, user, orderReferenceId, headers: req.headers,
gemsBlock, gift, sku, user, orderReferenceId, headers: req.headers,
});
res.respond(200);

View File

@@ -23,7 +23,7 @@ api.iapAndroidVerify = {
middlewares: [authWithHeaders()],
async handler (req, res) {
if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt'));
const googleRes = await googlePayments.verifyGemPurchase({
const googleRes = await googlePayments.verifyPurchase({
user: res.locals.user,
receipt: req.body.transaction.receipt,
signature: req.body.transaction.signature,
@@ -120,7 +120,7 @@ api.iapiOSVerify = {
middlewares: [authWithHeaders()],
async handler (req, res) {
if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt'));
const appleRes = await applePayments.verifyGemPurchase({
const appleRes = await applePayments.verifyPurchase({
user: res.locals.user,
receipt: req.body.transaction.receipt,
gift: req.body.gift,
@@ -144,7 +144,7 @@ api.iapSubscriptioniOS = {
if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode'));
if (!req.body.receipt) throw new BadRequest(res.t('missingReceipt'));
await applePayments.subscribe(req.body.sku, res.locals.user, req.body.receipt, req.headers);
await applePayments.subscribe(res.locals.user, req.body.receipt, req.headers);
res.respond(200);
},

View File

@@ -27,10 +27,13 @@ api.checkout = {
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
req.session.gift = req.query.gift;
const { gemsBlock } = req.query;
const { gemsBlock, sku } = req.query;
req.session.gemsBlock = gemsBlock;
req.session.sku = sku;
const link = await paypalPayments.checkout({ gift, gemsBlock, user: res.locals.user });
const link = await paypalPayments.checkout({
gift, gemsBlock, sku, user: res.locals.user,
});
if (req.query.noRedirect) {
res.respond(200);
@@ -56,14 +59,15 @@ api.checkoutSuccess = {
const { user } = res.locals;
const gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
delete req.session.gift;
const { gemsBlock } = req.session;
const { gemsBlock, sku } = req.session;
delete req.session.gemsBlock;
delete req.session.sku;
if (!paymentId) throw new BadRequest(apiError('missingPaymentId'));
if (!customerId) throw new BadRequest(apiError('missingCustomerId'));
await paypalPayments.checkoutSuccess({
user, gemsBlock, gift, paymentId, customerId, headers: req.headers,
user, gemsBlock, gift, paymentId, customerId, headers: req.headers, sku,
});
if (req.query.noRedirect) {

View File

@@ -27,13 +27,13 @@ api.createCheckoutSession = {
async handler (req, res) {
const { user } = res.locals;
const {
gift, sub: subKey, gemsBlock, coupon, groupId,
gift, sub: subKey, gemsBlock, coupon, groupId, sku,
} = req.body;
const sub = subKey ? shared.content.subscriptionBlocks[subKey] : false;
const session = await stripePayments.createCheckoutSession({
user, gemsBlock, gift, sub, groupId, coupon,
user, gemsBlock, gift, sub, groupId, coupon, sku,
});
res.respond(200, {

View File

@@ -37,7 +37,15 @@ export default function baseModel (schema, options = {}) {
});
schema.pre('update', function preUpdateModel () {
this.update({}, { $set: { updatedAt: new Date() } });
this.set({}, { $set: { updatedAt: new Date() } });
});
schema.pre('updateOne', function preUpdateModel () {
this.set({}, { $set: { updatedAt: new Date() } });
});
schema.pre('updateMany', function preUpdateModel () {
this.set({}, { $set: { updatedAt: new Date() } });
});
}

View File

@@ -63,9 +63,6 @@ const CLEAR_BUFFS = {
};
async function grantEndOfTheMonthPerks (user, now) {
// multi-month subscriptions are for multiples of 3 months
const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3;
const { plan, elapsedMonths } = getPlanContext(user, now);
if (elapsedMonths > 0) {
@@ -106,32 +103,17 @@ async function grantEndOfTheMonthPerks (user, now) {
planMonthsLength = getPlanMonths(plan);
}
// every 3 months you get one set of perks - this variable records how many sets you need
let perkAmountNeeded = 0;
if (planMonthsLength === 1) {
// User has a single-month recurring subscription and are due for perks
// IF they've been subscribed for a multiple of 3 months.
if (plan.consecutive.count % SUBSCRIPTION_BASIC_BLOCK_LENGTH === 0) { // every 3 months
perkAmountNeeded = 1;
}
plan.consecutive.offset = 0; // allow the same logic to be run next month
} else {
// User has a multi-month recurring subscription
// and it renewed in the previous calendar month.
// e.g., for a 6-month subscription, give two sets of perks
perkAmountNeeded = planMonthsLength / SUBSCRIPTION_BASIC_BLOCK_LENGTH;
// don't need to check for perks again for this many months
// (subtract 1 because we should have run this when the payment was taken last month)
plan.consecutive.offset = planMonthsLength - 1;
}
if (perkAmountNeeded > 0) {
// one Hourglass every 3 months
await plan.updateHourglasses(user._id, perkAmountNeeded, 'subscription_perks'); // eslint-disable-line no-await-in-loop
plan.consecutive.gemCapExtra += 5 * perkAmountNeeded; // 5 extra Gems every 3 months
// cap it at 50 (hard 25 limit + extra 25)
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25;
}
// eslint-disable-next-line no-await-in-loop
await plan.incrementPerkCounterAndReward(user._id, planMonthsLength);
}
}
}
@@ -297,6 +279,8 @@ export async function cron (options = {}) {
if (user.isSubscribed()) {
await grantEndOfTheMonthPerks(user, now);
} if (!user.isSubscribed() && user.purchased.plan.perkMonthCount > 0) {
user.purchased.plan.perkMonthCount = 0;
}
const { plan } = user.purchased;

View File

@@ -21,6 +21,8 @@ export default {
setup: util.promisify(iap.setup.bind(iap)),
validate: util.promisify(iap.validate.bind(iap)),
isValidated: iap.isValidated,
isCanceled: iap.isCanceled,
isExpired: iap.isExpired,
getPurchaseData: iap.getPurchaseData,
GOOGLE: iap.GOOGLE,
APPLE: iap.APPLE,

View File

@@ -87,10 +87,9 @@ async function inviteUserToParty (userToInvite, group, inviter, res) {
}
if (userToInvite.party._id) {
const userParty = await Group.getGroup({ user: userToInvite, groupId: 'party', fields: 'memberCount' });
const userParty = await Group.getGroup({ user: userToInvite, groupId: 'party', fields: '_id' });
// Allow user to be invited to a new party when they're partying solo
if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name }));
if (userParty) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name }));
}
const partyInvite = { id: group._id, name: group.name, inviter: inviter._id };
@@ -142,6 +141,22 @@ async function inviteByUUID (uuid, group, inviter, req, res) {
));
}
const analyticsObject = {
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: uuid,
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
if (group.type === 'party') {
analyticsObject.seekingParty = Boolean(userToInvite.party.seeking);
}
res.analytics.track('group invite', analyticsObject);
return addInvitationToUser(userToInvite, group, inviter, res);
}
@@ -189,6 +204,18 @@ async function inviteByEmail (invite, group, inviter, req, res) {
const userIsUnsubscribed = await EmailUnsubscription.findOne({ email: invite.email }).exec();
const groupLabel = group.type === 'guild' ? '-guild' : '';
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
const analyticsObject = {
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: 'email',
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
res.analytics.track('group invite', analyticsObject);
}
return userReturnInfo;
@@ -214,6 +241,23 @@ async function inviteByUserName (username, group, inviter, req, res) {
{ userId: userToInvite._id, username: userToInvite.profile.name },
));
}
const analyticsObject = {
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: userToInvite._id,
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
if (group.type === 'party') {
analyticsObject.seekingParty = Boolean(userToInvite.party.seeking);
}
res.analytics.track('group invite', analyticsObject);
return addInvitationToUser(userToInvite, group, inviter, res);
}

View File

@@ -46,6 +46,7 @@ api.constants = {
GIFT_TYPE_SUBSCRIPTION: 'subscription',
METHOD_BUY_GEMS: 'buyGems',
METHOD_BUY_SKU_ITEM: 'buySkuItem',
METHOD_CREATE_SUBSCRIPTION: 'createSubscription',
PAYMENT_METHOD: 'Amazon Payments',
PAYMENT_METHOD_GIFT: 'Amazon Payments (Gift)',
@@ -110,7 +111,7 @@ api.authorize = function authorize (inputSet) {
*/
api.checkout = async function checkout (options = {}) {
const {
gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey,
gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey, sku,
} = options;
let amount;
let gemsBlock;
@@ -127,6 +128,12 @@ api.checkout = async function checkout (options = {}) {
} else if (gift.type === this.constants.GIFT_TYPE_SUBSCRIPTION) {
amount = common.content.subscriptionBlocks[gift.subscription.key].price;
}
} else if (sku) {
if (sku === 'Pet-Gryphatrice-Jubilant') {
amount = 9.99;
} else {
throw new NotFound('SKU not found.');
}
} else {
gemsBlock = getGemsBlock(gemsBlockKey);
amount = gemsBlock.price / 100;
@@ -171,12 +178,16 @@ api.checkout = async function checkout (options = {}) {
// execute payment
let method = this.constants.METHOD_BUY_GEMS;
if (sku) {
method = this.constants.METHOD_BUY_SKU_ITEM;
}
const data = {
user,
paymentMethod: this.constants.PAYMENT_METHOD,
headers,
gemsBlock,
sku,
};
if (gift) {

View File

@@ -2,7 +2,7 @@ import moment from 'moment';
import shared from '../../../common';
import iap from '../inAppPurchases';
import payments from './payments';
import { getGemsBlock, validateGiftMessage } from './gems';
import { validateGiftMessage } from './gems';
import {
NotAuthorized,
BadRequest,
@@ -22,7 +22,7 @@ api.constants = {
RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED',
};
api.verifyGemPurchase = async function verifyGemPurchase (options) {
api.verifyPurchase = async function verifyPurchase (options) {
const {
gift, user, receipt, headers,
} = options;
@@ -44,7 +44,6 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
let correctReceipt = false;
// Purchasing one item at a time (processing of await(s) below is sequential not parallel)
for (const purchaseData of purchaseDataList) {
@@ -62,58 +61,45 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
userId: user._id,
});
let gemsBlockKey;
switch (purchaseData.productId) { // eslint-disable-line default-case
case 'com.habitrpg.ios.Habitica.4gems':
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.ios.Habitica.42gems':
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.ios.Habitica.84gems':
gemsBlockKey = '84gems';
break;
}
if (!gemsBlockKey) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM);
const gemsBlock = getGemsBlock(gemsBlockKey);
if (gift) {
gift.type = 'gems';
if (!gift.gems) gift.gems = {};
gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
if (gemsBlock) {
correctReceipt = true;
await payments.buyGems({ // eslint-disable-line no-await-in-loop
user,
gift,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
gemsBlock,
headers,
});
}
await payments.buySkuItem({ // eslint-disable-line no-await-in-loop
user,
gift,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
sku: purchaseData.productId,
headers,
});
}
}
if (!correctReceipt) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM);
return appleRes;
};
api.subscribe = async function subscribe (sku, user, receipt, headers, nextPaymentProcessing) {
if (user && user.isSubscribed()) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
await iap.setup();
const appleRes = await iap.validate(iap.APPLE, receipt);
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
const purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
let purchase;
let newestDate;
for (const purchaseData of purchaseDataList) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
const dateTerminated = new Date(Number(purchaseData.expirationDate));
if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) {
purchase = purchaseData;
newestDate = datePurchased;
}
}
let subCode;
switch (sku) { // eslint-disable-line default-case
switch (purchase.productId) { // eslint-disable-line default-case
case 'subscription1month':
subCode = 'basic_earned';
break;
@@ -128,45 +114,56 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme
break;
}
const sub = subCode ? shared.content.subscriptionBlocks[subCode] : false;
if (!sub) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
await iap.setup();
const appleRes = await iap.validate(iap.APPLE, receipt);
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
const purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
let transactionId;
for (const purchaseData of purchaseDataList) {
const dateTerminated = new Date(Number(purchaseData.expirationDate));
if (purchaseData.productId === sku && dateTerminated > new Date()) {
transactionId = purchaseData.transactionId;
break;
if (purchase.originalTransactionId) {
let existingSub;
if (user && user.isSubscribed()) {
if (user.purchased.plan.customerId !== purchase.originalTransactionId) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
existingSub = shared.content.subscriptionBlocks[user.purchased.plan.planId];
if (existingSub === sub) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
}
}
if (transactionId) {
const existingUser = await User.findOne({
'purchased.plan.customerId': transactionId,
const existingUsers = await User.find({
$or: [
{ 'purchased.plan.customerId': purchase.originalTransactionId },
{ 'purchased.plan.customerId': purchase.transactionId },
],
}).exec();
if (existingUser) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
if (existingUsers.length > 0) {
if (purchase.originalTransactionId === purchase.transactionId) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
for (const existingUser of existingUsers) {
if (existingUser._id !== user._id && !existingUser.purchased.plan.dateTerminated) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
}
}
nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign
const terminationDate = moment(Number(purchase.expirationDate));
if (nextPaymentProcessing > terminationDate) {
// For test subscriptions that have a significantly shorter expiration period, this is better
nextPaymentProcessing = terminationDate; // eslint-disable-line no-param-reassign
}
await payments.createSubscription({
const data = {
user,
customerId: transactionId,
customerId: purchase.originalTransactionId,
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
sub,
headers,
nextPaymentProcessing,
additionalData: receipt,
});
};
if (existingSub) {
data.updatedFrom = existingSub;
data.updatedFrom.logic = 'refundAndRepay';
}
await payments.createSubscription(data);
} else {
throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
}
@@ -258,8 +255,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
await iap.setup();
let dateTerminated;
try {
const appleRes = await iap.validate(iap.APPLE, plan.additionalData);
@@ -268,10 +263,27 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
const purchases = iap.getPurchaseData(appleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const subscriptionData = purchases[0];
let newestDate;
let newestPurchase;
dateTerminated = new Date(Number(subscriptionData.expirationDate));
if (dateTerminated > new Date()) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
for (const purchaseData of purchases) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
if (!newestDate || datePurchased > newestDate) {
newestDate = datePurchased;
newestPurchase = purchaseData;
}
}
if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) {
throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
}
await payments.cancelSubscription({
user,
nextBill: new Date(Number(newestPurchase.expirationDate)),
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
});
} catch (err) {
// If we have an invalid receipt, cancel anyway
if (
@@ -281,13 +293,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
throw err;
}
}
await payments.cancelSubscription({
user,
nextBill: dateTerminated,
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
});
};
export default api;

View File

@@ -8,7 +8,7 @@ import {
} from '../errors';
import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt';
import { model as User } from '../../models/user';
import { getGemsBlock, validateGiftMessage } from './gems';
import { validateGiftMessage } from './gems';
const api = {};
@@ -21,7 +21,7 @@ api.constants = {
RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID',
};
api.verifyGemPurchase = async function verifyGemPurchase (options) {
api.verifyPurchase = async function verifyPurchase (options) {
const {
gift, user, receipt, signature, headers,
} = options;
@@ -61,39 +61,11 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
userId: user._id,
});
let gemsBlockKey;
switch (receiptObj.productId) { // eslint-disable-line default-case
case 'com.habitrpg.android.habitica.iap.4gems':
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.android.habitica.iap.20gems':
case 'com.habitrpg.android.habitica.iap.21gems':
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.android.habitica.iap.42gems':
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.android.habitica.iap.84gems':
gemsBlockKey = '84gems';
break;
}
if (!gemsBlockKey) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
const gemsBlock = getGemsBlock(gemsBlockKey);
if (gift) {
gift.type = 'gems';
if (!gift.gems) gift.gems = {};
gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
await payments.buyGems({
await payments.buySkuItem({ // eslint-disable-line no-await-in-loop
user,
gift,
paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE,
gemsBlock,
paymentMethod: api.constants.PAYMENT_METHOD_GOOGLE,
sku: googleRes.productId,
headers,
});

View File

@@ -180,6 +180,7 @@ async function addSubToGroupUser (member, group) {
}
// save unused hourglass and mystery items
plan.perkMonthCount = memberPlan.perkMonthCount;
plan.consecutive.trinkets = memberPlan.consecutive.trinkets;
plan.mysteryItems = memberPlan.mysteryItems;

View File

@@ -11,6 +11,9 @@ import { // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
buyGems,
} from './gems';
import { // eslint-disable-line import/no-cycle
buySkuItem,
} from './skuItem';
import { paymentConstants } from './constants';
const api = {};
@@ -31,4 +34,6 @@ api.cancelSubscription = cancelSubscription;
api.buyGems = buyGems;
api.buySkuItem = buySkuItem;
export default api;

View File

@@ -77,7 +77,9 @@ api.paypalBillingAgreementCancel = util
api.ipnVerifyAsync = util.promisify(paypalIpn.verify.bind(paypalIpn));
api.checkout = async function checkout (options = {}) {
const { gift, user, gemsBlock: gemsBlockKey } = options;
const {
gift, gemsBlock: gemsBlockKey, sku, user,
} = options;
let amount;
let gemsBlock;
@@ -99,12 +101,17 @@ api.checkout = async function checkout (options = {}) {
amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
description = 'mo. Habitica Subscription (Gift)';
}
} else if (sku) {
if (sku === 'Pet-Gryphatrice-Jubilant') {
description = 'Jubilant Gryphatrice';
amount = 9.99;
}
} else {
gemsBlock = getGemsBlock(gemsBlockKey);
amount = gemsBlock.price / 100;
}
if (!gift || gift.type === 'gems') {
if (gemsBlock || (gift && gift.type === 'gems')) {
const receiver = gift ? gift.member : user;
const receiverCanGetGems = await receiver.canGetGems();
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', receiver.preferences.language));
@@ -146,10 +153,10 @@ api.checkout = async function checkout (options = {}) {
api.checkoutSuccess = async function checkoutSuccess (options = {}) {
const {
user, gift, gemsBlock: gemsBlockKey, paymentId, customerId,
user, gift, gemsBlock: gemsBlockKey, paymentId, customerId, sku,
} = options;
let method = 'buyGems';
let method = sku ? 'buySkuItem' : 'buyGems';
const data = {
user,
customerId,
@@ -164,6 +171,8 @@ api.checkoutSuccess = async function checkoutSuccess (options = {}) {
data.paymentMethod = 'PayPal (Gift)';
data.gift = gift;
} else if (sku) {
data.sku = sku;
} else {
data.gemsBlock = getGemsBlock(gemsBlockKey);
}

View File

@@ -0,0 +1,110 @@
import moment from 'moment';
import {
BadRequest,
} from '../errors';
import shared from '../../../common';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
import { getGemsBlock, buyGems } from './gems'; // eslint-disable-line import/no-cycle
const analytics = getAnalyticsServiceByEnvironment();
const RESPONSE_INVALID_ITEM = 'INVALID_ITEM_PURCHASED';
const EVENTS = {
birthday10: {
start: '2023-01-30T08:00-05:00',
end: '2023-02-08T23:59-05:00',
},
};
function canBuyGryphatrice (user) {
if (!moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end)) return false;
if (user.items.pets['Gryphatrice-Jubilant']) return false;
return true;
}
async function buyGryphatrice (data) {
// Double check it's available
if (!canBuyGryphatrice(data.user)) throw new BadRequest();
const key = 'Gryphatrice-Jubilant';
data.user.items.pets[key] = 5;
data.user.purchased.txnCount += 1;
analytics.trackPurchase({
uuid: data.user._id,
itemPurchased: 'Gryphatrice',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
purchaseType: 'checkout',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: 10,
headers: data.headers,
firstPurchase: data.user.purchased.txnCount === 1,
});
if (data.user.markModified) data.user.markModified('items.pets');
await data.user.save();
}
export function canBuySkuItem (sku, user) {
switch (sku) {
case 'com.habitrpg.android.habitica.iap.pets.gryphatrice_jubilant':
case 'com.habitrpg.ios.Habitica.pets.Gryphatrice_Jubilant':
case 'Pet-Gryphatrice-Jubilant':
case 'price_0MPZ6iZCD0RifGXlLah2furv':
return canBuyGryphatrice(user);
default:
return true;
}
}
export async function buySkuItem (data) {
let gemsBlockKey;
switch (data.sku) { // eslint-disable-line default-case
case 'com.habitrpg.android.habitica.iap.4gems':
case 'com.habitrpg.ios.Habitica.4gems':
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.android.habitica.iap.20gems':
case 'com.habitrpg.android.habitica.iap.21gems':
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.android.habitica.iap.42gems':
case 'com.habitrpg.ios.Habitica.42gems':
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.android.habitica.iap.84gems':
case 'com.habitrpg.ios.Habitica.84gems':
gemsBlockKey = '84gems';
break;
case 'com.habitrpg.android.habitica.iap.pets.gryphatrice_jubilant':
case 'com.habitrpg.ios.Habitica.pets.Gryphatrice_Jubilant':
case 'Pet-Gryphatrice-Jubilant':
case 'price_0MPZ6iZCD0RifGXlLah2furv':
buyGryphatrice(data);
return;
}
if (gemsBlockKey) {
const gemsBlock = getGemsBlock(gemsBlockKey);
if (data.gift) {
data.gift.type = 'gems';
if (!data.gift.gems) data.gift.gems = {};
data.gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
await buyGems({
user: data.user,
gift: data.gift,
paymentMethod: data.paymentMethod,
gemsBlock,
headers: data.headers,
});
return;
}
throw new BadRequest(RESPONSE_INVALID_ITEM);
}

View File

@@ -24,6 +24,7 @@ export async function createCheckoutSession (options, stripeInc) {
sub,
groupId,
coupon,
sku,
} = options;
// @TODO: We need to mock this, but curently we don't have correct
@@ -37,6 +38,8 @@ export async function createCheckoutSession (options, stripeInc) {
validateGiftMessage(gift, user);
} else if (sub) {
type = 'subscription';
} else if (sku) {
type = 'sku';
}
const metadata = {
@@ -71,6 +74,12 @@ export async function createCheckoutSession (options, stripeInc) {
price: sub.key,
quantity,
}];
} else if (type === 'sku') {
metadata.sku = sku;
lineItems = [{
price: sku,
quantity: 1,
}];
} else {
const {
amount,

View File

@@ -22,6 +22,20 @@ function getGiftAmount (gift) {
return `${(gift.gems.amount / 4) * 100}`;
}
export async function applySku (session) {
const { metadata } = session;
const { userId, sku } = metadata;
const user = await User.findById(metadata.userId).exec();
if (!user) throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId }));
if (sku === 'price_0MPZ6iZCD0RifGXlLah2furv') {
await payments.buySkuItem({
sku, user, paymentMethod: stripeConstants.PAYMENT_METHOD,
});
} else {
throw new NotFound('SKU not found.');
}
}
export async function getOneTimePaymentInfo (gemsBlockKey, gift, user) {
let receiver = user;

View File

@@ -14,7 +14,7 @@ import { // eslint-disable-line import/no-cycle
basicFields as basicGroupFields,
} from '../../../models/group';
import shared from '../../../../common';
import { applyGemPayment } from './oneTimePayments'; // eslint-disable-line import/no-cycle
import { applyGemPayment, applySku } from './oneTimePayments'; // eslint-disable-line import/no-cycle
import { applySubscription, handlePaymentMethodChange } from './subscriptions'; // eslint-disable-line import/no-cycle
const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET');
@@ -69,10 +69,12 @@ export async function handleWebhooks (options, stripeInc) {
if (metadata.type === 'edit-card-group' || metadata.type === 'edit-card-user') {
await handlePaymentMethodChange(session);
} else if (metadata.type !== 'subscription') {
await applyGemPayment(session);
} else {
} else if (metadata.type === 'subscription') {
await applySubscription(session);
} else if (metadata.type === 'sku') {
await applySku(session);
} else {
await applyGemPayment(session);
}
break;

View File

@@ -74,7 +74,39 @@ async function prepareSubscriptionValues (data) {
? data.gift.subscription.key
: data.sub.key];
const autoRenews = data.autoRenews !== undefined ? data.autoRenews : true;
const months = Number(block.months);
const updatedFrom = data.updatedFrom
? shared.content.subscriptionBlocks[data.updatedFrom.key]
: undefined;
let months;
if (updatedFrom && Number(updatedFrom.months) !== 1) {
if (Number(updatedFrom.months) > Number(block.months)) {
months = 0;
} else if (data.updatedFrom.logic === 'payDifference') {
months = Math.max(0, Number(block.months) - Number(updatedFrom.months));
} else if (data.updatedFrom.logic === 'payFull') {
months = Number(block.months);
} else if (data.updatedFrom.logic === 'refundAndRepay') {
const originalMonths = Number(updatedFrom.months);
let currentCycleBegin = moment(recipient.purchased.plan.dateCurrentTypeCreated);
const today = moment();
while (currentCycleBegin.isBefore()) {
currentCycleBegin = currentCycleBegin.add({ months: originalMonths });
}
// Subtract last iteration again, because we overshot
currentCycleBegin = currentCycleBegin.subtract({ months: originalMonths });
// For simplicity we round every month to 30 days since moment can not add half months
if (currentCycleBegin.add({ days: (originalMonths * 30) / 2.0 }).isBefore(today)) {
// user is in second half of their subscription cycle. Give them full benefits.
months = Number(block.months);
} else {
// user is in first half of their subscription cycle. Give them the difference.
months = Math.max(0, Number(block.months) - Number(updatedFrom.months));
}
}
}
if (months === undefined) {
months = Number(block.months);
}
const today = new Date();
let group;
let groupId;
@@ -82,6 +114,7 @@ async function prepareSubscriptionValues (data) {
let purchaseType = 'subscribe';
let emailType = 'subscription-begins';
let recipientIsSubscribed = recipient.isSubscribed();
const isNewSubscription = !recipientIsSubscribed;
// If we are buying a group subscription
if (data.groupId) {
@@ -122,6 +155,10 @@ async function prepareSubscriptionValues (data) {
const { plan } = recipient.purchased;
if (isNewSubscription) {
plan.perkMonthCount = 0;
}
if (data.gift || !autoRenews) {
if (plan.customerId && !plan.dateTerminated) { // User has active plan
plan.extraMonths += months;
@@ -136,6 +173,7 @@ async function prepareSubscriptionValues (data) {
plan.dateTerminated = moment().add({ months }).toDate();
plan.dateCreated = today;
}
plan.dateCurrentTypeCreated = today;
}
if (!plan.customerId) {
@@ -152,6 +190,7 @@ async function prepareSubscriptionValues (data) {
planId: block.key,
customerId: data.customerId,
dateUpdated: today,
dateCurrentTypeCreated: today,
paymentMethod: data.paymentMethod,
extraMonths: Number(plan.extraMonths) + _dateDiff(today, plan.dateTerminated),
dateTerminated: null,
@@ -194,6 +233,7 @@ async function prepareSubscriptionValues (data) {
itemPurchased,
purchaseType,
emailType,
isNewSubscription,
};
}
@@ -209,15 +249,22 @@ async function createSubscription (data) {
itemPurchased,
purchaseType,
emailType,
isNewSubscription,
} = await prepareSubscriptionValues(data);
// Block sub perks
const perks = Math.floor(months / 3);
if (perks) {
plan.consecutive.offset += months;
plan.consecutive.gemCapExtra += perks * 5;
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25;
await plan.updateHourglasses(recipient._id, perks, 'subscription_perks'); // one Hourglass every 3 months
if (months > 1 && (!data.gift || !isNewSubscription)) {
if (!data.gift && !groupId) {
plan.consecutive.offset = block.months;
}
} else if (months === 1) {
plan.consecutive.offset = 0;
}
if (months > 1 || data.gift) {
await plan.incrementPerkCounterAndReward(recipient._id, months);
} else {
// Make sure the perkMonthCount field is initialized.
await plan.incrementPerkCounterAndReward(recipient._id, 0);
}
if (recipient !== group) {

View File

@@ -21,7 +21,7 @@ const apnProvider = APN_ENABLED ? new apn.Provider({
}) : undefined;
function removePushDevice (user, pushDevice) {
return User.update({ _id: user._id }, {
return User.updateOne({ _id: user._id }, {
$pull: { pushDevices: { regId: pushDevice.regId } },
}).exec().catch(err => {
logger.error(err, `Error removing pushDevice ${pushDevice.regId} for user ${user._id}`);

View File

@@ -24,7 +24,7 @@ export function readController (router, controller, overrides = []) {
// If an authentication middleware is used run getUserLanguage after it, otherwise before
// for cron instead use it only if an authentication middleware is present
const authMiddlewareIndex = _.findIndex(middlewares, middleware => {
let authMiddlewareIndex = _.findIndex(middlewares, middleware => {
if (middleware.name.indexOf('authWith') === 0) { // authWith{Headers|Session|Url|...}
return true;
}
@@ -36,6 +36,7 @@ export function readController (router, controller, overrides = []) {
// disable caching for all routes with mandatory or optional authentication
if (authMiddlewareIndex !== -1) {
middlewares.unshift(disableCache);
authMiddlewareIndex += 1;
}
if (action.noLanguage !== true) { // unless getting the language is explictly disabled

View File

@@ -12,7 +12,7 @@ import {
} from '../models/group';
import apiError from './apiError';
const partyMembersFields = 'profile.name stats achievements items.special notifications flags pinnedItems';
const partyMembersFields = 'profile.name stats achievements items.special pinnedItems notifications flags';
// Excluding notifications and flags from the list of public fields to return.
const partyMembersPublicFields = 'profile.name stats achievements items.special';
@@ -75,12 +75,13 @@ async function castSelfSpell (req, user, spell, quantity = 1) {
await user.save();
}
async function castPartySpell (req, party, partyMembers, user, spell, quantity = 1) {
async function getPartyMembers (user, party) {
let partyMembers;
if (!party) {
// Act as solo party
partyMembers = [user]; // eslint-disable-line no-param-reassign
partyMembers = [user];
} else {
partyMembers = await User // eslint-disable-line no-param-reassign
partyMembers = await User
.find({
'party._id': party._id,
_id: { $ne: user._id }, // add separately
@@ -90,22 +91,40 @@ async function castPartySpell (req, party, partyMembers, user, spell, quantity =
partyMembers.unshift(user);
}
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, partyMembers, req);
}
await Promise.all(partyMembers.map(m => m.save()));
return partyMembers;
}
async function castUserSpell (res, req, party, partyMembers, targetId, user, spell, quantity = 1) {
async function castPartySpell (req, party, user, spell, quantity = 1) {
let partyMembers;
if (spell.bulk) {
const data = { };
if (party) {
data.query = { 'party._id': party._id };
} else {
data.query = { _id: user._id };
}
spell.cast(user, data);
await User.updateMany(data.query, data.update);
await user.save();
partyMembers = await getPartyMembers(user, party);
} else {
partyMembers = await getPartyMembers(user, party);
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, partyMembers, req);
}
await Promise.all(partyMembers.map(m => m.save()));
}
return partyMembers;
}
async function castUserSpell (res, req, party, targetId, user, spell, quantity = 1) {
let partyMembers;
if (!party && (!targetId || user._id === targetId)) {
partyMembers = user; // eslint-disable-line no-param-reassign
partyMembers = user;
} else {
if (!targetId) throw new BadRequest(res.t('targetIdUUID'));
if (!party) throw new NotFound(res.t('partyNotFound'));
partyMembers = await User // eslint-disable-line no-param-reassign
partyMembers = await User
.findOne({ _id: targetId, 'party._id': party._id })
.select(partyMembersFields)
.exec();
@@ -196,10 +215,10 @@ async function castSpell (req, res, { isV3 = false }) {
let partyMembers;
if (targetType === 'party') {
partyMembers = await castPartySpell(req, party, partyMembers, user, spell, quantity);
partyMembers = await castPartySpell(req, party, user, spell, quantity);
} else {
partyMembers = await castUserSpell(
res, req, party, partyMembers,
res, req, party,
targetId, user, spell, quantity,
);
}

View File

@@ -114,7 +114,7 @@ async function createTasks (req, res, options = {}) {
};
}
await owner.update(taskOrderUpdateQuery).exec();
await owner.updateOne(taskOrderUpdateQuery).exec();
// tasks with aliases need to be validated asynchronously
await validateTaskAlias(toSave, res);

View File

@@ -51,6 +51,7 @@ const updatablePaths = [
'party.orderAscending',
'party.quest.completed',
'party.quest.RSVPNeeded',
'party.seeking',
'preferences',
'profile',
@@ -97,7 +98,9 @@ function checkPreferencePurchase (user, path, item) {
const itemPath = `${path}.${item}`;
const appearance = _.get(common.content.appearances, itemPath);
if (!appearance) return false;
if (appearance.price === 0) return true;
if (appearance.price === 0 && path !== 'background') {
return true;
}
return _.get(user.purchased, itemPath);
}
@@ -120,6 +123,17 @@ export async function update (req, res, { isV3 = false }) {
let promisesForTagsRemoval = [];
if (req.body['party.seeking'] !== undefined && req.body['party.seeking'] !== null) {
user.invitations.party = {};
user.invitations.parties = [];
res.analytics.track('Starts Looking for Party', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
}
if (req.body['profile.name'] !== undefined) {
const newName = req.body['profile.name'];
if (newName === null) throw new BadRequest(res.t('invalidReqParams'));
@@ -168,7 +182,15 @@ export async function update (req, res, { isV3 = false }) {
throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key }));
}
if (key === 'tags') {
if (key === 'party.seeking' && val === null) {
user.party.seeking = undefined;
res.analytics.track('Leaves Looking for Party', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
} else if (key === 'tags') {
if (!Array.isArray(val)) throw new BadRequest('Tag list must be an array.');
const removedTagsIds = [];
@@ -198,13 +220,13 @@ export async function update (req, res, { isV3 = false }) {
// Remove from all the tasks
// NOTE each tag to remove requires a query
promisesForTagsRemoval = removedTagsIds.map(tagId => Tasks.Task.update({
promisesForTagsRemoval = removedTagsIds.map(tagId => Tasks.Task.updateMany({
userId: user._id,
}, {
$pull: {
tags: tagId,
},
}, { multi: true }).exec());
}).exec());
} else if (key === 'flags.newStuff' && val === false) {
// flags.newStuff was removed from the user schema and is only returned for compatibility
// reasons but we're keeping the ability to set it in API v3
@@ -254,6 +276,7 @@ export async function reset (req, res, { isV3 = false }) {
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
res.respond(200, ...resetRes);

View File

@@ -61,7 +61,7 @@ function sendWebhook (webhook, body, user) {
};
}
return User.update({
return User.updateOne({
_id: user._id,
'webhooks.id': webhook.id,
}, update).exec();

View File

@@ -1,3 +1,4 @@
import moment from 'moment';
import nconf from 'nconf';
import url from 'url';
import {
@@ -10,6 +11,7 @@ import gcpStackdriverTracer from '../libs/gcpTraceAgent';
import common from '../../common';
import { getLanguageFromUser } from '../libs/language';
const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android'];
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL');
const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags', 'permissions'];
@@ -55,6 +57,7 @@ export function authWithHeaders (options = {}) {
return function authWithHeadersHandler (req, res, next) {
const userId = req.header('x-api-user');
const apiToken = req.header('x-api-key');
const client = req.header('x-client');
const optional = options.optional || false;
if (!userId || !apiToken) {
@@ -90,6 +93,11 @@ export function authWithHeaders (options = {}) {
req.session.userId = user._id;
stackdriverTraceUserId(user._id);
user.auth.timestamps.updated = new Date();
if (OFFICIAL_PLATFORMS.indexOf(client) === -1
&& (!user.flags.thirdPartyTools || moment().diff(user.flags.thirdPartyTools, 'days') > 0)
) {
User.updateOne(userQuery, { $set: { 'flags.thirdPartyTools': new Date() } }).exec();
}
return next();
})
.catch(next);

View File

@@ -16,7 +16,7 @@ async function checkForActiveCron (user, now) {
// To avoid double cron we first set _cronSignature
// and then check that it's not changed while processing
const userUpdateResult = await User.update({
const userUpdateResult = await User.updateOne({
_id: user._id,
$or: [ // Make sure last cron was successful or failed before cronRetryTime
{ _cronSignature: 'NOT_RUNNING' },
@@ -36,7 +36,7 @@ async function checkForActiveCron (user, now) {
}
async function updateLastCron (user, now) {
await User.update({
await User.updateOne({
_id: user._id,
}, {
lastCron: now, // setting lastCron now so we don't risk re-running parts of cron if it fails
@@ -44,7 +44,7 @@ async function updateLastCron (user, now) {
}
async function unlockUser (user) {
await User.update({
await User.updateOne({
_id: user._id,
}, {
_cronSignature: 'NOT_RUNNING',
@@ -125,7 +125,7 @@ async function cronAsync (req, res) {
await Group.processQuestProgress(user, progress);
// Set _cronSignature, lastCron and auth.timestamps.loggedin to signal end of cron
await User.update({
await User.updateOne({
_id: user._id,
}, {
$set: {
@@ -153,7 +153,7 @@ async function cronAsync (req, res) {
// For any other error make sure to reset _cronSignature
// so that it doesn't prevent cron from running
// at the next request
await User.update({
await User.updateOne({
_id: user._id,
}, {
_cronSignature: 'NOT_RUNNING',

View File

@@ -72,6 +72,7 @@ export default function attachMiddlewares (app, server) {
app.use(bodyParser.urlencoded({
extended: true, // Uses 'qs' library as old connect middleware
limit: '10mb',
}));
app.use(function bodyMiddleware (req, res, next) { // eslint-disable-line prefer-arrow-callback
if (req.path === '/stripe/webhooks') {
@@ -79,7 +80,7 @@ export default function attachMiddlewares (app, server) {
// See https://stripe.com/docs/webhooks/signatures#verify-official-libraries
bodyParser.raw({ type: 'application/json' })(req, res, next);
} else {
bodyParser.json()(req, res, next);
bodyParser.json({ limit: '10mb' })(req, res, next);
}
});

View File

@@ -102,7 +102,7 @@ schema.methods.addToUser = async function addChallengeToUser (user) {
// Add challenge to users challenges atomically (with a condition that checks that it
// is not there already) to prevent multiple concurrent requests from passing through
// see https://github.com/HabitRPG/habitica/issues/11295
const result = await User.update(
const result = await User.updateOne(
{
_id: user._id,
challenges: { $nin: [this._id] },
@@ -249,7 +249,7 @@ async function _addTaskFn (challenge, tasks, memberId) {
},
};
const updateUserParams = { ...updateTasksOrderQ, ...addToChallengeTagSet };
toSave.unshift(User.update({ _id: memberId }, updateUserParams).exec());
toSave.unshift(User.updateOne({ _id: memberId }, updateUserParams).exec());
return Promise.all(toSave);
}
@@ -278,11 +278,11 @@ schema.methods.updateTask = async function challengeUpdateTask (task) {
const taskSchema = Tasks[task.type];
// Updating instead of loading and saving for performances,
// risks becoming a problem if we introduce more complexity in tasks
await taskSchema.update({
await taskSchema.updateMany({
userId: { $exists: true },
'challenge.id': challenge.id,
'challenge.taskId': task._id,
}, updateCmd, { multi: true }).exec();
}, updateCmd).exec();
};
// Remove a task from challenge members
@@ -290,13 +290,13 @@ schema.methods.removeTask = async function challengeRemoveTask (task) {
const challenge = this;
// Set the task as broken
await Tasks.Task.update({
await Tasks.Task.updateMany({
userId: { $exists: true },
'challenge.id': challenge.id,
'challenge.taskId': task._id,
}, {
$set: { 'challenge.broken': 'TASK_DELETED' },
}, { multi: true }).exec();
}).exec();
};
// Unlink challenges tasks (and the challenge itself) from user. TODO rename to 'leave'
@@ -311,9 +311,9 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep, sa
this.memberCount -= 1;
if (keep === 'keep-all') {
await Tasks.Task.update(findQuery, {
await Tasks.Task.updateMany(findQuery, {
$set: { challenge: {} },
}, { multi: true }).exec();
}).exec();
const promises = [this.save()];
@@ -356,11 +356,12 @@ schema.methods.closeChal = async function closeChal (broken = {}) {
// Refund the leader if the challenge is deleted (no winner chosen)
if (brokenReason === 'CHALLENGE_DELETED') {
await User.update({ _id: challenge.leader }, { $inc: { balance: challenge.prize / 4 } }).exec();
await User.updateOne({ _id: challenge.leader }, { $inc: { balance: challenge.prize / 4 } })
.exec();
}
// Update the challengeCount on the group
await Group.update({ _id: challenge.group }, { $inc: { challengeCount: -1 } }).exec();
await Group.updateOne({ _id: challenge.group }, { $inc: { challengeCount: -1 } }).exec();
// Award prize to winner and notify
if (winner) {
@@ -370,7 +371,7 @@ schema.methods.closeChal = async function closeChal (broken = {}) {
// reimburse the leader
const winnerCanGetGems = await winner.canGetGems();
if (!winnerCanGetGems) {
await User.update(
await User.updateOne(
{ _id: challenge.leader },
{ $inc: { balance: challenge.prize / 4 } },
).exec();
@@ -408,22 +409,22 @@ schema.methods.closeChal = async function closeChal (broken = {}) {
Tasks.Task.remove({ 'challenge.id': challenge._id, userId: { $exists: false } }).exec(),
// Set the challenge tag to non-challenge status
// and remove the challenge from the user's challenges
User.update({
User.updateMany({
challenges: challenge._id,
'tags.id': challenge._id,
}, {
$set: { 'tags.$.challenge': false },
$pull: { challenges: challenge._id },
}, { multi: true }).exec(),
}).exec(),
// Break users' tasks
Tasks.Task.update({
Tasks.Task.updateMany({
'challenge.id': challenge._id,
}, {
$set: {
'challenge.broken': brokenReason,
'challenge.winner': winner && winner.profile.name,
},
}, { multi: true }).exec(),
}).exec(),
];
Promise.all(backgroundTasks);

View File

@@ -44,6 +44,7 @@ const { questSeriesAchievements } = shared.content;
const { Schema } = mongoose;
export const INVITES_LIMIT = 100; // must not be greater than MAX_EMAIL_INVITES_BY_USER
export const PARTY_PENDING_LIMIT = 10;
export const { TAVERN_ID } = shared;
const NO_CHAT_NOTIFICATIONS = [TAVERN_ID];
@@ -267,10 +268,13 @@ schema.statics.getGroup = async function getGroup (options = {}) {
if (groupId === user.party._id) {
// reset party object to default state
user.party = {};
await user.save();
} else {
removeFromArray(user.guilds, groupId);
const item = removeFromArray(user.guilds, groupId);
if (item) {
await user.save();
}
}
await user.save();
}
return group;
@@ -403,6 +407,7 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
if (user._id !== chatMsg.uuid && chatMsg.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING) {
return undefined;
}
chatMsg.flagCount = 0;
}
return chatMsg;
@@ -486,6 +491,9 @@ schema.statics.validateInvitations = async function getInvitationErr (invites, r
query['invitations.party.id'] = group._id;
// @TODO invitations are now stored like this: `'invitations.parties': []`
const groupInvites = await User.countDocuments(query).exec();
if (groupInvites + totalInvites > PARTY_PENDING_LIMIT) {
throw new BadRequest(res.t('partyExceedsInvitesLimit', { maxInvites: PARTY_PENDING_LIMIT }));
}
memberCount += groupInvites;
// Counting the members that are going to be invited by email and uuids
@@ -581,7 +589,10 @@ schema.methods.sendChat = function sendChat (options = {}) {
// Kick off chat notifications in the background.
const query = {};
const query = {
_id: { $ne: user ? user._id : '' },
'notifications.data.group.id': { $ne: this._id },
};
if (this.type === 'party') {
query['party._id'] = this._id;
@@ -589,16 +600,7 @@ schema.methods.sendChat = function sendChat (options = {}) {
query.guilds = this._id;
}
query._id = { $ne: user ? user._id : '' };
// First remove the old notification (if it exists)
const lastSeenUpdateRemoveOld = {
$pull: {
notifications: { type: 'NEW_CHAT_MESSAGE', 'data.group.id': this._id },
},
};
// Then add the new notification
// Add the new notification
const lastSeenUpdateAddNew = {
$set: { // old notification, supported until mobile is updated and we release api v4
[`newMessages.${this._id}`]: { name: this.name, value: true },
@@ -612,9 +614,7 @@ schema.methods.sendChat = function sendChat (options = {}) {
};
User
.update(query, lastSeenUpdateRemoveOld, { multi: true })
.exec()
.then(() => User.update(query, lastSeenUpdateAddNew, { multi: true }).exec())
.updateMany(query, lastSeenUpdateAddNew).exec()
.catch(err => logger.error(err));
if (this.type === 'party' && user) {
@@ -662,7 +662,7 @@ schema.methods.handleQuestInvitation = async function handleQuestInvitation (use
// to prevent multiple concurrent requests overriding updates
// see https://github.com/HabitRPG/habitica/issues/11398
const Group = this.constructor;
const result = await Group.update(
const result = await Group.updateOne(
{
_id: this._id,
[`quest.members.${user._id}`]: { $type: 10 }, // match BSON Type Null (type number 10)
@@ -710,7 +710,7 @@ schema.methods.startQuest = async function startQuest (user) {
// Persist quest.members early to avoid simultaneous handling of accept/reject
// while processing the rest of this script
await this.update({ $set: { 'quest.members': this.quest.members } }).exec();
await this.updateOne({ $set: { 'quest.members': this.quest.members } }).exec();
const nonUserQuestMembers = _.keys(this.quest.members);
removeFromArray(nonUserQuestMembers, user._id);
@@ -750,7 +750,7 @@ schema.methods.startQuest = async function startQuest (user) {
user.markModified('items.quests');
promises.push(user.save());
} else { // another user is starting the quest, update the leader separately
promises.push(User.update({ _id: this.quest.leader }, {
promises.push(User.updateOne({ _id: this.quest.leader }, {
$inc: {
[`items.quests.${this.quest.key}`]: -1,
},
@@ -758,7 +758,7 @@ schema.methods.startQuest = async function startQuest (user) {
}
// update the remaining users
promises.push(User.update({
promises.push(User.updateMany({
_id: { $in: nonUserQuestMembers },
}, {
$set: {
@@ -766,16 +766,15 @@ schema.methods.startQuest = async function startQuest (user) {
'party.quest.progress.down': 0,
'party.quest.completed': null,
},
}, { multi: true }).exec());
}).exec());
await Promise.all(promises);
// update the users who are not participating
// Do not block updates
User.update({
User.updateMany({
_id: { $in: nonMembers },
}, _cleanQuestParty(),
{ multi: true }).exec();
}, _cleanQuestParty()).exec();
const newMessage = this.sendChat({
message: `\`${shared.i18n.t('chatQuestStarted', { questName: quest.text('en') }, 'en')}\``,
@@ -906,7 +905,7 @@ function _getUserUpdateForQuestReward (itemToAward, allAwardedItems) {
async function _updateUserWithRetries (userId, updates, numTry = 1, query = {}) {
query._id = userId;
try {
return await User.update(query, updates).exec();
return await User.updateOne(query, updates).exec();
} catch (err) {
if (numTry < MAX_UPDATE_RETRIES) {
numTry += 1; // eslint-disable-line no-param-reassign
@@ -952,7 +951,7 @@ schema.methods.finishQuest = async function finishQuest (quest) {
this.markModified('quest');
if (this._id === TAVERN_ID) {
return User.update({}, updates, { multi: true }).exec();
return User.updateMany({}, updates).exec();
}
const promises = participants.map(userId => {
@@ -1392,10 +1391,10 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepC
const userUpdate = { $pull: { 'preferences.tasks.mirrorGroupTasks': group._id } };
if (group.type === 'guild') {
userUpdate.$pull.guilds = group._id;
promises.push(User.update({ _id: user._id }, userUpdate).exec());
promises.push(User.updateOne({ _id: user._id }, userUpdate).exec());
} else {
userUpdate.$set = { party: {} };
promises.push(User.update({ _id: user._id }, userUpdate).exec());
promises.push(User.updateOne({ _id: user._id }, userUpdate).exec());
update.$unset = { [`quest.members.${user._id}`]: 1 };
}
@@ -1511,7 +1510,7 @@ schema.methods.unlinkTask = async function groupUnlinkTask (
const promises = [unlinkingTask.save()];
if (keep === 'keep-all') {
await Tasks.Task.update(findQuery, {
await Tasks.Task.updateOne(findQuery, {
$set: { group: {} },
}).exec();

View File

@@ -3,6 +3,9 @@ import validator from 'validator';
import baseModel from '../libs/baseModel';
import { TransactionModel as Transaction } from './transaction';
// multi-month subscriptions are for multiples of 3 months
const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3;
export const schema = new mongoose.Schema({
planId: String,
subscriptionId: String,
@@ -13,7 +16,9 @@ export const schema = new mongoose.Schema({
dateCreated: Date,
dateTerminated: Date,
dateUpdated: Date,
dateCurrentTypeCreated: Date,
extraMonths: { $type: Number, default: 0 },
perkMonthCount: { $type: Number, default: -1 },
gemsBought: { $type: Number, default: 0 },
mysteryItems: { $type: Array, default: () => [] },
lastReminderDate: Date, // indicates the last time a subscription reminder was sent
@@ -45,6 +50,40 @@ schema.plugin(baseModel, {
_id: false,
});
schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward
(userID, adding) {
let addingNumber = adding;
if (typeof adding === 'string' || adding instanceof String) {
addingNumber = parseInt(adding, 10);
}
const isSingleMonthPlan = this.planId === 'basic_earned' || this.planId === 'group_plan_auto' || this.planId === 'group_monthly';
// if perkMonthCount wasn't used before, initialize it.
if (this.perkMonthCount === undefined || this.perkMonthCount === -1) {
if (isSingleMonthPlan && this.consecutive.count > 0) {
this.perkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH;
} else {
this.perkMonthCount = 0;
}
} else if (isSingleMonthPlan) {
const expectedPerkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH;
if (this.perkMonthCount === (expectedPerkMonthCount - 1)) {
// User was affected by a bug that makes their perkMonthCount off by one
this.perkMonthCount += 1;
}
}
this.perkMonthCount += addingNumber;
const perks = Math.floor(this.perkMonthCount / 3);
if (perks > 0) {
this.consecutive.gemCapExtra += 5 * perks; // 5 extra Gems every 3 months
// cap it at 50 (hard 25 limit + extra 25)
if (this.consecutive.gemCapExtra > 25) this.consecutive.gemCapExtra = 25;
this.perkMonthCount -= (perks * 3);
// one Hourglass every 3 months
await this.updateHourglasses(userID, perks, 'subscription_perks'); // eslint-disable-line no-await-in-loop
}
};
schema.methods.updateHourglasses = async function updateHourglasses (userId,
amount,
transactionType,

View File

@@ -150,10 +150,22 @@ function _setUpNewUser (user) {
user.items.quests.dustbunnies = 1;
user.purchased.background.violet = true;
user.preferences.background = 'violet';
if (moment().isBetween('2022-12-27T08:00-05:00', '2023-01-02T20:00-05:00')) {
user.migration = '20221227_nye';
user.items.gear.owned.head_special_nye = true;
user.items.gear.equipped.head = 'head_special_nye';
if (moment().isBefore('2023-03-15T12:00-05:00')) {
user.migration = '20230314_pi_day';
user.items.gear.owned.head_special_piDay = true;
user.items.gear.equipped.head = 'head_special_piDay';
user.items.gear.owned.shield_special_piDay = true;
user.items.gear.equipped.shield = 'shield_special_piDay';
user.items.food.Pie_Skeleton = 1;
user.items.food.Pie_Base = 1;
user.items.food.Pie_CottonCandyBlue = 1;
user.items.food.Pie_CottonCandyPink = 1;
user.items.food.Pie_Shade = 1;
user.items.food.Pie_White = 1;
user.items.food.Pie_Golden = 1;
user.items.food.Pie_Zombie = 1;
user.items.food.Pie_Desert = 1;
user.items.food.Pie_Red = 1;
}
user.markModified('items achievements');
@@ -380,6 +392,13 @@ schema.pre('update', function preUpdateUser () {
this.update({}, { $inc: { _v: 1 } });
});
schema.pre('updateOne', function preUpdateUser () {
this.updateOne({}, { $inc: { _v: 1 } });
});
schema.pre('updateMany', function preUpdateUser () {
this.updateMany({}, { $inc: { _v: 1 } });
});
schema.post('save', function postSaveUser () {
// Send a webhook notification when the user has leveled up
if (this._tmp && this._tmp.leveledUp && this._tmp.leveledUp.length > 0) {

View File

@@ -225,10 +225,9 @@ schema.statics.pushNotification = async function pushNotification (
throw validationResult;
}
await this.update(
await this.updateMany(
query,
{ $push: { notifications: newNotification.toObject() } },
{ multi: true },
).exec();
};
@@ -274,13 +273,12 @@ schema.statics.addAchievementUpdate = async function addAchievementUpdate (query
const validationResult = newNotification.validateSync();
if (validationResult) throw validationResult;
await this.update(
await this.updateMany(
query,
{
$push: { notifications: newNotification.toObject() },
$set: { [`achievements.${achievement}`]: true },
},
{ multi: true },
).exec();
};

View File

@@ -153,6 +153,8 @@ export default new Schema({
woodlandWizard: Boolean,
boneToPick: Boolean,
polarPro: Boolean,
plantParent: Boolean,
dinosaurDynasty: Boolean,
// Onboarding Guide
createdTask: Boolean,
completedTask: Boolean,
@@ -305,6 +307,7 @@ export default new Schema({
cardReceived: { $type: Boolean, default: false },
warnedLowHealth: { $type: Boolean, default: false },
verifiedUsername: { $type: Boolean, default: false },
thirdPartyTools: { $type: Date },
},
history: {
@@ -500,6 +503,7 @@ export default new Schema({
// invite is accepted or rejected, quest starts, or quest is cancelled
RSVPNeeded: { $type: Boolean, default: false },
},
seeking: Date,
},
preferences: {
dayStart: {
@@ -531,6 +535,7 @@ export default new Schema({
stickyHeader: { $type: Boolean, default: true },
disableClasses: { $type: Boolean, default: false },
newTaskEdit: { $type: Boolean, default: false },
// not used anymore, now the current filter is saved in preferences.activeFilter
dailyDueDefaultView: { $type: Boolean, default: false },
advancedCollapsed: { $type: Boolean, default: false },
toolbarCollapsed: { $type: Boolean, default: false },
@@ -591,6 +596,12 @@ export default new Schema({
mirrorGroupTasks: [
{ $type: String, validate: [v => validator.isUUID(v), 'Invalid group UUID.'], ref: 'Group' },
],
activeFilter: {
habit: { $type: String, default: 'all' },
daily: { $type: String, default: 'all' },
todo: { $type: String, default: 'remaining' },
reward: { $type: String, default: 'all' },
},
},
improvementCategories: {
$type: Array,
@@ -613,9 +624,9 @@ export default new Schema({
},
stats: {
hp: { $type: Number, default: shared.maxHealth },
mp: { $type: Number, default: 10 },
mp: { $type: Number, default: 10, min: 0 },
exp: { $type: Number, default: 0 },
gp: { $type: Number, default: 0 },
gp: { $type: Number, default: 0, min: 0 },
lvl: {
$type: Number,
default: 1,
@@ -627,17 +638,17 @@ export default new Schema({
class: {
$type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior', required: true,
},
points: { $type: Number, default: 0 },
str: { $type: Number, default: 0 },
con: { $type: Number, default: 0 },
int: { $type: Number, default: 0 },
per: { $type: Number, default: 0 },
points: { $type: Number, default: 0, min: 0 },
str: { $type: Number, default: 0, min: 0 },
con: { $type: Number, default: 0, min: 0 },
int: { $type: Number, default: 0, min: 0 },
per: { $type: Number, default: 0, min: 0 },
buffs: {
str: { $type: Number, default: 0 },
int: { $type: Number, default: 0 },
per: { $type: Number, default: 0 },
con: { $type: Number, default: 0 },
stealth: { $type: Number, default: 0 },
str: { $type: Number, default: 0, min: 0 },
int: { $type: Number, default: 0, min: 0 },
per: { $type: Number, default: 0, min: 0 },
con: { $type: Number, default: 0, min: 0 },
stealth: { $type: Number, default: 0, min: 0 },
streaks: { $type: Boolean, default: false },
snowball: { $type: Boolean, default: false },
spookySparkles: { $type: Boolean, default: false },

View File

@@ -31,6 +31,7 @@ const NOTIFICATION_TYPES = [
'SCORED_TASK',
'UNALLOCATED_STATS_POINTS',
'WON_CHALLENGE',
'ITEM_RECEIVED', // notify user when they've got goodies via migration
// achievement notifications
'ACHIEVEMENT', // generic achievement notification, details inside `notification.data`
'CHALLENGE_JOINED_ACHIEVEMENT',