mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-19 15:48:04 +01:00
Merge branch 'develop' into phillip/chat-skill-merge
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, {});
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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() } });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
110
website/server/libs/payments/skuItem.js
Normal file
110
website/server/libs/payments/skuItem.js
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -61,7 +61,7 @@ function sendWebhook (webhook, body, user) {
|
||||
};
|
||||
}
|
||||
|
||||
return User.update({
|
||||
return User.updateOne({
|
||||
_id: user._id,
|
||||
'webhooks.id': webhook.id,
|
||||
}, update).exec();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user