Merge branch 'api-v3' into api-v3-client
This commit is contained in:
@@ -37,7 +37,7 @@ function($rootScope, User, $http, Content) {
|
||||
$http.post(url, res).success(function() {
|
||||
window.location.reload(true);
|
||||
}).error(function(res) {
|
||||
alert(res.err);
|
||||
alert(res.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -55,7 +55,7 @@ function($rootScope, User, $http, Content) {
|
||||
$http.post(url, data).success(function() {
|
||||
window.location.reload(true);
|
||||
}).error(function(data) {
|
||||
alert(data.err);
|
||||
alert(data.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -127,12 +127,12 @@ function($rootScope, User, $http, Content) {
|
||||
var url = '/amazon/createOrderReferenceId'
|
||||
$http.post(url, {
|
||||
billingAgreementId: Payments.amazonPayments.billingAgreementId
|
||||
}).success(function(data){
|
||||
}).success(function(res){
|
||||
Payments.amazonPayments.loggedIn = true;
|
||||
Payments.amazonPayments.orderReferenceId = data.orderReferenceId;
|
||||
Payments.amazonPayments.orderReferenceId = res.data.orderReferenceId;
|
||||
Payments.amazonPayments.initWidgets();
|
||||
}).error(function(res){
|
||||
alert(res.err);
|
||||
alert(res.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -146,7 +146,7 @@ function($rootScope, User, $http, Content) {
|
||||
|
||||
var url = '/amazon/verifyAccessToken'
|
||||
$http.post(url, response).error(function(res){
|
||||
alert(res.err);
|
||||
alert(res.message);
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -232,7 +232,7 @@ function($rootScope, User, $http, Content) {
|
||||
Payments.amazonPayments.reset();
|
||||
window.location.reload(true);
|
||||
}).error(function(res){
|
||||
alert(res.err);
|
||||
alert(res.message);
|
||||
Payments.amazonPayments.reset();
|
||||
});
|
||||
}else if(Payments.amazonPayments.type === 'subscription'){
|
||||
@@ -246,7 +246,7 @@ function($rootScope, User, $http, Content) {
|
||||
Payments.amazonPayments.reset();
|
||||
window.location.reload(true);
|
||||
}).error(function(res){
|
||||
alert(res.err);
|
||||
alert(res.message);
|
||||
Payments.amazonPayments.reset();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ var csvStringify = require('csv-stringify');
|
||||
var utils = require('../../libs/api-v2/utils');
|
||||
var api = module.exports;
|
||||
var pushNotify = require('./pushNotifications');
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import v3MembersController from '../api-v3/members';
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
@@ -56,13 +56,13 @@ api.list = async function(req, res, next) {
|
||||
});
|
||||
|
||||
// TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Q.all(resChals.map((chal, index) => {
|
||||
return Q.all([
|
||||
await Bluebird.all(resChals.map((chal, index) => {
|
||||
return Bluebird.all([
|
||||
User.findById(chal.leader).select(nameFields).exec(),
|
||||
Group.findById(chal.group).select(basicGroupFields).exec(),
|
||||
]).then(populatedData => {
|
||||
resChals[index].leader = populatedData[0].toJSON({minimize: true});
|
||||
resChals[index].group = populatedData[1].toJSON({minimize: true});
|
||||
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
|
||||
resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null;
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -88,7 +88,8 @@ api.get = async function(req, res, next) {
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'});
|
||||
|
||||
let leaderRes = (await User.findById(challenge.leader).select('profile.name').exec()).toJSON({minimize: true});
|
||||
let leaderRes = await User.findById(challenge.leader).select('profile.name').exec();
|
||||
leaderRes = leaderRes ? leaderRes.toJSON({minimize: true}) : null;
|
||||
|
||||
challenge.getTransformedData({
|
||||
populateMembers: 'profile.name',
|
||||
@@ -206,7 +207,7 @@ api.create = async function(req, res, next){
|
||||
return newTask.save();
|
||||
});
|
||||
|
||||
let results = await Q.all([challenge.save({
|
||||
let results = await Bluebird.all([challenge.save({
|
||||
validateBeforeSave: false, // already validated
|
||||
}), group.save()].concat(chalTasks));
|
||||
let savedChal = results[0];
|
||||
@@ -288,8 +289,6 @@ api.update = function(req, res, next){
|
||||
});
|
||||
}
|
||||
|
||||
import { _closeChal } from '../api-v3/challenges';
|
||||
|
||||
/**
|
||||
* Delete & close
|
||||
*/
|
||||
@@ -303,7 +302,7 @@ api.delete = async function(req, res, next){
|
||||
if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge'));
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'});
|
||||
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
|
||||
res.sendStatus(200);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -325,7 +324,7 @@ api.selectWinner = async function(req, res, next) {
|
||||
if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.');
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner});
|
||||
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
|
||||
res.respond(200, {});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -347,7 +346,7 @@ api.join = async function(req, res, next){
|
||||
challenge.memberCount += 1;
|
||||
|
||||
// Add all challenge's tasks to user's tasks and save the challenge
|
||||
await Q.all([challenge.syncToUser(user), challenge.save()]);
|
||||
await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
|
||||
|
||||
challenge.getTransformedData({
|
||||
cb (err, transformedChal) {
|
||||
@@ -378,7 +377,7 @@ api.leave = async function(req, res, next){
|
||||
challenge.memberCount -= 1;
|
||||
|
||||
// Unlink challenge's tasks from user's tasks and save the challenge
|
||||
await Q.all([challenge.unlinkTasks(user, keep), challenge.save()]);
|
||||
await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]);
|
||||
|
||||
challenge.getTransformedData({
|
||||
cb (err, transformedChal) {
|
||||
@@ -417,7 +416,7 @@ api.unlink = async function(req, res, next) {
|
||||
} else { // remove
|
||||
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
|
||||
removeFromArray(user.tasksOrder[`${task.type}s`], tid);
|
||||
await Q.all([user.save(), task.remove()]);
|
||||
await Bluebird.all([user.save(), task.remove()]);
|
||||
} else {
|
||||
await task.remove();
|
||||
}
|
||||
|
||||
@@ -1013,7 +1013,7 @@ api.questAccept = function(req, res, next) {
|
||||
|
||||
if (canStartQuestAutomatically(group)) {
|
||||
group.startQuest(user).then(() => {
|
||||
return Q.all([group.save(), user.save()])
|
||||
return Bluebird.all([group.save(), user.save()])
|
||||
})
|
||||
.then(results => {
|
||||
results[0].getTransformedData({
|
||||
@@ -1027,7 +1027,7 @@ api.questAccept = function(req, res, next) {
|
||||
.catch(next);
|
||||
|
||||
} else {
|
||||
Q.all([group.save(), user.save()])
|
||||
Bluebird.all([group.save(), user.save()])
|
||||
.then(results => {
|
||||
results[0].getTransformedData({
|
||||
cb (err, groupTransformed) {
|
||||
@@ -1049,7 +1049,7 @@ api.questAccept = function(req, res, next) {
|
||||
|
||||
if (canStartQuestAutomatically(group)) {
|
||||
group.startQuest(user).then(() => {
|
||||
return Q.all([group.save(), user.save()])
|
||||
return Bluebird.all([group.save(), user.save()])
|
||||
})
|
||||
.then(results => {
|
||||
results[0].getTransformedData({
|
||||
@@ -1063,7 +1063,7 @@ api.questAccept = function(req, res, next) {
|
||||
.catch(next);
|
||||
|
||||
} else {
|
||||
Q.all([group.save(), user.save()])
|
||||
Bluebird.all([group.save(), user.save()])
|
||||
.then(results => {
|
||||
results[0].getTransformedData({
|
||||
cb (err, groupTransformed) {
|
||||
@@ -1090,7 +1090,7 @@ api.questReject = function(req, res, next) {
|
||||
|
||||
if (canStartQuestAutomatically(group)) {
|
||||
group.startQuest(user).then(() => {
|
||||
return Q.all([group.save(), user.save()])
|
||||
return Bluebird.all([group.save(), user.save()])
|
||||
})
|
||||
.then(results => {
|
||||
results[0].getTransformedData({
|
||||
@@ -1104,7 +1104,7 @@ api.questReject = function(req, res, next) {
|
||||
.catch(next);
|
||||
|
||||
} else {
|
||||
Q.all([group.save(), user.save()])
|
||||
Bluebird.all([group.save(), user.save()])
|
||||
.then(results => {
|
||||
results[0].getTransformedData({
|
||||
cb (err, groupTransformed) {
|
||||
@@ -1124,7 +1124,7 @@ api.questCancel = function(req, res, next){
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
Q.all([
|
||||
Bluebird.all([
|
||||
group.save(),
|
||||
User.update(
|
||||
{'party._id': group._id},
|
||||
@@ -1167,7 +1167,7 @@ api.questAbort = function(req, res, next){
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
Q.all([group.save(), memberUpdates, questLeaderUpdate])
|
||||
Bluebird.all([group.save(), memberUpdates, questLeaderUpdate])
|
||||
.then(results => {
|
||||
results[0].getTransformedData({
|
||||
cb (err, groupTransformed) {
|
||||
@@ -1203,10 +1203,10 @@ api.questLeave = function(req, res, next) {
|
||||
user.party.quest = Group.cleanQuestProgress();
|
||||
user.markModified('party.quest');
|
||||
|
||||
var groupSavePromise = Q.nbind(group.save, group);
|
||||
var userSavePromise = Q.nbind(user.save, user);
|
||||
var groupSavePromise = Bluebird.promisify(group.save, {context: group});
|
||||
var userSavePromise = Bluebird.promisify(user.save, {context: user});
|
||||
|
||||
Q.all([groupSavePromise(), userSavePromise()])
|
||||
Bluebird.all([groupSavePromise(), userSavePromise()])
|
||||
.done(function(values) {
|
||||
return res.sendStatus(204);
|
||||
}, function(error) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../libs/api-v3/errors';
|
||||
import { model as Tag } from '../../models/tag';
|
||||
import * as Tasks from '../../models/task';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import {removeFromArray} from './../../libs/api-v3/collectionManipulators';
|
||||
var utils = require('./../../libs/api-v2/utils');
|
||||
var analytics = utils.analytics;
|
||||
@@ -434,7 +434,7 @@ api.delete = function(req, res, next) {
|
||||
|
||||
Group.getGroups({user, types, groupFields})
|
||||
.then(groups => {
|
||||
return Q.all(groups.map((group) => {
|
||||
return Bluebird.all(groups.map((group) => {
|
||||
return group.leave(user, 'remove-all');
|
||||
}));
|
||||
})
|
||||
@@ -651,7 +651,7 @@ api.cast = async function(req, res, next) {
|
||||
let toSave = tasks.filter(t => t.isModified());
|
||||
let isUserModified = user.isModified();
|
||||
toSave.unshift(user.save());
|
||||
let saved = await Q.all(toSave);
|
||||
let saved = await Bluebird.all(toSave);
|
||||
} else if (targetType === 'party' || targetType === 'user') {
|
||||
let party = await Group.getGroup({groupId: 'party', user});
|
||||
// arrays of users when targetType is 'party' otherwise single users
|
||||
@@ -665,7 +665,7 @@ api.cast = async function(req, res, next) {
|
||||
}
|
||||
|
||||
spell.cast(user, partyMembers, req);
|
||||
await Q.all(partyMembers.map(m => m.save()));
|
||||
await Bluebird.all(partyMembers.map(m => m.save()));
|
||||
} else {
|
||||
if (!party && (!targetId || user._id === targetId)) {
|
||||
partyMembers = user;
|
||||
@@ -678,7 +678,7 @@ api.cast = async function(req, res, next) {
|
||||
if (partyMembers === user) {
|
||||
await partyMembers.save();
|
||||
} else {
|
||||
await Q.all([
|
||||
await Bluebird.all([
|
||||
await partyMembers.save(),
|
||||
await user.save(),
|
||||
]);
|
||||
@@ -869,7 +869,7 @@ api.addTask = function(req, res, next) {
|
||||
let validationErrors = task.validateSync();
|
||||
if (validationErrors) return next(validationErrors);
|
||||
|
||||
Q.all([
|
||||
Bluebird.all([
|
||||
user.save(),
|
||||
task.save({validateBeforeSave: false}) // already done ^
|
||||
]).then(results => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
BadRequest,
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import * as passwordUtils from '../../libs/api-v3/password';
|
||||
import logger from '../../libs/api-v3/logger';
|
||||
import { model as User } from '../../models/user';
|
||||
@@ -127,7 +127,7 @@ api.registerLocal = {
|
||||
newUser = fbUser;
|
||||
} else {
|
||||
newUser = new User(newUser);
|
||||
newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere?
|
||||
newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used
|
||||
}
|
||||
|
||||
// we check for partyInvite for backward compatibility
|
||||
@@ -215,17 +215,15 @@ api.loginLocal = {
|
||||
};
|
||||
|
||||
function _passportFbProfile (accessToken) {
|
||||
let deferred = Q.defer();
|
||||
|
||||
passport._strategies.facebook.userProfile(accessToken, (err, profile) => {
|
||||
if (err) {
|
||||
deferred.rejec();
|
||||
} else {
|
||||
deferred.resolve(profile);
|
||||
}
|
||||
return new Bluebird((resolve, reject) => {
|
||||
passport._strategies.facebook.userProfile(accessToken, (err, profile) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(profile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
// Called as a callback by Facebook (or other social providers). Internal route
|
||||
|
||||
@@ -14,11 +14,8 @@ import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import shared from '../../../../common';
|
||||
import * as Tasks from '../../models/task';
|
||||
import { sendTxn as txnEmail } from '../../libs/api-v3/email';
|
||||
import sendPushNotification from '../../libs/api-v3/pushNotifications';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import csvStringify from '../../libs/api-v3/csvStringify';
|
||||
|
||||
let api = {};
|
||||
@@ -90,7 +87,7 @@ api.createChallenge = {
|
||||
let challengeValidationErrors = challenge.validateSync();
|
||||
if (challengeValidationErrors) throw challengeValidationErrors;
|
||||
|
||||
let results = await Q.all([challenge.save({
|
||||
let results = await Bluebird.all([challenge.save({
|
||||
validateBeforeSave: false, // already validate
|
||||
}), group.save()]);
|
||||
let savedChal = results[0];
|
||||
@@ -144,7 +141,7 @@ api.joinChallenge = {
|
||||
challenge.memberCount += 1;
|
||||
|
||||
// Add all challenge's tasks to user's tasks and save the challenge
|
||||
let results = await Q.all([challenge.syncToUser(user), challenge.save()]);
|
||||
let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
|
||||
|
||||
let response = results[1].toJSON();
|
||||
response.group = { // we already have the group data
|
||||
@@ -153,7 +150,8 @@ api.joinChallenge = {
|
||||
type: group.type,
|
||||
privacy: group.privacy,
|
||||
};
|
||||
response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
|
||||
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
|
||||
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
|
||||
|
||||
res.respond(200, response);
|
||||
},
|
||||
@@ -192,7 +190,7 @@ api.leaveChallenge = {
|
||||
challenge.memberCount -= 1;
|
||||
|
||||
// Unlink challenge's tasks from user's tasks and save the challenge
|
||||
await Q.all([challenge.unlinkTasks(user, keep), challenge.save()]);
|
||||
await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]);
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
@@ -228,13 +226,13 @@ api.getUserChallenges = {
|
||||
|
||||
let resChals = challenges.map(challenge => challenge.toJSON());
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Q.all(resChals.map((chal, index) => {
|
||||
return Q.all([
|
||||
await Bluebird.all(resChals.map((chal, index) => {
|
||||
return Bluebird.all([
|
||||
User.findById(chal.leader).select(nameFields).exec(),
|
||||
Group.findById(chal.group).select(basicGroupFields).exec(),
|
||||
]).then(populatedData => {
|
||||
resChals[index].leader = populatedData[0].toJSON({minimize: true});
|
||||
resChals[index].group = populatedData[1].toJSON({minimize: true});
|
||||
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
|
||||
resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null;
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -276,9 +274,9 @@ api.getGroupChallenges = {
|
||||
|
||||
let resChals = challenges.map(challenge => challenge.toJSON());
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Q.all(resChals.map((chal, index) => {
|
||||
await Bluebird.all(resChals.map((chal, index) => {
|
||||
return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => {
|
||||
resChals[index].leader = populatedLeader.toJSON({minimize: true});
|
||||
resChals[index].leader = populatedLeader ? populatedLeader.toJSON({minimize: true}) : null;
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -322,7 +320,8 @@ api.getChallenge = {
|
||||
let chalRes = challenge.toJSON();
|
||||
chalRes.group = group.toJSON({minimize: true});
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
chalRes.leader = (await User.findById(chalRes.leader).select(nameFields).exec()).toJSON({minimize: true});
|
||||
let chalLeader = await User.findById(chalRes.leader).select(nameFields).exec();
|
||||
chalRes.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
|
||||
|
||||
res.respond(200, chalRes);
|
||||
},
|
||||
@@ -359,7 +358,7 @@ api.exportChallengeCsv = {
|
||||
// In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all
|
||||
// results on the server so the perf difference isn't that big (hopefully)
|
||||
|
||||
let [members, tasks] = await Q.all([
|
||||
let [members, tasks] = await Bluebird.all([
|
||||
User.find({challenges: challengeId})
|
||||
.select(nameFields)
|
||||
.sort({_id: 1})
|
||||
@@ -441,76 +440,19 @@ api.updateChallenge = {
|
||||
type: group.type,
|
||||
privacy: group.privacy,
|
||||
};
|
||||
response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
|
||||
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
|
||||
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
|
||||
res.respond(200, response);
|
||||
},
|
||||
};
|
||||
|
||||
// TODO everything here should be moved to a worker
|
||||
// actually even for a worker it's probably just too big and will kill mongo
|
||||
// Exported because it's used in v2 controller
|
||||
export async function _closeChal (challenge, broken = {}) {
|
||||
let winner = broken.winner;
|
||||
let brokenReason = broken.broken;
|
||||
|
||||
// Delete the challenge
|
||||
await Challenge.remove({_id: challenge._id}).exec();
|
||||
|
||||
// Refund the leader if the challenge is closed and the group not the tavern
|
||||
if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') {
|
||||
await User.update({_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();
|
||||
|
||||
// Award prize to winner and notify
|
||||
if (winner) {
|
||||
winner.achievements.challenges.push(challenge.name);
|
||||
winner.balance += challenge.prize / 4;
|
||||
let savedWinner = await winner.save();
|
||||
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
|
||||
txnEmail(savedWinner, 'won-challenge', [
|
||||
{name: 'CHALLENGE_NAME', content: challenge.name},
|
||||
]);
|
||||
}
|
||||
|
||||
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate
|
||||
}
|
||||
|
||||
// Run some operations in the background withouth blocking the thread
|
||||
let backgroundTasks = [
|
||||
// And it's tasks
|
||||
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({
|
||||
challenges: challenge._id,
|
||||
'tags._id': challenge._id,
|
||||
}, {
|
||||
$set: {'tags.$.challenge': false},
|
||||
$pull: {challenges: challenge._id},
|
||||
}, {multi: true}).exec(),
|
||||
// Break users' tasks
|
||||
Tasks.Task.update({
|
||||
'challenge.id': challenge._id,
|
||||
}, {
|
||||
$set: {
|
||||
'challenge.broken': brokenReason,
|
||||
'challenge.winner': winner && winner.profile.name,
|
||||
},
|
||||
}, {multi: true}).exec(),
|
||||
];
|
||||
|
||||
Q.allSettled(backgroundTasks); // TODO look if allSettled could be useful somewhere else
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/challenges/:challengeId Delete a challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName DeleteChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* challengeId {UUID} The _id for the challenge to delete
|
||||
* @apiParam {UUID} challengeId The _id for the challenge to delete
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
@@ -531,7 +473,7 @@ api.deleteChallenge = {
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'});
|
||||
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
@@ -542,8 +484,8 @@ api.deleteChallenge = {
|
||||
* @apiName SelectChallengeWinner
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* challengeId {UUID} The _id for the challenge to close with a winner
|
||||
* winnerId {UUID} The _id of the winning user
|
||||
* @apiParam {UUID} challengeId The _id for the challenge to close with a winner
|
||||
* @apiParam {UUID} winnerId The _id of the winning user
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
@@ -568,7 +510,7 @@ api.selectChallengeWinner = {
|
||||
if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId}));
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner});
|
||||
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import _ from 'lodash';
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
import { sendTxn } from '../../libs/api-v3/email';
|
||||
import nconf from 'nconf';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => {
|
||||
return { email, canSend: true };
|
||||
@@ -87,12 +88,14 @@ api.postChat = {
|
||||
|
||||
group.sendChat(req.body.message, user);
|
||||
|
||||
let toSave = [group.save()];
|
||||
|
||||
if (group.type === 'party') {
|
||||
user.party.lastMessageSeen = group.chat[0].id;
|
||||
user.save(); // TODO why this is non-blocking? must catch?
|
||||
toSave.push(user.save());
|
||||
}
|
||||
|
||||
let savedGroup = await group.save();
|
||||
let [savedGroup] = await Bluebird.all(toSave);
|
||||
if (chatUpdated) {
|
||||
res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat});
|
||||
} else {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import common from '../../../../common';
|
||||
import _ from 'lodash';
|
||||
import { langCodes } from '../../libs/api-v3/i18n';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import fsCallback from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../../libs/api-v3/logger';
|
||||
|
||||
// Transform fs methods that accept callbacks in ones that return promises
|
||||
const fs = {
|
||||
readFile: Q.denodeify(fsCallback.readFile),
|
||||
writeFile: Q.denodeify(fsCallback.writeFile),
|
||||
stat: Q.denodeify(fsCallback.stat),
|
||||
mkdir: Q.denodeify(fsCallback.mkdir),
|
||||
readFile: Bluebird.promisify(fsCallback.readFile, {context: fsCallback}),
|
||||
writeFile: Bluebird.promisify(fsCallback.writeFile, {context: fsCallback}),
|
||||
stat: Bluebird.promisify(fsCallback.stat, {context: fsCallback}),
|
||||
mkdir: Bluebird.promisify(fsCallback.mkdir, {context: fsCallback}),
|
||||
};
|
||||
|
||||
let api = {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
INVITES_LIMIT,
|
||||
@@ -55,7 +55,7 @@ api.createGroup = {
|
||||
user.party._id = group._id;
|
||||
}
|
||||
|
||||
let results = await Q.all([user.save(), group.save()]);
|
||||
let results = await Bluebird.all([user.save(), group.save()]);
|
||||
let savedGroup = results[1];
|
||||
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
@@ -79,7 +79,7 @@ api.createGroup = {
|
||||
* @apiName GetGroups
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, privateGuilds, publicGuilds, tavern
|
||||
* @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, guilds, privateGuilds, publicGuilds, tavern
|
||||
*
|
||||
* @apiSuccess {Array} data An array of the requested groups
|
||||
*/
|
||||
@@ -95,7 +95,6 @@ api.getGroups = {
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
// TODO validate types are acceptable? probably not necessary
|
||||
let types = req.query.type.split(',');
|
||||
let groupFields = basicGroupFields.concat('description memberCount balance');
|
||||
let sort = '-memberCount';
|
||||
@@ -275,7 +274,7 @@ api.joinGroup = {
|
||||
}
|
||||
}
|
||||
|
||||
await Q.all(promises);
|
||||
promises = await Bluebird.all(promises);
|
||||
|
||||
let response = Group.toJSONCleanChat(promises[0], user);
|
||||
let leader = await User.findById(response.leader).select(nameFields).exec();
|
||||
@@ -447,7 +446,7 @@ api.removeGroupMember = {
|
||||
group.quest.leader = undefined;
|
||||
} else if (group.quest && group.quest.members) {
|
||||
// remove member from quest
|
||||
group.quest.members[member._id] = undefined; // TODO remmeber to check these are mark modified everywhere
|
||||
group.quest.members[member._id] = undefined;
|
||||
group.markModified('quest.members');
|
||||
}
|
||||
|
||||
@@ -479,7 +478,7 @@ api.removeGroupMember = {
|
||||
let message = req.query.message;
|
||||
if (message) _sendMessageToRemoved(group, member, message);
|
||||
|
||||
await Q.all([
|
||||
await Bluebird.all([
|
||||
member.save(),
|
||||
group.save(),
|
||||
]);
|
||||
@@ -657,13 +656,13 @@ api.inviteToGroup = {
|
||||
|
||||
if (uuids) {
|
||||
let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res));
|
||||
let uuidResults = await Q.all(uuidInvites);
|
||||
let uuidResults = await Bluebird.all(uuidInvites);
|
||||
results.push(...uuidResults);
|
||||
}
|
||||
|
||||
if (emails) {
|
||||
let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res));
|
||||
let emailResults = await Q.all(emailInvites);
|
||||
let emailResults = await Bluebird.all(emailInvites);
|
||||
results.push(...emailResults);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
getUserInfo,
|
||||
sendTxn as sendTxnEmail,
|
||||
} from '../../libs/api-v3/email';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import sendPushNotification from '../../libs/api-v3/pushNotifications';
|
||||
|
||||
let api = {};
|
||||
|
||||
@@ -329,7 +330,7 @@ api.transferGems = {
|
||||
receiver.balance += amount;
|
||||
sender.balance -= amount;
|
||||
let promises = [receiver.save(), sender.save()];
|
||||
await Q.all(promises);
|
||||
await Bluebird.all(promises);
|
||||
|
||||
let message = res.t('privateMessageGiftIntro', {
|
||||
receiverName: receiver.profile.name,
|
||||
@@ -349,8 +350,7 @@ api.transferGems = {
|
||||
]);
|
||||
}
|
||||
|
||||
// TODO: Add push notifications
|
||||
// pushNotify.sendNotify(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername }));
|
||||
sendPushNotification(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername }));
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import analytics from '../../libs/api-v3/analyticsService';
|
||||
import {
|
||||
@@ -95,7 +95,7 @@ api.inviteToQuest = {
|
||||
await group.startQuest(user);
|
||||
}
|
||||
|
||||
let [savedGroup] = await Q.all([
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
@@ -170,7 +170,7 @@ api.acceptQuest = {
|
||||
await group.startQuest(user);
|
||||
}
|
||||
|
||||
let [savedGroup] = await Q.all([
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
@@ -229,7 +229,7 @@ api.rejectQuest = {
|
||||
await group.startQuest(user);
|
||||
}
|
||||
|
||||
let [savedGroup] = await Q.all([
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
@@ -282,7 +282,7 @@ api.forceStart = {
|
||||
|
||||
await group.startQuest(user);
|
||||
|
||||
let [savedGroup] = await Q.all([
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
@@ -336,7 +336,7 @@ api.cancelQuest = {
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
let [savedGroup] = await Q.all([
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
User.update(
|
||||
{'party._id': groupId},
|
||||
@@ -397,7 +397,7 @@ api.abortQuest = {
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]);
|
||||
let [groupSaved] = await Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]);
|
||||
|
||||
res.respond(200, groupSaved.quest);
|
||||
},
|
||||
@@ -440,7 +440,7 @@ api.leaveQuest = {
|
||||
user.party.quest = Group.cleanQuestProgress();
|
||||
user.markModified('party.quest');
|
||||
|
||||
let [savedGroup] = await Q.all([
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
BadRequest,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import common from '../../../../common';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import _ from 'lodash';
|
||||
import logger from '../../libs/api-v3/logger';
|
||||
|
||||
@@ -48,7 +48,7 @@ async function _createTasks (req, res, user, challenge) {
|
||||
|
||||
toSave.unshift((challenge || user).save());
|
||||
|
||||
let tasks = await Q.all(toSave);
|
||||
let tasks = await Bluebird.all(toSave);
|
||||
tasks.splice(0, 1); // Remove user or challenge
|
||||
return tasks;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ api.createUserTasks = {
|
||||
*/
|
||||
api.createChallengeTasks = {
|
||||
method: 'POST',
|
||||
url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural?
|
||||
url: '/tasks/challenge/:challengeId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
@@ -303,7 +303,6 @@ api.updateTask = {
|
||||
}
|
||||
|
||||
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
|
||||
// TODO regarding comment above, make sure other models with nested fields are using this trick too
|
||||
let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req);
|
||||
_.assign(task, Tasks.Task.sanitize(updatedTaskObj));
|
||||
// console.log(task.modifiedPaths(), task.toObject().repeat === tep)
|
||||
@@ -360,7 +359,7 @@ api.scoreTask = {
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route?
|
||||
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
@@ -389,12 +388,12 @@ api.scoreTask = {
|
||||
} else if (wasCompleted && !task.completed) {
|
||||
let hasTask = removeFromArray(user.tasksOrder.todos, task._id);
|
||||
if (!hasTask) {
|
||||
user.tasksOrder.todos.push(task._id); // TODO push at the top?
|
||||
user.tasksOrder.todos.push(task._id);
|
||||
} // If for some reason it hadn't been removed previously don't do anything
|
||||
}
|
||||
}
|
||||
|
||||
let results = await Q.all([
|
||||
let results = await Bluebird.all([
|
||||
user.save(),
|
||||
task.save(),
|
||||
]);
|
||||
@@ -790,7 +789,7 @@ api.unlinkTask = {
|
||||
} else { // remove
|
||||
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
|
||||
removeFromArray(user.tasksOrder[`${task.type}s`], taskId);
|
||||
await Q.all([user.save(), task.remove()]);
|
||||
await Bluebird.all([user.save(), task.remove()]);
|
||||
} else {
|
||||
await task.remove();
|
||||
}
|
||||
@@ -871,7 +870,7 @@ api.deleteTask = {
|
||||
|
||||
if (task.type !== 'todo' || !task.completed) {
|
||||
removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId);
|
||||
await Q.all([(challenge || user).save(), task.remove()]);
|
||||
await Bluebird.all([(challenge || user).save(), task.remove()]);
|
||||
} else {
|
||||
await task.remove();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
model as Group,
|
||||
} from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import _ from 'lodash';
|
||||
import * as firebase from '../../libs/api-v3/firebase';
|
||||
import * as passwordUtils from '../../libs/api-v3/password';
|
||||
@@ -218,7 +218,7 @@ api.deleteUser = {
|
||||
return group.leave(user, 'remove-all');
|
||||
});
|
||||
|
||||
await Q.all(groupLeavePromises);
|
||||
await Bluebird.all(groupLeavePromises);
|
||||
|
||||
await Tasks.Task.remove({
|
||||
userId: user._id,
|
||||
@@ -351,7 +351,7 @@ api.castSpell = {
|
||||
|
||||
spell.cast(user, task, req);
|
||||
if (user.isModified()) {
|
||||
await Q.all([
|
||||
await Bluebird.all([
|
||||
user.save(),
|
||||
task.save(),
|
||||
]);
|
||||
@@ -380,7 +380,7 @@ api.castSpell = {
|
||||
let isUserModified = user.isModified();
|
||||
|
||||
if (isUserModified) toSave.unshift(user.save());
|
||||
let saved = await Q.all(toSave);
|
||||
let saved = await Bluebird.all(toSave);
|
||||
|
||||
let response = {
|
||||
tasks: isUserModified ? _.rest(saved) : saved,
|
||||
@@ -400,7 +400,7 @@ api.castSpell = {
|
||||
}
|
||||
|
||||
spell.cast(user, partyMembers, req);
|
||||
await Q.all(partyMembers.map(m => m.save()));
|
||||
await Bluebird.all(partyMembers.map(m => m.save()));
|
||||
} else {
|
||||
if (!party && (!targetId || user._id === targetId)) {
|
||||
partyMembers = user;
|
||||
@@ -413,7 +413,7 @@ api.castSpell = {
|
||||
if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId}));
|
||||
spell.cast(user, partyMembers, req);
|
||||
if (user.isModified()) {
|
||||
await Q.all([
|
||||
await Bluebird.all([
|
||||
user.save(),
|
||||
partyMembers.save(),
|
||||
]);
|
||||
@@ -1105,7 +1105,7 @@ api.userRebirth = {
|
||||
|
||||
await user.save();
|
||||
|
||||
await Q.all(tasks.map(task => task.save()));
|
||||
await Bluebird.all(tasks.map(task => task.save()));
|
||||
|
||||
res.respond(200, ...rebirthRes);
|
||||
},
|
||||
@@ -1221,7 +1221,7 @@ api.userReroll = {
|
||||
let promises = tasks.map(task => task.save());
|
||||
promises.push(user.save());
|
||||
|
||||
await Q.all(promises);
|
||||
await Bluebird.all(promises);
|
||||
|
||||
res.respond(200, ...rerollRes);
|
||||
},
|
||||
@@ -1274,7 +1274,7 @@ api.userReset = {
|
||||
|
||||
let resetRes = common.ops.reset(user, tasks);
|
||||
|
||||
await Q.all([Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), user.save()]);
|
||||
await Bluebird.all([Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), user.save()]);
|
||||
|
||||
res.respond(200, ...resetRes);
|
||||
},
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
var amazonPayments = require('amazon-payments');
|
||||
var mongoose = require('mongoose');
|
||||
var moment = require('moment');
|
||||
var nconf = require('nconf');
|
||||
var async = require('async');
|
||||
var User = require('mongoose').model('User');
|
||||
var shared = require('../../../../common');
|
||||
var payments = require('./index');
|
||||
var cc = require('coupon-code');
|
||||
var isProd = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
var amzPayment = amazonPayments.connect({
|
||||
environment: amazonPayments.Environment[isProd ? 'Production' : 'Sandbox'],
|
||||
sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
|
||||
mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'),
|
||||
mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'),
|
||||
clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID')
|
||||
});
|
||||
|
||||
exports.verifyAccessToken = function(req, res, next){
|
||||
if(!req.body || !req.body['access_token']){
|
||||
return res.status(400).json({err: 'Access token not supplied.'});
|
||||
}
|
||||
|
||||
amzPayment.api.getTokenInfo(req.body['access_token'], function(err, tokenInfo){
|
||||
if(err) return res.status(400).json({err:err});
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
};
|
||||
|
||||
exports.createOrderReferenceId = function(req, res, next){
|
||||
if(!req.body || !req.body.billingAgreementId){
|
||||
return res.status(400).json({err: 'Billing Agreement Id not supplied.'});
|
||||
}
|
||||
|
||||
amzPayment.offAmazonPayments.createOrderReferenceForId({
|
||||
Id: req.body.billingAgreementId,
|
||||
IdType: 'BillingAgreement',
|
||||
ConfirmNow: false
|
||||
}, function(err, response){
|
||||
if(err) return next(err);
|
||||
if(!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId){
|
||||
return next(new Error('Missing attributes in Amazon response.'));
|
||||
}
|
||||
|
||||
res.json({
|
||||
orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
exports.checkout = function(req, res, next){
|
||||
if(!req.body || !req.body.orderReferenceId){
|
||||
return res.status(400).json({err: 'Billing Agreement Id not supplied.'});
|
||||
}
|
||||
|
||||
var gift = req.body.gift;
|
||||
var user = res.locals.user;
|
||||
var orderReferenceId = req.body.orderReferenceId;
|
||||
var amount = 5;
|
||||
|
||||
if(gift){
|
||||
if(gift.type === 'gems'){
|
||||
amount = gift.gems.amount/4;
|
||||
}else if(gift.type === 'subscription'){
|
||||
amount = shared.content.subscriptionBlocks[gift.subscription.key].price;
|
||||
}
|
||||
}
|
||||
|
||||
async.series({
|
||||
setOrderReferenceDetails: function(cb){
|
||||
amzPayment.offAmazonPayments.setOrderReferenceDetails({
|
||||
AmazonOrderReferenceId: orderReferenceId,
|
||||
OrderReferenceAttributes: {
|
||||
OrderTotal: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: amount
|
||||
},
|
||||
SellerNote: 'HabitRPG Payment',
|
||||
SellerOrderAttributes: {
|
||||
SellerOrderId: shared.uuid(),
|
||||
StoreName: 'HabitRPG'
|
||||
}
|
||||
}
|
||||
}, cb);
|
||||
},
|
||||
|
||||
confirmOrderReference: function(cb){
|
||||
amzPayment.offAmazonPayments.confirmOrderReference({
|
||||
AmazonOrderReferenceId: orderReferenceId
|
||||
}, cb);
|
||||
},
|
||||
|
||||
authorize: function(cb){
|
||||
amzPayment.offAmazonPayments.authorize({
|
||||
AmazonOrderReferenceId: orderReferenceId,
|
||||
AuthorizationReferenceId: shared.uuid().substring(0, 32),
|
||||
AuthorizationAmount: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: amount
|
||||
},
|
||||
SellerAuthorizationNote: 'HabitRPG Payment',
|
||||
TransactionTimeout: 0,
|
||||
CaptureNow: true
|
||||
}, function(err, res){
|
||||
if(err) return cb(err);
|
||||
|
||||
if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){
|
||||
return cb(new Error('The payment was not successfull.'));
|
||||
}
|
||||
|
||||
return cb();
|
||||
});
|
||||
},
|
||||
|
||||
closeOrderReference: function(cb){
|
||||
amzPayment.offAmazonPayments.closeOrderReference({
|
||||
AmazonOrderReferenceId: orderReferenceId
|
||||
}, cb);
|
||||
},
|
||||
|
||||
executePayment: function(cb){
|
||||
async.waterfall([
|
||||
function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); },
|
||||
function(member, cb2){
|
||||
var data = {user:user, paymentMethod:'Amazon Payments'};
|
||||
var method = 'buyGems';
|
||||
|
||||
if (gift){
|
||||
if (gift.type == 'subscription') method = 'createSubscription';
|
||||
gift.member = member;
|
||||
data.gift = gift;
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
|
||||
payments[method](data, cb2);
|
||||
}
|
||||
], cb);
|
||||
}
|
||||
}, function(err, results){
|
||||
if(err) return next(err);
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
exports.subscribe = function(req, res, next){
|
||||
if(!req.body || !req.body['billingAgreementId']){
|
||||
return res.status(400).json({err: 'Billing Agreement Id not supplied.'});
|
||||
}
|
||||
|
||||
var billingAgreementId = req.body.billingAgreementId;
|
||||
var sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
|
||||
var coupon = req.body.coupon;
|
||||
var user = res.locals.user;
|
||||
|
||||
if(!sub){
|
||||
return res.status(400).json({err: 'Subscription plan not found.'});
|
||||
}
|
||||
|
||||
async.series({
|
||||
applyDiscount: function(cb){
|
||||
if (!sub.discount) return cb();
|
||||
if (!coupon) return cb(new Error('Please provide a coupon code for this plan.'));
|
||||
mongoose.model('Coupon').findOne({_id:cc.validate(coupon), event:sub.key}, function(err, coupon){
|
||||
if(err) return cb(err);
|
||||
if(!coupon) return cb(new Error('Coupon code not found.'));
|
||||
cb();
|
||||
});
|
||||
},
|
||||
|
||||
setBillingAgreementDetails: function(cb){
|
||||
amzPayment.offAmazonPayments.setBillingAgreementDetails({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
BillingAgreementAttributes: {
|
||||
SellerNote: 'HabitRPG Subscription',
|
||||
SellerBillingAgreementAttributes: {
|
||||
SellerBillingAgreementId: shared.uuid(),
|
||||
StoreName: 'HabitRPG',
|
||||
CustomInformation: 'HabitRPG Subscription'
|
||||
}
|
||||
}
|
||||
}, cb);
|
||||
},
|
||||
|
||||
confirmBillingAgreement: function(cb){
|
||||
amzPayment.offAmazonPayments.confirmBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId
|
||||
}, cb);
|
||||
},
|
||||
|
||||
authorizeOnBillingAgreeement: function(cb){
|
||||
amzPayment.offAmazonPayments.authorizeOnBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
AuthorizationReferenceId: shared.uuid().substring(0, 32),
|
||||
AuthorizationAmount: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: sub.price
|
||||
},
|
||||
SellerAuthorizationNote: 'HabitRPG Subscription Payment',
|
||||
TransactionTimeout: 0,
|
||||
CaptureNow: true,
|
||||
SellerNote: 'HabitRPG Subscription Payment',
|
||||
SellerOrderAttributes: {
|
||||
SellerOrderId: shared.uuid(),
|
||||
StoreName: 'HabitRPG'
|
||||
}
|
||||
}, function(err, res){
|
||||
if(err) return cb(err);
|
||||
|
||||
if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){
|
||||
return cb(new Error('The payment was not successfull.'));
|
||||
}
|
||||
|
||||
return cb();
|
||||
});
|
||||
},
|
||||
|
||||
createSubscription: function(cb){
|
||||
payments.createSubscription({
|
||||
user: user,
|
||||
customerId: billingAgreementId,
|
||||
paymentMethod: 'Amazon Payments',
|
||||
sub: sub
|
||||
}, cb);
|
||||
}
|
||||
}, function(err, results){
|
||||
if(err) return next(err);
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
};
|
||||
|
||||
exports.subscribeCancel = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
if (!user.purchased.plan.customerId)
|
||||
return res.status(401).json({err: 'User does not have a plan subscription'});
|
||||
|
||||
var billingAgreementId = user.purchased.plan.customerId;
|
||||
|
||||
async.series({
|
||||
closeBillingAgreement: function(cb){
|
||||
amzPayment.offAmazonPayments.closeBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId
|
||||
}, cb);
|
||||
},
|
||||
|
||||
cancelSubscription: function(cb){
|
||||
var data = {
|
||||
user: user,
|
||||
// Date of next bill
|
||||
nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}),
|
||||
paymentMethod: 'Amazon Payments'
|
||||
};
|
||||
|
||||
payments.cancelSubscription(data, cb);
|
||||
}
|
||||
}, function(err, results){
|
||||
if (err) return next(err); // don't json this, let toString() handle errors
|
||||
|
||||
if(req.query.noRedirect){
|
||||
res.sendStatus(200);
|
||||
}else{
|
||||
res.redirect('/');
|
||||
}
|
||||
|
||||
user = null;
|
||||
});
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
var iap = require('in-app-purchase');
|
||||
var async = require('async');
|
||||
var payments = require('./index');
|
||||
var nconf = require('nconf');
|
||||
|
||||
var inAppPurchase = require('in-app-purchase');
|
||||
inAppPurchase.config({
|
||||
// this is the path to the directory containing iap-sanbox/iap-live files
|
||||
googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR')
|
||||
});
|
||||
|
||||
// Validation ERROR Codes
|
||||
var INVALID_PAYLOAD = 6778001;
|
||||
var CONNECTION_FAILED = 6778002;
|
||||
var PURCHASE_EXPIRED = 6778003;
|
||||
|
||||
exports.androidVerify = function(req, res, next) {
|
||||
var iapBody = req.body;
|
||||
var user = res.locals.user;
|
||||
|
||||
iap.setup(function (error) {
|
||||
if (error) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error'
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
google receipt must be provided as an object
|
||||
{
|
||||
"data": "{stringified data object}",
|
||||
"signature": "signature from google"
|
||||
}
|
||||
*/
|
||||
var testObj = {
|
||||
data: iapBody.transaction.receipt,
|
||||
signature: iapBody.transaction.signature
|
||||
};
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.GOOGLE, testObj, function (err, googleRes) {
|
||||
if (err) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString()
|
||||
}
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
if (iap.isValidated(googleRes)) {
|
||||
var resObj = {
|
||||
ok: true,
|
||||
data: googleRes
|
||||
};
|
||||
|
||||
payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25});
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
exports.iosVerify = function(req, res, next) {
|
||||
var iapBody = req.body;
|
||||
var user = res.locals.user;
|
||||
|
||||
iap.setup(function (error) {
|
||||
if (error) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error'
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
|
||||
}
|
||||
|
||||
//iap is ready
|
||||
iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) {
|
||||
if (err) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString()
|
||||
}
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
if (iap.isValidated(appleRes)) {
|
||||
var purchaseDataList = iap.getPurchaseData(appleRes);
|
||||
if (purchaseDataList.length > 0) {
|
||||
var correctReceipt = true;
|
||||
for (var index in purchaseDataList) {
|
||||
switch (purchaseDataList[index].productId) {
|
||||
case 'com.habitrpg.ios.Habitica.4gems':
|
||||
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.8gems':
|
||||
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.20gems':
|
||||
case 'com.habitrpg.ios.Habitica.21gems':
|
||||
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 5.25});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.42gems':
|
||||
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5});
|
||||
break;
|
||||
default:
|
||||
correctReceipt = false;
|
||||
}
|
||||
}
|
||||
if (correctReceipt) {
|
||||
var resObj = {
|
||||
ok: true,
|
||||
data: appleRes
|
||||
};
|
||||
// yay good!
|
||||
return res.json(resObj);
|
||||
}
|
||||
}
|
||||
//wrong receipt content
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: 'Incorrect receipt content'
|
||||
}
|
||||
};
|
||||
return res.json(resObj);
|
||||
}
|
||||
//invalid receipt
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: 'Invalid receipt'
|
||||
}
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,207 +0,0 @@
|
||||
var _ = require('lodash');
|
||||
var shared = require('../../../../common');
|
||||
var nconf = require('nconf');
|
||||
var utils = require('./../../libs/api-v2/utils');
|
||||
var moment = require('moment');
|
||||
var isProduction = nconf.get("NODE_ENV") === "production";
|
||||
var stripe = require('./stripe');
|
||||
var paypal = require('./paypal');
|
||||
var amazon = require('./amazon');
|
||||
var members = require('../api-v2/members')
|
||||
var async = require('async');
|
||||
var iap = require('./iap');
|
||||
var mongoose= require('mongoose');
|
||||
var cc = require('coupon-code');
|
||||
var pushNotify = require('./../api-v2/pushNotifications');
|
||||
|
||||
function revealMysteryItems(user) {
|
||||
_.each(shared.content.gear.flat, function(item) {
|
||||
if (
|
||||
item.klass === 'mystery' &&
|
||||
moment().isAfter(shared.content.mystery[item.mystery].start) &&
|
||||
moment().isBefore(shared.content.mystery[item.mystery].end) &&
|
||||
!user.items.gear.owned[item.key] &&
|
||||
!~user.purchased.plan.mysteryItems.indexOf(item.key)
|
||||
) {
|
||||
user.purchased.plan.mysteryItems.push(item.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
exports.createSubscription = function(data, cb) {
|
||||
var recipient = data.gift ? data.gift.member : data.user;
|
||||
//if (!recipient.purchased.plan) recipient.purchased.plan = {}; // TODO double-check, this should never be the case
|
||||
var p = recipient.purchased.plan;
|
||||
var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key];
|
||||
var months = +block.months;
|
||||
|
||||
if (data.gift) {
|
||||
if (p.customerId && !p.dateTerminated) { // User has active plan
|
||||
p.extraMonths += months;
|
||||
} else {
|
||||
p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate();
|
||||
if (!p.dateUpdated) p.dateUpdated = new Date();
|
||||
}
|
||||
if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId
|
||||
} else {
|
||||
_(p).merge({ // override with these values
|
||||
planId: block.key,
|
||||
customerId: data.customerId,
|
||||
dateUpdated: new Date(),
|
||||
gemsBought: 0,
|
||||
paymentMethod: data.paymentMethod,
|
||||
extraMonths: +p.extraMonths
|
||||
+ +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0),
|
||||
dateTerminated: null,
|
||||
// Specify a lastBillingDate just for Amazon Payments
|
||||
// Resetted every time the subscription restarts
|
||||
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined
|
||||
}).defaults({ // allow non-override if a plan was previously used
|
||||
dateCreated: new Date(),
|
||||
mysteryItems: []
|
||||
}).value();
|
||||
}
|
||||
|
||||
// Block sub perks
|
||||
var perks = Math.floor(months/3);
|
||||
if (perks) {
|
||||
p.consecutive.offset += months;
|
||||
p.consecutive.gemCapExtra += perks*5;
|
||||
if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25;
|
||||
p.consecutive.trinkets += perks;
|
||||
}
|
||||
revealMysteryItems(recipient);
|
||||
if(isProduction) {
|
||||
if (!data.gift) utils.txnEmail(data.user, 'subscription-begins');
|
||||
|
||||
var analyticsData = {
|
||||
uuid: data.user._id,
|
||||
itemPurchased: 'Subscription',
|
||||
sku: data.paymentMethod.toLowerCase() + '-subscription',
|
||||
purchaseType: 'subscribe',
|
||||
paymentMethod: data.paymentMethod,
|
||||
quantity: 1,
|
||||
gift: !!data.gift, // coerced into a boolean
|
||||
purchaseValue: block.price
|
||||
}
|
||||
utils.analytics.trackPurchase(analyticsData);
|
||||
}
|
||||
data.user.purchased.txnCount++;
|
||||
if (data.gift){
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
|
||||
var byUserName = utils.getUserInfo(data.user, ['name']).name;
|
||||
|
||||
if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){
|
||||
utils.txnEmail(data.gift.member, 'gifted-subscription', [
|
||||
{name: 'GIFTER', content: byUserName},
|
||||
{name: 'X_MONTHS_SUBSCRIPTION', content: months}
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself
|
||||
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), months + " months - by "+ byUserName);
|
||||
}
|
||||
}
|
||||
async.parallel([
|
||||
function(cb2){data.user.save(cb2)},
|
||||
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
|
||||
], cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets their subscription to be cancelled later
|
||||
*/
|
||||
exports.cancelSubscription = function(data, cb) {
|
||||
var p = data.user.purchased.plan,
|
||||
now = moment(),
|
||||
remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30;
|
||||
|
||||
p.dateTerminated =
|
||||
moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') )
|
||||
.add({days: remaining}) // end their subscription 1mo from their last payment
|
||||
.add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. TODO: moment can't add months in fractions...
|
||||
.toDate();
|
||||
p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
|
||||
|
||||
data.user.save(cb);
|
||||
utils.txnEmail(data.user, 'cancel-subscription');
|
||||
var analyticsData = {
|
||||
uuid: data.user._id,
|
||||
gaCategory: 'commerce',
|
||||
gaLabel: data.paymentMethod,
|
||||
paymentMethod: data.paymentMethod
|
||||
}
|
||||
utils.analytics.track('unsubscribe', analyticsData);
|
||||
}
|
||||
|
||||
exports.buyGems = function(data, cb) {
|
||||
var amt = data.amount || 5;
|
||||
amt = data.gift ? data.gift.gems.amount/4 : amt;
|
||||
(data.gift ? data.gift.member : data.user).balance += amt;
|
||||
data.user.purchased.txnCount++;
|
||||
if(isProduction) {
|
||||
if (!data.gift) utils.txnEmail(data.user, 'donation');
|
||||
|
||||
var analyticsData = {
|
||||
uuid: data.user._id,
|
||||
itemPurchased: 'Gems',
|
||||
sku: data.paymentMethod.toLowerCase() + '-checkout',
|
||||
purchaseType: 'checkout',
|
||||
paymentMethod: data.paymentMethod,
|
||||
quantity: 1,
|
||||
gift: !!data.gift, // coerced into a boolean
|
||||
purchaseValue: amt
|
||||
}
|
||||
utils.analytics.trackPurchase(analyticsData);
|
||||
}
|
||||
|
||||
if (data.gift){
|
||||
var byUsername = utils.getUserInfo(data.user, ['name']).name;
|
||||
var gemAmount = data.gift.gems.amount || 20;
|
||||
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
if(data.gift.member.preferences.emailNotifications.giftedGems !== false){
|
||||
utils.txnEmail(data.gift.member, 'gifted-gems', [
|
||||
{name: 'GIFTER', content: byUsername},
|
||||
{name: 'X_GEMS_GIFTED', content: gemAmount}
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself
|
||||
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), gemAmount + ' Gems - by '+byUsername);
|
||||
}
|
||||
}
|
||||
async.parallel([
|
||||
function(cb2){data.user.save(cb2)},
|
||||
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
|
||||
], cb);
|
||||
}
|
||||
|
||||
exports.validCoupon = function(req, res, next){
|
||||
mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){
|
||||
if (err) return next(err);
|
||||
if (!coupon) return res.status(401).json({err:"Invalid coupon code"});
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
}
|
||||
|
||||
exports.stripeCheckout = stripe.checkout;
|
||||
exports.stripeSubscribeCancel = stripe.subscribeCancel;
|
||||
exports.stripeSubscribeEdit = stripe.subscribeEdit;
|
||||
|
||||
exports.paypalSubscribe = paypal.createBillingAgreement;
|
||||
exports.paypalSubscribeSuccess = paypal.executeBillingAgreement;
|
||||
exports.paypalSubscribeCancel = paypal.cancelSubscription;
|
||||
exports.paypalCheckout = paypal.createPayment;
|
||||
exports.paypalCheckoutSuccess = paypal.executePayment;
|
||||
exports.paypalIPN = paypal.ipn;
|
||||
|
||||
exports.amazonVerifyAccessToken = amazon.verifyAccessToken;
|
||||
exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId;
|
||||
exports.amazonCheckout = amazon.checkout;
|
||||
exports.amazonSubscribe = amazon.subscribe;
|
||||
exports.amazonSubscribeCancel = amazon.subscribeCancel;
|
||||
|
||||
exports.iapAndroidVerify = iap.androidVerify;
|
||||
exports.iapIosVerify = iap.iosVerify;
|
||||
@@ -1,216 +0,0 @@
|
||||
var nconf = require('nconf');
|
||||
var moment = require('moment');
|
||||
var async = require('async');
|
||||
var _ = require('lodash');
|
||||
var url = require('url');
|
||||
var User = require('mongoose').model('User');
|
||||
var payments = require('./index');
|
||||
var logger = require('../../libs/api-v2/logging');
|
||||
var ipn = require('paypal-ipn');
|
||||
var paypal = require('paypal-rest-sdk');
|
||||
var shared = require('../../../../common');
|
||||
var mongoose = require('mongoose');
|
||||
var cc = require('coupon-code');
|
||||
|
||||
// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have
|
||||
// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created
|
||||
// there, get it's plan.id and store it in config.json
|
||||
_.each(shared.content.subscriptionBlocks, function(block){
|
||||
block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key);
|
||||
});
|
||||
|
||||
paypal.configure({
|
||||
'mode': nconf.get("PAYPAL:mode"), //sandbox or live
|
||||
'client_id': nconf.get("PAYPAL:client_id"),
|
||||
'client_secret': nconf.get("PAYPAL:client_secret")
|
||||
});
|
||||
|
||||
var parseErr = function(res, err){
|
||||
//var error = err.response ? err.response.message || err.response.details[0].issue : err;
|
||||
var error = JSON.stringify(err);
|
||||
return res.status(400).json({err:error});
|
||||
}
|
||||
|
||||
exports.createBillingAgreement = function(req,res,next){
|
||||
var sub = shared.content.subscriptionBlocks[req.query.sub];
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
if (!sub.discount) return cb(null, null);
|
||||
if (!req.query.coupon) return cb('Please provide a coupon code for this plan.');
|
||||
mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb);
|
||||
},
|
||||
function(coupon, cb){
|
||||
if (sub.discount && !coupon) return cb('Invalid coupon code.');
|
||||
var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)';
|
||||
var billingAgreementAttributes = {
|
||||
"name": billingPlanTitle,
|
||||
"description": billingPlanTitle,
|
||||
"start_date": moment().add({minutes:5}).format(),
|
||||
"plan": {
|
||||
"id": sub.paypalKey
|
||||
},
|
||||
"payer": {
|
||||
"payment_method": "paypal"
|
||||
}
|
||||
};
|
||||
paypal.billingAgreement.create(billingAgreementAttributes, cb);
|
||||
}
|
||||
], function(err, billingAgreement){
|
||||
if (err) return parseErr(res, err);
|
||||
// For approving subscription via Paypal, first redirect user to: approval_url
|
||||
req.session.paypalBlock = req.query.sub;
|
||||
var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href;
|
||||
res.redirect(approval_url);
|
||||
});
|
||||
}
|
||||
|
||||
exports.executeBillingAgreement = function(req,res,next){
|
||||
var block = shared.content.subscriptionBlocks[req.session.paypalBlock];
|
||||
delete req.session.paypalBlock;
|
||||
async.auto({
|
||||
exec: function (cb) {
|
||||
paypal.billingAgreement.execute(req.query.token, {}, cb);
|
||||
},
|
||||
get_user: function (cb) {
|
||||
User.findById(req.session.userId, cb);
|
||||
},
|
||||
create_sub: ['exec', 'get_user', function (cb, results) {
|
||||
payments.createSubscription({
|
||||
user: results.get_user,
|
||||
customerId: results.exec.id,
|
||||
paymentMethod: 'Paypal',
|
||||
sub: block
|
||||
}, cb);
|
||||
}]
|
||||
},function(err){
|
||||
if (err) return parseErr(res, err);
|
||||
res.redirect('/');
|
||||
})
|
||||
}
|
||||
|
||||
exports.createPayment = function(req, res) {
|
||||
// if we're gifting to a user, put it in session for the `execute()`
|
||||
req.session.gift = req.query.gift || undefined;
|
||||
var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
var price = !gift ? 5.00
|
||||
: gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2)
|
||||
: Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
|
||||
var description = !gift ? "HabitRPG Gems"
|
||||
: gift.type=='gems' ? "HabitRPG Gems (Gift)"
|
||||
: shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)";
|
||||
var create_payment = {
|
||||
"intent": "sale",
|
||||
"payer": {
|
||||
"payment_method": "paypal"
|
||||
},
|
||||
"redirect_urls": {
|
||||
"return_url": nconf.get('BASE_URL') + '/paypal/checkout/success',
|
||||
"cancel_url": nconf.get('BASE_URL')
|
||||
},
|
||||
"transactions": [{
|
||||
"item_list": {
|
||||
"items": [{
|
||||
"name": description,
|
||||
//"sku": "1",
|
||||
"price": price,
|
||||
"currency": "USD",
|
||||
"quantity": 1
|
||||
}]
|
||||
},
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"total": price
|
||||
},
|
||||
"description": description
|
||||
}]
|
||||
};
|
||||
paypal.payment.create(create_payment, function (err, payment) {
|
||||
if (err) return parseErr(res, err);
|
||||
var link = _.find(payment.links, {rel: 'approval_url'}).href;
|
||||
res.redirect(link);
|
||||
});
|
||||
}
|
||||
|
||||
exports.executePayment = function(req, res) {
|
||||
var paymentId = req.query.paymentId,
|
||||
PayerID = req.query.PayerID,
|
||||
gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
|
||||
delete req.session.gift;
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
paypal.payment.execute(paymentId, {payer_id: PayerID}, cb);
|
||||
},
|
||||
function(payment, cb){
|
||||
async.parallel([
|
||||
function(cb2){ User.findById(req.session.userId, cb2); },
|
||||
function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }
|
||||
], cb);
|
||||
},
|
||||
function(results, cb){
|
||||
if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction");
|
||||
var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift}
|
||||
var method = 'buyGems';
|
||||
if (gift) {
|
||||
gift.member = results[1];
|
||||
if (gift.type=='subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
payments[method](data, cb);
|
||||
}
|
||||
],function(err){
|
||||
if (err) return parseErr(res, err);
|
||||
res.redirect('/');
|
||||
})
|
||||
}
|
||||
|
||||
exports.cancelSubscription = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
if (!user.purchased.plan.customerId)
|
||||
return res.status(401).json({err: "User does not have a plan subscription"});
|
||||
async.auto({
|
||||
get_cus: function(cb){
|
||||
paypal.billingAgreement.get(user.purchased.plan.customerId, cb);
|
||||
},
|
||||
verify_cus: ['get_cus', function(cb, results){
|
||||
var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0";
|
||||
if (hasntBilledYet)
|
||||
return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits");
|
||||
cb();
|
||||
}],
|
||||
del_cus: ['verify_cus', function(cb, results){
|
||||
paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb);
|
||||
}],
|
||||
cancel_sub: ['get_cus', 'verify_cus', function(cb, results){
|
||||
var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date};
|
||||
payments.cancelSubscription(data, cb)
|
||||
}]
|
||||
}, function(err){
|
||||
if (err) return parseErr(res, err);
|
||||
res.redirect('/');
|
||||
user = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their
|
||||
* recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution
|
||||
*/
|
||||
exports.ipn = function(req, res, next) {
|
||||
console.log('IPN Called');
|
||||
res.sendStatus(200); // Must respond to PayPal IPN request with an empty 200 first
|
||||
ipn.verify(req.body, function(err, msg) {
|
||||
if (err) return logger.error(msg);
|
||||
switch (req.body.txn_type) {
|
||||
// TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead...
|
||||
case 'recurring_payment_profile_cancel':
|
||||
case 'subscr_cancel':
|
||||
User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){
|
||||
if (err) return logger.error(err);
|
||||
if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel)
|
||||
payments.cancelSubscription({user:user, paymentMethod: 'Paypal'});
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring
|
||||
// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this
|
||||
// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json),
|
||||
// and once for any time you need to edit the plan thereafter
|
||||
var path = require('path');
|
||||
var nconf = require('nconf');
|
||||
_ = require('lodash');
|
||||
nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json')));
|
||||
var paypal = require('paypal-rest-sdk');
|
||||
var blocks = require('../../../../common').content.subscriptionBlocks;
|
||||
var live = nconf.get('PAYPAL:mode')=='live';
|
||||
|
||||
var OP = 'create'; // list create update remove
|
||||
|
||||
paypal.configure({
|
||||
'mode': nconf.get("PAYPAL:mode"), //sandbox or live
|
||||
'client_id': nconf.get("PAYPAL:client_id"),
|
||||
'client_secret': nconf.get("PAYPAL:client_secret")
|
||||
});
|
||||
|
||||
// https://developer.paypal.com/docs/api/#billing-plans-and-agreements
|
||||
var billingPlanTitle ="Habitica Subscription";
|
||||
var billingPlanAttributes = {
|
||||
"name": billingPlanTitle,
|
||||
"description": billingPlanTitle,
|
||||
"type": "INFINITE",
|
||||
"merchant_preferences": {
|
||||
"auto_bill_amount": "yes",
|
||||
"cancel_url": live ? 'https://habitica.com' : 'http://localhost:3000',
|
||||
"return_url": (live ? 'https://habitica.com' : 'http://localhost:3000') + '/paypal/subscribe/success'
|
||||
},
|
||||
payment_definitions: [{
|
||||
"type": "REGULAR",
|
||||
"frequency": "MONTH",
|
||||
"cycles": "0"
|
||||
}]
|
||||
};
|
||||
_.each(blocks, function(block){
|
||||
block.definition = _.cloneDeep(billingPlanAttributes);
|
||||
_.merge(block.definition.payment_definitions[0], {
|
||||
"name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)',
|
||||
"frequency_interval": ""+block.months,
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": ""+block.price
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
switch(OP) {
|
||||
case "list":
|
||||
paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){
|
||||
console.log({err:err, plans:plans});
|
||||
});
|
||||
break;
|
||||
case "get":
|
||||
paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) {
|
||||
console.log({err:err, plan:plan});
|
||||
})
|
||||
break;
|
||||
case "update":
|
||||
var update = {
|
||||
"op": "replace",
|
||||
"path": "/merchant_preferences",
|
||||
"value": {
|
||||
"cancel_url": "https://habitica.com"
|
||||
}
|
||||
};
|
||||
paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) {
|
||||
console.log({err:err, plan:res});
|
||||
});
|
||||
break;
|
||||
case "create":
|
||||
paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){
|
||||
if (err) return console.log(err);
|
||||
if (plan.state == "ACTIVE")
|
||||
return console.log({err:err, plan:plan});
|
||||
var billingPlanUpdateAttributes = [{
|
||||
"op": "replace",
|
||||
"path": "/",
|
||||
"value": {
|
||||
"state": "ACTIVE"
|
||||
}
|
||||
}];
|
||||
// Activate the plan by changing status to Active
|
||||
paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){
|
||||
console.log({err:err, response:response, id:plan.id});
|
||||
});
|
||||
});
|
||||
break;
|
||||
case "remove": break;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
var nconf = require('nconf');
|
||||
var stripe = require('stripe')(nconf.get('STRIPE_API_KEY'));
|
||||
var async = require('async');
|
||||
var payments = require('./index');
|
||||
var User = require('mongoose').model('User');
|
||||
var shared = require('../../../../common');
|
||||
var mongoose = require('mongoose');
|
||||
var cc = require('coupon-code');
|
||||
|
||||
/*
|
||||
Setup Stripe response when posting payment
|
||||
*/
|
||||
exports.checkout = function(req, res, next) {
|
||||
var token = req.body.id;
|
||||
var user = res.locals.user;
|
||||
var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
|
||||
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
if (sub) {
|
||||
async.waterfall([
|
||||
function(cb2){
|
||||
if (!sub.discount) return cb2(null, null);
|
||||
if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.');
|
||||
mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2);
|
||||
},
|
||||
function(coupon, cb2){
|
||||
if (sub.discount && !coupon) return cb2('Invalid coupon code.');
|
||||
var customer = {
|
||||
email: req.body.email,
|
||||
metadata: {uuid: user._id},
|
||||
card: token,
|
||||
plan: sub.key
|
||||
};
|
||||
stripe.customers.create(customer, cb2);
|
||||
}
|
||||
], cb);
|
||||
} else {
|
||||
stripe.charges.create({
|
||||
amount: !gift ? '500' //"500" = $5
|
||||
: gift.type=='subscription' ? ''+shared.content.subscriptionBlocks[gift.subscription.key].price*100
|
||||
: ''+gift.gems.amount/4*100,
|
||||
currency: 'usd',
|
||||
card: token
|
||||
}, cb);
|
||||
}
|
||||
},
|
||||
function(response, cb) {
|
||||
if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb);
|
||||
async.waterfall([
|
||||
function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); },
|
||||
function(member, cb2){
|
||||
var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift};
|
||||
var method = 'buyGems';
|
||||
if (gift) {
|
||||
gift.member = member;
|
||||
if (gift.type=='subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
payments[method](data, cb2);
|
||||
}
|
||||
], cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
|
||||
res.sendStatus(200);
|
||||
user = token = null;
|
||||
});
|
||||
};
|
||||
|
||||
exports.subscribeCancel = function(req, res, next) {
|
||||
var user = res.locals.user;
|
||||
if (!user.purchased.plan.customerId)
|
||||
return res.status(401).json({err: 'User does not have a plan subscription'});
|
||||
|
||||
async.auto({
|
||||
get_cus: function(cb){
|
||||
stripe.customers.retrieve(user.purchased.plan.customerId, cb);
|
||||
},
|
||||
del_cus: ['get_cus', function(cb, results){
|
||||
stripe.customers.del(user.purchased.plan.customerId, cb);
|
||||
}],
|
||||
cancel_sub: ['get_cus', function(cb, results) {
|
||||
var data = {
|
||||
user: user,
|
||||
nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds
|
||||
paymentMethod: 'Stripe'
|
||||
};
|
||||
payments.cancelSubscription(data, cb);
|
||||
}]
|
||||
}, function(err, results){
|
||||
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
|
||||
res.redirect('/');
|
||||
user = null;
|
||||
});
|
||||
};
|
||||
|
||||
exports.subscribeEdit = function(req, res, next) {
|
||||
var token = req.body.id;
|
||||
var user = res.locals.user;
|
||||
var user_id = user.purchased.plan.customerId;
|
||||
var sub_id;
|
||||
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
stripe.customers.listSubscriptions(user_id, cb);
|
||||
},
|
||||
function(response, cb) {
|
||||
sub_id = response.data[0].id;
|
||||
console.warn(sub_id);
|
||||
console.warn([user_id, sub_id, { card: token }]);
|
||||
stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb);
|
||||
},
|
||||
function(response, cb) {
|
||||
user.save(cb);
|
||||
}
|
||||
], function(err, saved){
|
||||
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
|
||||
res.sendStatus(200);
|
||||
token = user = user_id = sub_id;
|
||||
});
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import Pageres from 'pageres';
|
||||
import AWS from 'aws-sdk';
|
||||
import nconf from 'nconf';
|
||||
import got from 'got';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import locals from '../../middlewares/api-v3/locals';
|
||||
|
||||
let S3 = new AWS.S3({
|
||||
@@ -171,7 +171,7 @@ api.exportUserAvatarHtml = {
|
||||
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
|
||||
res.render('avatar-static', {
|
||||
title: member.profile.name,
|
||||
env: _.defaults({member}, res.locals.habitrpg), // TODO review once static pages are done
|
||||
env: _.defaults({member}, res.locals.habitrpg),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -222,7 +222,16 @@ api.exportUserAvatarPng = {
|
||||
Body: stream,
|
||||
});
|
||||
|
||||
let s3res = await Q.ninvoke(s3upload, 'send');
|
||||
let s3res = await new Bluebird((resolve, reject) => {
|
||||
s3upload.send((err, s3uploadRes) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(s3uploadRes);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
res.redirect(s3res.Location);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../../libs/api-v3/errors';
|
||||
import amzLib from '../../../libs/api-v3/amazonPayments';
|
||||
import {
|
||||
authWithHeaders,
|
||||
authWithUrl,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
import shared from '../../../../../common';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
import moment from 'moment';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import { model as User } from '../../../models/user';
|
||||
import cc from 'coupon-code';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonVerifyAccessToken
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
**/
|
||||
api.verifyAccessToken = {
|
||||
method: 'POST',
|
||||
url: '/amazon/verifyAccessToken',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let accessToken = req.body.access_token;
|
||||
|
||||
if (!accessToken) throw new BadRequest('Missing req.body.access_token');
|
||||
|
||||
await amzLib.getTokenInfo(accessToken);
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonCreateOrderReferenceId
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {string} data.orderReferenceId The order reference id.
|
||||
**/
|
||||
api.createOrderReferenceId = {
|
||||
method: 'POST',
|
||||
url: '/amazon/createOrderReferenceId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let billingAgreementId = req.body.billingAgreementId;
|
||||
|
||||
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
|
||||
|
||||
let response = await amzLib.createOrderReferenceId({
|
||||
Id: billingAgreementId,
|
||||
IdType: 'BillingAgreement',
|
||||
ConfirmNow: false,
|
||||
});
|
||||
|
||||
res.respond(200, {
|
||||
orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/checkout Amazon Payments: checkout
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonCheckout
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {object} data Empty object
|
||||
**/
|
||||
api.checkout = {
|
||||
method: 'POST',
|
||||
url: '/amazon/checkout',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let gift = req.body.gift;
|
||||
let user = res.locals.user;
|
||||
let orderReferenceId = req.body.orderReferenceId;
|
||||
let amount = 5;
|
||||
|
||||
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'gems') {
|
||||
amount = gift.gems.amount / 4;
|
||||
} else if (gift.type === 'subscription') {
|
||||
amount = shared.content.subscriptionBlocks[gift.subscription.key].price;
|
||||
}
|
||||
}
|
||||
|
||||
await amzLib.setOrderReferenceDetails({
|
||||
AmazonOrderReferenceId: orderReferenceId,
|
||||
OrderReferenceAttributes: {
|
||||
OrderTotal: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: amount,
|
||||
},
|
||||
SellerNote: 'HabitRPG Payment',
|
||||
SellerOrderAttributes: {
|
||||
SellerOrderId: shared.uuid(),
|
||||
StoreName: 'HabitRPG',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId });
|
||||
|
||||
await amzLib.authorize({
|
||||
AmazonOrderReferenceId: orderReferenceId,
|
||||
AuthorizationReferenceId: shared.uuid().substring(0, 32),
|
||||
AuthorizationAmount: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: amount,
|
||||
},
|
||||
SellerAuthorizationNote: 'HabitRPG Payment',
|
||||
TransactionTimeout: 0,
|
||||
CaptureNow: true,
|
||||
});
|
||||
|
||||
await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId });
|
||||
|
||||
// execute payment
|
||||
let method = 'buyGems';
|
||||
let data = { user, paymentMethod: 'Amazon Payments' };
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
gift.member = await User.findById(gift ? gift.uuid : undefined);
|
||||
data.gift = gift;
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
|
||||
res.respond(200);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/subscribe Amazon Payments: subscribe
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonSubscribe
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {object} data Empty object
|
||||
**/
|
||||
api.subscribe = {
|
||||
method: 'POST',
|
||||
url: '/amazon/subscribe',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let billingAgreementId = req.body.billingAgreementId;
|
||||
let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
|
||||
let coupon = req.body.coupon;
|
||||
let user = res.locals.user;
|
||||
|
||||
if (!sub) throw new BadRequest(res.t('missingSubscriptionCode'));
|
||||
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
|
||||
|
||||
if (sub.discount) { // apply discount
|
||||
if (!coupon) throw new BadRequest(res.t('couponCodeRequired'));
|
||||
let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key});
|
||||
if (!result) throw new NotAuthorized(res.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
await amzLib.setBillingAgreementDetails({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
BillingAgreementAttributes: {
|
||||
SellerNote: 'HabitRPG Subscription',
|
||||
SellerBillingAgreementAttributes: {
|
||||
SellerBillingAgreementId: shared.uuid(),
|
||||
StoreName: 'HabitRPG',
|
||||
CustomInformation: 'HabitRPG Subscription',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await amzLib.confirmBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
});
|
||||
|
||||
await amzLib.authorizeOnBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
AuthorizationReferenceId: shared.uuid().substring(0, 32),
|
||||
AuthorizationAmount: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: sub.price,
|
||||
},
|
||||
SellerAuthorizationNote: 'HabitRPG Subscription Payment',
|
||||
TransactionTimeout: 0,
|
||||
CaptureNow: true,
|
||||
SellerNote: 'HabitRPG Subscription Payment',
|
||||
SellerOrderAttributes: {
|
||||
SellerOrderId: shared.uuid(),
|
||||
StoreName: 'HabitRPG',
|
||||
},
|
||||
});
|
||||
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: billingAgreementId,
|
||||
paymentMethod: 'Amazon Payments',
|
||||
sub,
|
||||
});
|
||||
|
||||
res.respond(200);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonSubscribe
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
url: '/amazon/subscribe/cancel',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let billingAgreementId = user.purchased.plan.customerId;
|
||||
|
||||
if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
|
||||
await amzLib.closeBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
});
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }),
|
||||
paymentMethod: 'Amazon Payments',
|
||||
});
|
||||
|
||||
if (req.query.noRedirect) {
|
||||
res.respond(200);
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,191 @@
|
||||
import iap from 'in-app-purchase';
|
||||
import nconf from 'nconf';
|
||||
import {
|
||||
authWithHeaders,
|
||||
authWithUrl,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
|
||||
// NOT PORTED TO v3
|
||||
|
||||
iap.config({
|
||||
// this is the path to the directory containing iap-sanbox/iap-live files
|
||||
googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'),
|
||||
});
|
||||
|
||||
// Validation ERROR Codes
|
||||
const INVALID_PAYLOAD = 6778001;
|
||||
// const CONNECTION_FAILED = 6778002;
|
||||
// const PURCHASE_EXPIRED = 6778003;
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /iap/android/verify Android Verify IAP
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName IapAndroidVerify
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.iapAndroidVerify = {
|
||||
method: 'POST',
|
||||
url: '/iap/android/verify',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let iapBody = req.body;
|
||||
|
||||
iap.setup((error) => {
|
||||
if (error) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error',
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
// google receipt must be provided as an object
|
||||
// {
|
||||
// "data": "{stringified data object}",
|
||||
// "signature": "signature from google"
|
||||
// }
|
||||
let testObj = {
|
||||
data: iapBody.transaction.receipt,
|
||||
signature: iapBody.transaction.signature,
|
||||
};
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.GOOGLE, testObj, (err, googleRes) => {
|
||||
if (err) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString(),
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
if (iap.isValidated(googleRes)) {
|
||||
let resObj = {
|
||||
ok: true,
|
||||
data: googleRes,
|
||||
};
|
||||
|
||||
payments.buyGems({
|
||||
user,
|
||||
paymentMethod: 'IAP GooglePlay',
|
||||
amount: 5.25,
|
||||
}).then(() => res.json(resObj));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /iap/ios/verify iOS Verify IAP
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName IapiOSVerify
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.iapiOSVerify = {
|
||||
method: 'POST',
|
||||
url: '/iap/android/verify',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let iapBody = req.body;
|
||||
let user = res.locals.user;
|
||||
|
||||
iap.setup(function iosSetupResult (error) {
|
||||
if (error) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error',
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => {
|
||||
if (err) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString(),
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
if (iap.isValidated(appleRes)) {
|
||||
let purchaseDataList = iap.getPurchaseData(appleRes);
|
||||
if (purchaseDataList.length > 0) {
|
||||
let correctReceipt = true;
|
||||
|
||||
for (let index of purchaseDataList) {
|
||||
switch (purchaseDataList[index].productId) {
|
||||
case 'com.habitrpg.ios.Habitica.4gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.8gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.20gems':
|
||||
case 'com.habitrpg.ios.Habitica.21gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.42gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5});
|
||||
break;
|
||||
default:
|
||||
correctReceipt = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (correctReceipt) {
|
||||
let resObj = {
|
||||
ok: true,
|
||||
data: appleRes,
|
||||
};
|
||||
|
||||
// yay good!
|
||||
return res.json(resObj);
|
||||
}
|
||||
}
|
||||
|
||||
// wrong receipt content
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: 'Incorrect receipt content',
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
// invalid receipt
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: 'Invalid receipt',
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,278 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import nconf from 'nconf';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
import ipn from 'paypal-ipn';
|
||||
import paypal from 'paypal-rest-sdk';
|
||||
import shared from '../../../../../common';
|
||||
import cc from 'coupon-code';
|
||||
import Bluebird from 'bluebird';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import { model as User } from '../../../models/user';
|
||||
import {
|
||||
authWithUrl,
|
||||
authWithSession,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../../libs/api-v3/errors';
|
||||
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have
|
||||
// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created
|
||||
// there, get it's plan.id and store it in config.json
|
||||
_.each(shared.content.subscriptionBlocks, (block) => {
|
||||
block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`);
|
||||
});
|
||||
|
||||
paypal.configure({
|
||||
mode: nconf.get('PAYPAL:mode'), // sandbox or live
|
||||
client_id: nconf.get('PAYPAL:client_id'),
|
||||
client_secret: nconf.get('PAYPAL:client_secret'),
|
||||
});
|
||||
|
||||
// TODO better handling of errors
|
||||
const paypalPaymentCreate = Bluebird.promisify(paypal.payment.create, {context: paypal.payment});
|
||||
const paypalPaymentExecute = Bluebird.promisify(paypal.payment.execute, {context: paypal.payment});
|
||||
const paypalBillingAgreementCreate = Bluebird.promisify(paypal.billingAgreement.create, {context: paypal.billingAgreement});
|
||||
const paypalBillingAgreementExecute = Bluebird.promisify(paypal.billingAgreement.execute, {context: paypal.billingAgreement});
|
||||
const paypalBillingAgreementGet = Bluebird.promisify(paypal.billingAgreement.get, {context: paypal.billingAgreement});
|
||||
const paypalBillingAgreementCancel = Bluebird.promisify(paypal.billingAgreement.cancel, {context: paypal.billingAgreement});
|
||||
|
||||
const ipnVerifyAsync = Bluebird.promisify(ipn.verify, {context: ipn});
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/checkout Paypal: checkout
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalCheckout
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.checkout = {
|
||||
method: 'GET',
|
||||
url: '/paypal/checkout',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
req.session.gift = req.query.gift;
|
||||
|
||||
let amount = 5.00;
|
||||
let description = 'HabitRPG gems';
|
||||
if (gift) {
|
||||
if (gift.type === 'gems') {
|
||||
amount = Number(gift.gems.amount / 4).toFixed(2);
|
||||
description = `${description} (Gift)`;
|
||||
} else {
|
||||
amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
|
||||
description = 'mo. HabitRPG Subscription (Gift)';
|
||||
}
|
||||
}
|
||||
|
||||
let createPayment = {
|
||||
intent: 'sale',
|
||||
payer: { payment_method: 'Paypal' },
|
||||
redirect_urls: {
|
||||
return_url: `${BASE_URL}/paypal/checkout/success`,
|
||||
cancel_url: `${BASE_URL}`,
|
||||
},
|
||||
transactions: [{
|
||||
item_list: {
|
||||
items: [{
|
||||
name: description,
|
||||
// sku: 1,
|
||||
price: amount,
|
||||
currency: 'USD',
|
||||
quality: 1,
|
||||
}],
|
||||
},
|
||||
amount: {
|
||||
currency: 'USD',
|
||||
total: amount,
|
||||
},
|
||||
description,
|
||||
}],
|
||||
};
|
||||
|
||||
let result = await paypalPaymentCreate(createPayment);
|
||||
let link = _.find(result.links, { rel: 'approval_url' }).href;
|
||||
res.redirect(link);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/checkout/success Paypal: checkout success
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalCheckoutSuccess
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.checkoutSuccess = {
|
||||
method: 'GET',
|
||||
url: '/paypal/checkout/success',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
let paymentId = req.query.paymentId;
|
||||
let customerId = req.query.payerID;
|
||||
|
||||
let method = 'buyGems';
|
||||
let data = {
|
||||
user: res.locals.user,
|
||||
customerId,
|
||||
paymentMethod: 'Paypal',
|
||||
};
|
||||
|
||||
let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
|
||||
delete req.session.gift;
|
||||
|
||||
if (gift) {
|
||||
gift.member = await User.findById(gift.uuid);
|
||||
if (gift.type === 'subscription') {
|
||||
method = 'createSubscription';
|
||||
}
|
||||
|
||||
data.paymentMethod = 'Gift';
|
||||
data.gift = gift;
|
||||
}
|
||||
|
||||
await paypalPaymentExecute(paymentId, { payer_id: customerId });
|
||||
await payments[method](data);
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/subscribe Paypal: subscribe
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalSubscribe
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribe = {
|
||||
method: 'GET',
|
||||
url: '/paypal/subscribe',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let sub = shared.content.subscriptionBlocks[req.query.sub];
|
||||
|
||||
if (sub.discount) {
|
||||
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
|
||||
let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
|
||||
if (!coupon) throw new NotAuthorized(res.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`;
|
||||
let billingAgreementAttributes = {
|
||||
name: billingPlanTitle,
|
||||
description: billingPlanTitle,
|
||||
start_date: moment().add({ minutes: 5 }).format(),
|
||||
plan: {
|
||||
id: sub.paypalKey,
|
||||
},
|
||||
payer: {
|
||||
payment_method: 'Paypal',
|
||||
},
|
||||
};
|
||||
let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes);
|
||||
|
||||
req.session.paypalBlock = req.query.sub;
|
||||
let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href;
|
||||
res.redirect(link);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/subscribe/success Paypal: subscribe success
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalSubscribeSuccess
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeSuccess = {
|
||||
method: 'GET',
|
||||
url: '/paypal/subscribe/success',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let block = shared.content.subscriptionBlocks[req.session.paypalBlock];
|
||||
delete req.session.paypalBlock;
|
||||
|
||||
let result = await paypalBillingAgreementExecute(req.query.token, {});
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: result.id,
|
||||
paymentMethod: 'Paypal',
|
||||
sub: block,
|
||||
});
|
||||
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/subscribe/cancel Paypal: subscribe cancel
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalSubscribeCancel
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
url: '/paypal/subscribe/cancel',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let customerId = user.purchased.plan.customerId;
|
||||
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
|
||||
let customer = await paypalBillingAgreementGet(customerId);
|
||||
|
||||
let nextBillingDate = customer.agreement_details.next_billing_date;
|
||||
if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet
|
||||
throw new BadRequest(res.t('planNotActive', { nextBillingDate }));
|
||||
}
|
||||
|
||||
await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') });
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
paymentMethod: 'Paypal',
|
||||
nextBill: nextBillingDate,
|
||||
});
|
||||
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
// General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their
|
||||
// recurring paypal payments in their paypal dashboard. TODO ? Remove this when we can move to webhooks or some other solution
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /paypal/ipn Paypal IPN
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalIpn
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.ipn = {
|
||||
method: 'POST',
|
||||
url: '/paypal/ipn',
|
||||
async handler (req, res) {
|
||||
res.sendStatus(200);
|
||||
|
||||
await ipnVerifyAsync(req.body);
|
||||
|
||||
if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') {
|
||||
let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id });
|
||||
if (user) {
|
||||
await payments.cancelSubscription({ user, paymentMethod: 'Paypal' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,169 @@
|
||||
import stripeModule from 'stripe';
|
||||
import shared from '../../../../../common';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../../libs/api-v3/errors';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
import nconf from 'nconf';
|
||||
import { model as User } from '../../../models/user';
|
||||
import cc from 'coupon-code';
|
||||
import {
|
||||
authWithHeaders,
|
||||
authWithUrl,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
|
||||
const stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/checkout Stripe checkout
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName StripeCheckout
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {string} id Body parameter - The token
|
||||
* @apiParam {string} email Body parameter - the customer email
|
||||
* @apiParam {string} gift Query parameter - stringified json object, gift
|
||||
* @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo
|
||||
* @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
**/
|
||||
api.checkout = {
|
||||
method: 'POST',
|
||||
url: '/stripe/checkout',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let token = req.body.id;
|
||||
let user = res.locals.user;
|
||||
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
|
||||
let coupon;
|
||||
let response;
|
||||
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
|
||||
if (sub) {
|
||||
if (sub.discount) {
|
||||
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
|
||||
coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
|
||||
if (!coupon) throw new BadRequest(res.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
response = await stripe.customers.create({
|
||||
email: req.body.email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
});
|
||||
} else {
|
||||
let amount = 500; // $5
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') {
|
||||
amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
|
||||
} else {
|
||||
amount = `${gift.gems.amount / 4 * 100}`;
|
||||
}
|
||||
}
|
||||
|
||||
response = await stripe.charges.create({
|
||||
amount,
|
||||
currency: 'usd',
|
||||
card: token,
|
||||
});
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: 'Stripe',
|
||||
sub,
|
||||
});
|
||||
} else {
|
||||
let method = 'buyGems';
|
||||
let data = {
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: 'Stripe',
|
||||
gift,
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
let member = await User.findById(gift.uuid);
|
||||
gift.member = member;
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
}
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/subscribe/edit Edit Stripe subscription
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName StripeSubscribeEdit
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {string} id Body parameter - The token
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
**/
|
||||
api.subscribeEdit = {
|
||||
method: 'POST',
|
||||
url: '/stripe/subscribe/edit',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let token = req.body.id;
|
||||
let user = res.locals.user;
|
||||
let customerId = user.purchased.plan.customerId;
|
||||
|
||||
if (!customerId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
|
||||
let subscriptions = await stripe.customers.listSubscriptions(customerId);
|
||||
let subscriptionId = subscriptions.data[0].id;
|
||||
await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token });
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /stripe/subscribe/cancel Cancel Stripe subscription
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName StripeSubscribeCancel
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
url: '/stripe/subscribe/cancel',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
|
||||
let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId);
|
||||
await stripe.customers.del(user.purchased.plan.customerId);
|
||||
await payments.cancelSubscriptoin({
|
||||
user,
|
||||
nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds
|
||||
paymentMethod: 'Stripe',
|
||||
});
|
||||
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -10,6 +10,8 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
// The BabelJS polyfill is needed in production too
|
||||
require('babel-polyfill');
|
||||
|
||||
global.Promise = require('bluebird');
|
||||
|
||||
// Only do the minimal amount of work before forking just in case of a dyno restart
|
||||
const cluster = require('cluster');
|
||||
const nconf = require('nconf');
|
||||
|
||||
@@ -22,9 +22,9 @@ if (nconf.get('LOGGLY:enabled')){
|
||||
|
||||
if (!logger) {
|
||||
logger = new (winston.Logger)({});
|
||||
logger.add(winston.transports.Console, {colorize:true}); // TODO remove
|
||||
|
||||
if (nconf.get('NODE_ENV') !== 'production') {
|
||||
logger.add(winston.transports.Console, {colorize:true});
|
||||
logger.add(winston.transports.File, {filename: 'habitrpg.log'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import amazonPayments from 'amazon-payments';
|
||||
import nconf from 'nconf';
|
||||
import common from '../../../../common';
|
||||
import Bluebird from 'bluebird';
|
||||
import {
|
||||
BadRequest,
|
||||
} from './errors';
|
||||
|
||||
// TODO better handling of errors
|
||||
|
||||
const i18n = common.i18n;
|
||||
const IS_PROD = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
let amzPayment = amazonPayments.connect({
|
||||
environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'],
|
||||
sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
|
||||
mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'),
|
||||
mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'),
|
||||
clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'),
|
||||
});
|
||||
|
||||
let getTokenInfo = Bluebird.promisify(amzPayment.api.getTokenInfo, {context: amzPayment.api});
|
||||
let createOrderReferenceId = Bluebird.promisify(amzPayment.offAmazonPayments.createOrderReferenceForId, {context: amzPayment.offAmazonPayments});
|
||||
let setOrderReferenceDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setOrderReferenceDetails, {context: amzPayment.offAmazonPayments});
|
||||
let confirmOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.confirmOrderReference, {context: amzPayment.offAmazonPayments});
|
||||
let closeOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.closeOrderReference, {context: amzPayment.offAmazonPayments});
|
||||
let setBillingAgreementDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setBillingAgreementDetails, {context: amzPayment.offAmazonPayments});
|
||||
let confirmBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.confirmBillingAgreement, {context: amzPayment.offAmazonPayments});
|
||||
let closeBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.closeBillingAgreement, {context: amzPayment.offAmazonPayments});
|
||||
|
||||
let authorizeOnBillingAgreement = (inputSet) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => {
|
||||
if (err) return reject(err);
|
||||
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
|
||||
return resolve(response);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let authorize = (inputSet) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => {
|
||||
if (err) return reject(err);
|
||||
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
|
||||
return resolve(response);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getTokenInfo,
|
||||
createOrderReferenceId,
|
||||
setOrderReferenceDetails,
|
||||
confirmOrderReference,
|
||||
closeOrderReference,
|
||||
confirmBillingAgreement,
|
||||
setBillingAgreementDetails,
|
||||
closeBillingAgreement,
|
||||
authorizeOnBillingAgreement,
|
||||
authorize,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable camelcase */
|
||||
import nconf from 'nconf';
|
||||
import Amplitude from 'amplitude';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import googleAnalytics from 'universal-analytics';
|
||||
import {
|
||||
each,
|
||||
@@ -109,7 +109,7 @@ let _sendDataToAmplitude = (eventType, data) => {
|
||||
|
||||
amplitudeData.event_type = eventType;
|
||||
|
||||
return Q.promise((resolve, reject) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
amplitude.track(amplitudeData)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
@@ -160,7 +160,7 @@ let _sendDataToGoogle = (eventType, data) => {
|
||||
eventData.ev = value;
|
||||
}
|
||||
|
||||
return Q.promise((resolve, reject) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
ga.event(eventData, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
@@ -174,7 +174,7 @@ let _sendPurchaseDataToAmplitude = (data) => {
|
||||
amplitudeData.event_type = 'purchase';
|
||||
amplitudeData.revenue = data.purchaseValue;
|
||||
|
||||
return Q.promise((resolve, reject) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
amplitude.track(amplitudeData)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
@@ -199,7 +199,7 @@ let _sendPurchaseDataToGoogle = (data) => {
|
||||
ev: price,
|
||||
};
|
||||
|
||||
return Q.promise((resolve) => {
|
||||
return new Bluebird((resolve) => {
|
||||
ga.event(eventData).send();
|
||||
|
||||
ga.transaction(data.uuid, price)
|
||||
@@ -211,14 +211,14 @@ let _sendPurchaseDataToGoogle = (data) => {
|
||||
};
|
||||
|
||||
function track (eventType, data) {
|
||||
return Q.all([
|
||||
return Bluebird.all([
|
||||
_sendDataToAmplitude(eventType, data),
|
||||
_sendDataToGoogle(eventType, data),
|
||||
]);
|
||||
}
|
||||
|
||||
function trackPurchase (data) {
|
||||
return Q.all([
|
||||
return Bluebird.all([
|
||||
_sendPurchaseDataToAmplitude(data),
|
||||
_sendPurchaseDataToGoogle(data),
|
||||
]);
|
||||
|
||||
@@ -182,7 +182,7 @@ export function cron (options = {}) {
|
||||
task.completed = false;
|
||||
|
||||
if (completed || scheduleMisses > 0) {
|
||||
task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed
|
||||
task.checklist.forEach(i => i.completed = false); // FIXME this should not happen for grey tasks unless they are completed
|
||||
}
|
||||
});
|
||||
|
||||
@@ -262,7 +262,7 @@ export function cron (options = {}) {
|
||||
gaLabel: 'Cron Count',
|
||||
gaValue: user.flags.cronCount,
|
||||
uuid: user._id,
|
||||
user, // TODO is it really necessary passing the whole user object?
|
||||
user,
|
||||
resting: user.preferences.sleep,
|
||||
cronCount: user.flags.cronCount,
|
||||
progressUp: _.min([_progress.up, 900]),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import csvStringify from 'csv-stringify';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
module.exports = (input) => {
|
||||
return Q.promise((resolve, reject) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
csvStringify(input, (err, output) => {
|
||||
if (err) return reject(err);
|
||||
return resolve(output);
|
||||
|
||||
@@ -5,12 +5,19 @@ import _ from 'lodash';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const IS_TEST = nconf.get('IS_TEST');
|
||||
const ENABLE_CONSOLE_LOGS_IN_PROD = nconf.get('ENABLE_CONSOLE_LOGS_IN_PROD') === 'true';
|
||||
|
||||
const logger = new winston.Logger();
|
||||
|
||||
if (IS_PROD) {
|
||||
// TODO production logging, use loggly and new relic too
|
||||
// log errors to console too
|
||||
|
||||
if (ENABLE_CONSOLE_LOGS_IN_PROD) {
|
||||
logger.add(winston.transports.Console, {
|
||||
colorize: true,
|
||||
prettyPrint: true,
|
||||
});
|
||||
}
|
||||
} else if (IS_TEST) {
|
||||
// Do not log anything when testing
|
||||
} else {
|
||||
@@ -48,10 +55,8 @@ let loggerInterface = {
|
||||
|
||||
// Logs unhandled promises errors
|
||||
// when no catch is attached to a promise a unhandledRejection event will be triggered
|
||||
process.on('unhandledRejection', function handlePromiseRejection (reason, promise) {
|
||||
loggerInterface.error(reason, {
|
||||
promise,
|
||||
});
|
||||
process.on('unhandledRejection', function handlePromiseRejection (reason) {
|
||||
loggerInterface.error(reason);
|
||||
});
|
||||
|
||||
module.exports = loggerInterface;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// We can't rely on babel here
|
||||
// because the file is requested directly by the new relic module
|
||||
|
||||
const nconf = require('nconf');
|
||||
|
||||
// IMPORTANT remember to set the location of this file using the NEW_RELIC_HOME env variable
|
||||
// more info here https://docs.newrelic.com/docs/agents/nodejs-agent/installation-configuration/nodejs-agent-configuration
|
||||
|
||||
exports.config = {
|
||||
app_name: nconf.get('NEW_RELIC_APP_NAME'), // eslint-disable-line camelcase
|
||||
license_key: nconf.get('NEW_RELIC_LICENSE_KEY'), // eslint-disable-line camelcase
|
||||
logging: {
|
||||
/**
|
||||
* Level at which to log. 'trace' is most useful to New Relic when diagnosing
|
||||
* issues with the agent, 'info' and higher will impose the least overhead on
|
||||
* production applications.
|
||||
*/
|
||||
level: 'info',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import _ from 'lodash' ;
|
||||
import analytics from './analyticsService';
|
||||
import {
|
||||
getUserInfo,
|
||||
sendTxn as txnEmail,
|
||||
} from './email';
|
||||
import members from '../../controllers/api-v3/members';
|
||||
import moment from 'moment';
|
||||
import nconf from 'nconf';
|
||||
import pushNotify from './pushNotifications';
|
||||
import shared from '../../../../common' ;
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
|
||||
let api = {};
|
||||
|
||||
function revealMysteryItems (user) {
|
||||
_.each(shared.content.gear.flat, function findMysteryItems (item) {
|
||||
if (
|
||||
item.klass === 'mystery' &&
|
||||
moment().isAfter(shared.content.mystery[item.mystery].start) &&
|
||||
moment().isBefore(shared.content.mystery[item.mystery].end) &&
|
||||
!user.items.gear.owned[item.key] &&
|
||||
user.purchased.plan.mysteryItems.indexOf(item.key) !== -1
|
||||
) {
|
||||
user.purchased.plan.mysteryItems.push(item.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
api.createSubscription = async function createSubscription (data) {
|
||||
let recipient = data.gift ? data.gift.member : data.user;
|
||||
let plan = recipient.purchased.plan;
|
||||
let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key];
|
||||
let months = Number(block.months);
|
||||
|
||||
if (data.gift) {
|
||||
if (plan.customerId && !plan.dateTerminated) { // User has active plan
|
||||
plan.extraMonths += months;
|
||||
} else {
|
||||
plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate();
|
||||
if (!plan.dateUpdated) plan.dateUpdated = new Date();
|
||||
}
|
||||
|
||||
if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId
|
||||
} else {
|
||||
_(plan).merge({ // override with these values
|
||||
planId: block.key,
|
||||
customerId: data.customerId,
|
||||
dateUpdated: new Date(),
|
||||
gemsBought: 0,
|
||||
paymentMethod: data.paymentMethod,
|
||||
extraMonths: Number(plan.extraMonths) +
|
||||
Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0),
|
||||
dateTerminated: null,
|
||||
// Specify a lastBillingDate just for Amazon Payments
|
||||
// Resetted every time the subscription restarts
|
||||
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined,
|
||||
}).defaults({ // allow non-override if a plan was previously used
|
||||
dateCreated: new Date(),
|
||||
mysteryItems: [],
|
||||
}).value();
|
||||
}
|
||||
|
||||
// Block sub perks
|
||||
let 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;
|
||||
plan.consecutive.trinkets += perks;
|
||||
}
|
||||
|
||||
revealMysteryItems(recipient);
|
||||
|
||||
if (IS_PROD) {
|
||||
if (!data.gift) txnEmail(data.user, 'subscription-begins');
|
||||
|
||||
analytics.trackPurchase({
|
||||
uuid: data.user._id,
|
||||
itemPurchased: 'Subscription',
|
||||
sku: `${data.paymentMethod.toLowerCase()}-subscription`,
|
||||
purchaseType: 'subscribe',
|
||||
paymentMethod: data.paymentMethod,
|
||||
quantity: 1,
|
||||
gift: Boolean(data.gift),
|
||||
purchaseValue: block.price,
|
||||
});
|
||||
}
|
||||
|
||||
data.user.purchased.txnCount++;
|
||||
|
||||
if (data.gift) {
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
|
||||
let byUserName = getUserInfo(data.user, ['name']).name;
|
||||
|
||||
if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) {
|
||||
txnEmail(data.gift.member, 'gifted-subscription', [
|
||||
{name: 'GIFTER', content: byUserName},
|
||||
{name: 'X_MONTHS_SUBSCRIPTION', content: months},
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
|
||||
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`);
|
||||
}
|
||||
}
|
||||
|
||||
await data.user.save();
|
||||
if (data.gift) await data.gift.member.save();
|
||||
};
|
||||
|
||||
// Sets their subscription to be cancelled later
|
||||
api.cancelSubscription = async function cancelSubscription (data) {
|
||||
let plan = data.user.purchased.plan;
|
||||
let now = moment();
|
||||
let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30;
|
||||
let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`;
|
||||
let nowStrFormat = 'MM/DD/YYYY';
|
||||
|
||||
plan.dateTerminated =
|
||||
moment(nowStr, nowStrFormat)
|
||||
.add({days: remaining}) // end their subscription 1mo from their last payment
|
||||
.add({days: Math.ceil(30 * plan.extraMonths)}) // plus any extra time (carry-over, gifted subscription, etc) they have.
|
||||
.toDate();
|
||||
plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
|
||||
|
||||
await data.user.save();
|
||||
|
||||
txnEmail(data.user, 'cancel-subscription');
|
||||
|
||||
analytics.track('unsubscribe', {
|
||||
uuid: data.user._id,
|
||||
gaCategory: 'commerce',
|
||||
gaLabel: data.paymentMethod,
|
||||
paymentMethod: data.paymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
api.buyGems = async function buyGems (data) {
|
||||
let amt = data.amount || 5;
|
||||
amt = data.gift ? data.gift.gems.amount / 4 : amt;
|
||||
|
||||
(data.gift ? data.gift.member : data.user).balance += amt;
|
||||
data.user.purchased.txnCount++;
|
||||
|
||||
if (IS_PROD) {
|
||||
if (!data.gift) txnEmail(data.user, 'donation');
|
||||
|
||||
analytics.trackPurchase({
|
||||
uuid: data.user._id,
|
||||
itemPurchased: 'Gems',
|
||||
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
|
||||
purchaseType: 'checkout',
|
||||
paymentMethod: data.paymentMethod,
|
||||
quantity: 1,
|
||||
gift: Boolean(data.gift),
|
||||
purchaseValue: amt,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.gift) {
|
||||
let byUsername = getUserInfo(data.user, ['name']).name;
|
||||
let gemAmount = data.gift.gems.amount || 20;
|
||||
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
if (data.gift.member.preferences.emailNotifications.giftedGems !== false) {
|
||||
txnEmail(data.gift.member, 'gifted-gems', [
|
||||
{name: 'GIFTER', content: byUsername},
|
||||
{name: 'X_GEMS_GIFTED', content: gemAmount},
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
|
||||
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`);
|
||||
}
|
||||
|
||||
await data.gift.member.save();
|
||||
}
|
||||
|
||||
await data.user.save();
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -30,12 +30,12 @@ Subscribers and challenges:
|
||||
- 1 value each year for the previous years
|
||||
*/
|
||||
export function preenHistory (history, isSubscribed, timezoneOffset) {
|
||||
// history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries TODO add to migration
|
||||
// history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries
|
||||
let now = timezoneOffset ? moment().zone(timezoneOffset) : moment();
|
||||
// Date after which to begin compressing data
|
||||
let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day');
|
||||
|
||||
// Keep uncompressed entries (modifies history)
|
||||
// Keep uncompressed entries (modifies history and returns removed items)
|
||||
let newHistory = _.remove(history, entry => {
|
||||
let date = moment(entry.date);
|
||||
return date.isSame(cutOff) || date.isAfter(cutOff);
|
||||
|
||||
@@ -26,10 +26,7 @@ if (gcm) {
|
||||
}
|
||||
|
||||
module.exports = function sendNotification (user, title, message, timeToLive = 15) {
|
||||
// TODO need investigation:
|
||||
// https://github.com/HabitRPG/habitrpg/issues/5252
|
||||
|
||||
if (!user) throw new Error('User is required.');
|
||||
if (!user) return;
|
||||
|
||||
_.each(user.pushDevices, pushDevice => {
|
||||
switch (pushDevice.type) {
|
||||
|
||||
@@ -2,12 +2,12 @@ import nconf from 'nconf';
|
||||
import logger from './logger';
|
||||
import autoinc from 'mongoose-id-autoinc';
|
||||
import mongoose from 'mongoose';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
|
||||
// Use Q promises instead of mpromise in mongoose
|
||||
mongoose.Promise = Q.Promise;
|
||||
mongoose.Promise = Bluebird;
|
||||
|
||||
let mongooseOptions = !IS_PROD ? {} : {
|
||||
replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } },
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
|
||||
// TODO how to translate the strings here since getUserLanguage hasn't run yet?
|
||||
// Strins won't be translated here because getUserLanguage has not run yet
|
||||
|
||||
// Authenticate a request through the x-api-user and x-api key header
|
||||
// If optional is true, don't error on missing authentication
|
||||
@@ -55,3 +55,21 @@ export function authWithSession (req, res, next) {
|
||||
})
|
||||
.catch(next);
|
||||
}
|
||||
|
||||
export function authWithUrl (req, res, next) {
|
||||
let userId = req.query._id;
|
||||
let apiToken = req.query.apiToken;
|
||||
|
||||
if (!userId || !apiToken) {
|
||||
throw new NotAuthorized(res.t('missingAuthParams'));
|
||||
}
|
||||
|
||||
User.findOne({ _id: userId, apiToken }).exec()
|
||||
.then((user) => {
|
||||
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
|
||||
|
||||
res.locals.user = user;
|
||||
next();
|
||||
})
|
||||
.catch(next);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import common from '../../../../common';
|
||||
import * as Tasks from '../../models/task';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import { model as Group } from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import { cron } from '../../libs/api-v3/cron';
|
||||
@@ -123,12 +123,12 @@ module.exports = function cronMiddleware (req, res, next) {
|
||||
$lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(),
|
||||
},
|
||||
'challenge.id': {$exists: false},
|
||||
}).exec(); // TODO wait before returning?
|
||||
}).exec();
|
||||
|
||||
let ranCron = user.isModified();
|
||||
let quest = common.content.quests[user.party.quest.key];
|
||||
|
||||
// if (ranCron) res.locals.wasModified = true; // TODO remove?
|
||||
// if (ranCron) res.locals.wasModified = true; // TODO remove after v2 is retired
|
||||
if (!ranCron) return next();
|
||||
|
||||
// Group.tavernBoss(user, progress);
|
||||
@@ -139,7 +139,7 @@ module.exports = function cronMiddleware (req, res, next) {
|
||||
toSave.push(task.save());
|
||||
});
|
||||
|
||||
Q.all(toSave)
|
||||
Bluebird.all(toSave)
|
||||
.then(saved => {
|
||||
user = res.locals.user = saved[0];
|
||||
if (!quest) return;
|
||||
|
||||
@@ -52,6 +52,12 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Stripe Card errors errors (can be safely shown to the users)
|
||||
// https://stripe.com/docs/api/node#errors
|
||||
if (err.type === 'StripeCardError') {
|
||||
responseErr = new BadRequest(err.message);
|
||||
}
|
||||
|
||||
if (!responseErr || responseErr.httpCode >= 500) {
|
||||
// Try to identify the error...
|
||||
// ...
|
||||
|
||||
@@ -52,7 +52,6 @@ module.exports = function attachMiddlewares (app, server) {
|
||||
app.use(forceSSL);
|
||||
app.use(forceHabitica);
|
||||
|
||||
// TODO if we don't manage to move the client off $resource the limit for bodyParser.json must be increased to 1mb from 100kb (default)
|
||||
app.use(bodyParser.urlencoded({
|
||||
extended: true, // Uses 'qs' library as old connect middleware
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// TODO tests?
|
||||
// TODO test this middleware
|
||||
module.exports = function setupBodyMiddleware (req, res, next) {
|
||||
req.body = req.body || {};
|
||||
next();
|
||||
|
||||
@@ -19,8 +19,6 @@ v2app.use(responseHandler);
|
||||
|
||||
// Custom Directives
|
||||
v2app.use('/', require('../../routes/api-v2/auth'));
|
||||
v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3
|
||||
v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3
|
||||
|
||||
require('../../routes/api-v2/swagger')(swagger, v2app);
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@ var limiter = require('connect-ratelimit');
|
||||
var IS_PROD = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
// TODO since Habitica runs on many different servers this module is pretty useless
|
||||
// as it will only block requests that go to the same server
|
||||
// as it will only block requests that go to the same server but anyway we should probably have a rate limiter in place
|
||||
|
||||
module.exports = function(app) {
|
||||
// TODO review later
|
||||
// disable the rate limiter middleware
|
||||
if (/*!IS_PROD || */true) return;
|
||||
app.use(limiter({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// TODO do we need this module?
|
||||
// TODO do we need this module anymore in v3? No
|
||||
|
||||
module.exports.siteVersion = 1;
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import mongoose from 'mongoose';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import validator from 'validator';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import _ from 'lodash';
|
||||
import * as Tasks from './task';
|
||||
import { model as User } from './user';
|
||||
import {
|
||||
model as Group,
|
||||
TAVERN_ID,
|
||||
} from './group';
|
||||
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
|
||||
import shared from '../../../common';
|
||||
import { sendTxn as txnEmail } from '../libs/api-v3/email';
|
||||
import sendPushNotification from '../libs/api-v3/pushNotifications';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
|
||||
@@ -98,7 +105,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) {
|
||||
});
|
||||
}
|
||||
|
||||
let [challengeTasks, userTasks] = await Q.all([
|
||||
let [challengeTasks, userTasks] = await Bluebird.all([
|
||||
// Find original challenge tasks
|
||||
Tasks.Task.find({
|
||||
userId: {$exists: false},
|
||||
@@ -123,7 +130,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) {
|
||||
user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id);
|
||||
} else {
|
||||
_.merge(matchingTask, _syncableAttrs(chalTask));
|
||||
// Make sure the task is in user.tasksOrder TODO necessary?
|
||||
// Make sure the task is in user.tasksOrder
|
||||
let orderList = user.tasksOrder[`${chalTask.type}s`];
|
||||
if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
|
||||
}
|
||||
@@ -142,7 +149,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) {
|
||||
});
|
||||
|
||||
toSave.push(user.save());
|
||||
return Q.all(toSave);
|
||||
return Bluebird.all(toSave);
|
||||
};
|
||||
|
||||
async function _fetchMembersIds (challengeId) {
|
||||
@@ -155,7 +162,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) {
|
||||
let membersIds = await _fetchMembersIds(challenge._id);
|
||||
|
||||
// Sync each user sequentially
|
||||
// TODO are we sure it's the best solution?
|
||||
// TODO are we sure it's the best solution? Use cwait
|
||||
// use bulk ops? http://stackoverflow.com/questions/16726330/mongoose-mongodb-batch-insert
|
||||
for (let memberId of membersIds) {
|
||||
let updateTasksOrderQ = {$push: {}};
|
||||
@@ -182,7 +189,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) {
|
||||
|
||||
// Update the user
|
||||
toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec());
|
||||
await Q.all(toSave); // eslint-disable-line babel/no-await-in-loop
|
||||
await Bluebird.all(toSave); // eslint-disable-line babel/no-await-in-loop
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,10 +254,69 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) {
|
||||
});
|
||||
user.markModified('tasksOrder');
|
||||
taskPromises.push(user.save());
|
||||
return Q.all(taskPromises);
|
||||
return Bluebird.all(taskPromises);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO everything here should be moved to a worker
|
||||
// actually even for a worker it's probably just too big and will kill mongo
|
||||
schema.methods.closeChal = async function closeChal (broken = {}) {
|
||||
let challenge = this;
|
||||
|
||||
let winner = broken.winner;
|
||||
let brokenReason = broken.broken;
|
||||
|
||||
// Delete the challenge
|
||||
await this.model('Challenge').remove({_id: challenge._id}).exec();
|
||||
|
||||
// Refund the leader if the challenge is closed and the group not the tavern
|
||||
if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') {
|
||||
await User.update({_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();
|
||||
|
||||
// Award prize to winner and notify
|
||||
if (winner) {
|
||||
winner.achievements.challenges.push(challenge.name);
|
||||
winner.balance += challenge.prize / 4;
|
||||
let savedWinner = await winner.save();
|
||||
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
|
||||
txnEmail(savedWinner, 'won-challenge', [
|
||||
{name: 'CHALLENGE_NAME', content: challenge.name},
|
||||
]);
|
||||
}
|
||||
|
||||
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name);
|
||||
}
|
||||
|
||||
// Run some operations in the background withouth blocking the thread
|
||||
let backgroundTasks = [
|
||||
// And it's tasks
|
||||
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({
|
||||
challenges: challenge._id,
|
||||
'tags._id': challenge._id,
|
||||
}, {
|
||||
$set: {'tags.$.challenge': false},
|
||||
$pull: {challenges: challenge._id},
|
||||
}, {multi: true}).exec(),
|
||||
// Break users' tasks
|
||||
Tasks.Task.update({
|
||||
'challenge.id': challenge._id,
|
||||
}, {
|
||||
$set: {
|
||||
'challenge.broken': brokenReason,
|
||||
'challenge.winner': winner && winner.profile.name,
|
||||
},
|
||||
}, {multi: true}).exec(),
|
||||
];
|
||||
|
||||
Bluebird.all(backgroundTasks);
|
||||
};
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model)
|
||||
// These will be removed once API v2 is discontinued
|
||||
|
||||
@@ -340,7 +406,7 @@ schema.methods.getTransformedData = function getTransformedData (options) {
|
||||
let membersQuery = User.find(queryMembers).select(selectDataMembers);
|
||||
if (options.limitPopulation) membersQuery.limit(15);
|
||||
|
||||
Q.all([
|
||||
Bluebird.all([
|
||||
membersQuery.exec(),
|
||||
self.getTasks(),
|
||||
])
|
||||
|
||||
+27
-16
@@ -12,7 +12,7 @@ import { InternalServerError } from '../libs/api-v3/errors';
|
||||
import * as firebase from '../libs/api-v2/firebase';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import { sendTxn as sendTxnEmail } from '../libs/api-v3/email';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import nconf from 'nconf';
|
||||
import sendPushNotification from '../libs/api-v3/pushNotifications';
|
||||
|
||||
@@ -149,25 +149,35 @@ schema.statics.getGroups = async function getGroups (options = {}) {
|
||||
queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader}));
|
||||
break;
|
||||
}
|
||||
case 'guilds': {
|
||||
let userGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
_id: {$in: user.guilds},
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) userGuildsQuery.populate('leader', nameFields);
|
||||
userGuildsQuery.sort(sort).exec();
|
||||
queries.push(userGuildsQuery);
|
||||
break;
|
||||
}
|
||||
case 'privateGuilds': {
|
||||
let privateGroupQuery = this.find({
|
||||
let privateGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
privacy: 'private',
|
||||
_id: {$in: user.guilds},
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) privateGroupQuery.populate('leader', nameFields);
|
||||
privateGroupQuery.sort(sort).exec();
|
||||
queries.push(privateGroupQuery);
|
||||
if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields);
|
||||
privateGuildsQuery.sort(sort).exec();
|
||||
queries.push(privateGuildsQuery);
|
||||
break;
|
||||
}
|
||||
case 'publicGuilds': {
|
||||
let publicGroupQuery = this.find({
|
||||
let publicGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) publicGroupQuery.populate('leader', nameFields);
|
||||
publicGroupQuery.sort(sort).exec();
|
||||
queries.push(publicGroupQuery); // TODO use lean?
|
||||
if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields);
|
||||
publicGuildsQuery.sort(sort).exec();
|
||||
queries.push(publicGuildsQuery); // TODO use lean?
|
||||
break;
|
||||
}
|
||||
case 'tavern': {
|
||||
@@ -179,7 +189,7 @@ schema.statics.getGroups = async function getGroups (options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
let groupsArray = _.reduce(await Q.all(queries), (previousValue, currentValue) => {
|
||||
let groupsArray = _.reduce(await Bluebird.all(queries), (previousValue, currentValue) => {
|
||||
if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array
|
||||
return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue
|
||||
}, []);
|
||||
@@ -218,7 +228,7 @@ schema.methods.removeGroupInvitations = async function removeGroupInvitations ()
|
||||
return user.save();
|
||||
});
|
||||
|
||||
return Q.all(userUpdates);
|
||||
return Bluebird.all(userUpdates);
|
||||
};
|
||||
|
||||
// Return true if user is a member of the group
|
||||
@@ -410,7 +420,7 @@ schema.methods.finishQuest = function finishQuest (quest) {
|
||||
let updates = {$inc: {}, $set: {}};
|
||||
|
||||
updates.$inc[`achievements.quests.${questK}`] = 1;
|
||||
updates.$inc['stats.gp'] = Number(quest.drop.gp); // TODO are this castings necessary?
|
||||
updates.$inc['stats.gp'] = Number(quest.drop.gp);
|
||||
updates.$inc['stats.exp'] = Number(quest.drop.exp);
|
||||
updates.$inc._v = 1;
|
||||
|
||||
@@ -520,7 +530,8 @@ schema.statics.bossQuest = async function bossQuest (user, progress) {
|
||||
}, {multi: true}).exec();
|
||||
// Apply changes the currently cronning user locally so we don't have to reload it to get the updated state
|
||||
// TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167
|
||||
// must be notModified or otherwise could overwrite future changes
|
||||
// must be notModified or otherwise could overwrite future changes: if the user is saved it'll save
|
||||
// the modified user.stats.hp but that must not happen as the hp value has already been updated by the User.update above
|
||||
// if (down) user.stats.hp += down;
|
||||
|
||||
// Boss slain, finish quest
|
||||
@@ -627,7 +638,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
|
||||
let challengesToRemoveUserFrom = challenges.map(chal => {
|
||||
return chal.unlinkTasks(user, keep);
|
||||
});
|
||||
await Q.all(challengesToRemoveUserFrom);
|
||||
await Bluebird.all(challengesToRemoveUserFrom);
|
||||
|
||||
let promises = [];
|
||||
|
||||
@@ -659,7 +670,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
|
||||
|
||||
firebase.removeUserFromGroup(group._id, user._id);
|
||||
|
||||
return Q.all(promises);
|
||||
return Bluebird.all(promises);
|
||||
};
|
||||
|
||||
// API v2 compatibility methods
|
||||
@@ -703,7 +714,7 @@ schema.methods.getTransformedData = function getTransformedData (options) {
|
||||
let membersQuery = User.find(queryMembers).select(selectDataMembers);
|
||||
if (options.limitPopulation) membersQuery.limit(15);
|
||||
|
||||
Q.all([
|
||||
Bluebird.all([
|
||||
membersQuery.exec(),
|
||||
User.find(queryInvites).select(populateInvites).exec(),
|
||||
Challenge.find({group: obj._id}).select(populateMembers).exec(),
|
||||
|
||||
@@ -39,7 +39,7 @@ export let TaskSchema = new Schema({
|
||||
|
||||
challenge: {
|
||||
id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task
|
||||
taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task TODO unique index?
|
||||
taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task
|
||||
broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']},
|
||||
winner: String, // user.profile.name of the winner
|
||||
},
|
||||
@@ -149,7 +149,7 @@ export let Task = mongoose.model('Task', TaskSchema);
|
||||
|
||||
// habits and dailies shared fields
|
||||
let habitDailySchema = () => {
|
||||
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems TODO revisit
|
||||
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems
|
||||
};
|
||||
|
||||
// dailys and todos shared fields
|
||||
@@ -197,7 +197,7 @@ export let daily = Task.discriminator('daily', DailySchema);
|
||||
|
||||
export let TodoSchema = new Schema(_.defaults({
|
||||
dateCompleted: Date,
|
||||
// TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date
|
||||
// TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript
|
||||
date: String, // due date for todos
|
||||
}, dailyTodoSchema()), subDiscriminatorOptions);
|
||||
export let todo = Task.discriminator('todo', TodoSchema);
|
||||
|
||||
@@ -4,7 +4,7 @@ import _ from 'lodash';
|
||||
import validator from 'validator';
|
||||
import moment from 'moment';
|
||||
import * as Tasks from './task';
|
||||
import Q from 'q';
|
||||
import Bluebird from 'bluebird';
|
||||
import { schema as TagSchema } from './tag';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import {
|
||||
@@ -30,13 +30,10 @@ export let schema = new Schema({
|
||||
local: {
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
validate: [validator.isEmail, shared.i18n.t('invalidEmail')], // TODO translate error messages here, use preferences.language?
|
||||
validate: [validator.isEmail, shared.i18n.t('invalidEmail')],
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
trim: true,
|
||||
},
|
||||
// Store a lowercase version of username to check for duplicates
|
||||
lowerCaseUsername: String,
|
||||
@@ -239,7 +236,7 @@ export let schema = new Schema({
|
||||
gear: {
|
||||
owned: _.transform(shared.content.gear.flat, (m, v) => {
|
||||
m[v.key] = {type: Boolean};
|
||||
if (v.key.match(/[armor|head|shield]_warrior_0/)) {
|
||||
if (v.key.match(/[armor|head|shield]_warrior_0/) || v.gearSet === 'glasses') {
|
||||
m[v.key].default = true;
|
||||
}
|
||||
}),
|
||||
@@ -529,14 +526,14 @@ export let schema = new Schema({
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
// TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated
|
||||
// TODO this is a only used for creating an user, on update we use a whitelist
|
||||
// This is not really used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...)
|
||||
noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password',
|
||||
'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest',
|
||||
'invitations', 'balance', 'backer', 'contributor'],
|
||||
private: ['auth.local.hashed_password', 'auth.local.salt'],
|
||||
toJSONTransform: function userToJSON (plainObj, originalDoc) {
|
||||
// plainObj.filters = {}; TODO Not saved
|
||||
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs TODO how to test?
|
||||
// plainObj.filters = {}; TODO Not saved, remove?
|
||||
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs
|
||||
|
||||
return plainObj;
|
||||
},
|
||||
@@ -593,10 +590,10 @@ function _populateDefaultTasks (user, taskTypes) {
|
||||
return newTask.save();
|
||||
});
|
||||
|
||||
tasksToCreate.push(...tasksOfType); // TODO find better way since this creates each task individually
|
||||
tasksToCreate.push(...tasksOfType);
|
||||
});
|
||||
|
||||
return Q.all(tasksToCreate)
|
||||
return Bluebird.all(tasksToCreate)
|
||||
.then((tasksCreated) => {
|
||||
_.each(tasksCreated, (task) => {
|
||||
user.tasksOrder[`${task.type}s`].push(task._id);
|
||||
@@ -723,7 +720,7 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, m
|
||||
sender.markModified('inbox.messages');
|
||||
|
||||
let promises = [userToReceiveMessage.save(), sender.save()];
|
||||
await Q.all(promises);
|
||||
await Bluebird.all(promises);
|
||||
};
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model)
|
||||
|
||||
@@ -3,6 +3,9 @@ import logger from './libs/api-v3/logger';
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import attachMiddlewares from './middlewares/api-v3/index';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
global.Promise = Bluebird;
|
||||
|
||||
const server = http.createServer();
|
||||
const app = express();
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
.container-fluid
|
||||
.stable.row: .col-xs-12
|
||||
div(class="#{env.worldDmg.seasonalShop ? 'seasonalshop_broken' : 'seasonalshop_open'}").pull-left-sm.col-centered
|
||||
div(class="#{env.worldDmg.seasonalShop ? 'seasonalshop_broken' : 'seasonalshop_closed'}").pull-left-sm.col-centered
|
||||
.popover.static-popover.fade.right.in.pull-left-sm.col-centered
|
||||
.arrow.hidden-xs
|
||||
h3.popover-title!=env.t('seasonalShopTitle', {linkStart:"<a href='http://blog.habitrpg.com/who' target='_blank'>", linkEnd: "</a>"})
|
||||
h3.popover-title!=env.t('seasonalShopClosedTitle', {linkStart:"<a href='http://blog.habitrpg.com/who' target='_blank'>", linkEnd: "</a>"})
|
||||
.popover-content
|
||||
p(ng-if='!env.worldDmg.seasonalShop')!=env.t('seasonalShopText')
|
||||
p(ng-if='env.worldDmg.seasonalShop')!=env.t('seasonalShopText')
|
||||
p(ng-if='!env.worldDmg.seasonalShop')!=env.t('seasonalShopClosedText')
|
||||
p(ng-if='env.worldDmg.seasonalShop')!=env.t('seasonalShopClosedText')
|
||||
|
||||
.well(ng-if='User.user.achievements.rebirths > 0')=env.t('seasonalShopRebirth')
|
||||
// .well(ng-if='User.user.achievements.rebirths > 0')=env.t('seasonalShopRebirth')
|
||||
|
||||
li.customize-menu.inventory-gear
|
||||
// li.customize-menu.inventory-gear
|
||||
menu.pets-menu(label=env.t('quests'))
|
||||
div(ng-repeat='quest in ::getSeasonalShopQuests()')
|
||||
button.customize-option(ng-class='(quest.previous && !user.achievements.quests[quest.previous]) ? "inventory_quest_scroll_locked inventory_quest_scroll_{{::quest.key}}_locked locked" : "inventory_quest_scroll inventory_quest_scroll_{{::quest.key}}"'
|
||||
@@ -30,7 +30,7 @@
|
||||
ng-click='purchase("special", Content.spells.special.shinySeed)')
|
||||
p {{::Content.spells.special.shinySeed.value}}
|
||||
span(class='shop_gold')
|
||||
// div
|
||||
div
|
||||
button.customize-option(class='Pet_HatchingPotion_Peppermint',
|
||||
popover='{{::Content.hatchingPotions.Peppermint.notes()}}',
|
||||
popover-title!=env.t("potion", {potionType: "{{::Content.hatchingPotions.Peppermint.text()}}"}),
|
||||
@@ -39,7 +39,7 @@
|
||||
ng-click='purchase("hatchingPotions", Content.hatchingPotions.Peppermint)')
|
||||
p {{::Content.hatchingPotions.Peppermint.value}}
|
||||
span.Pet_Currency_Gem1x.inline-gems
|
||||
// div
|
||||
div
|
||||
button.customize-option(popover='{{::Content.spells.special.nye.notes()}}', popover-title='{{::Content.spells.special.nye.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', ng-click='castStart(Content.spells.special.nye)', class='inventory_special_nye')
|
||||
p {{Content.spells.special.nye.value}}
|
||||
span(class='shop_gold')
|
||||
|
||||
@@ -56,6 +56,22 @@ mixin customizeProfile(mobile)
|
||||
each num in [1,2,3,4,5,6]
|
||||
button(class='hair_flower_#{num} customize-option', type='button', ng-click='set({"preferences.hair.flower":#{num}})', ng-class='{selectableInventory: user.preferences.hair.flower == "#{num}"}')
|
||||
|
||||
// Eyeglasses
|
||||
li.customize-menu
|
||||
menu(label=env.t('eyewear'))
|
||||
button.customize-option(ng-repeat='item in ::getGearArray("glasses")', class='{{::item.key}}',
|
||||
ng-class="{selectableInventory: user.preferences.costume ? user.items.gear.costume.eyewear === item.key : user.items.gear.equipped.eyewear === item.key}",
|
||||
popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter',
|
||||
popover-placement='right', popover-append-to-body='true',
|
||||
ng-click='equip(item.key)')
|
||||
|
||||
// Wheelchair
|
||||
li.customize-menu
|
||||
menu(label=env.t('wheelchair'))
|
||||
button.customize-option(ng-repeat='item in ::["black","blue","green","pink","red","yellow"]', class='button_chair_{{::item}}',
|
||||
ng-class="{selectableInventory: user.preferences.chair == item}",
|
||||
ng-click='user.preferences.chair != item ? set({"preferences.chair":item}) : set({"preferences.chair":"none"})')
|
||||
|
||||
// Animal Ears
|
||||
li.customize-menu
|
||||
menu(label=env.t('animalEars'))
|
||||
@@ -68,13 +84,6 @@ mixin customizeProfile(mobile)
|
||||
popover-placement='right', popover-append-to-body='true',
|
||||
ng-click='user.items.gear.owned[item.key] ? equip(item.key) : purchase(item.type,item)')
|
||||
|
||||
// Wheelchair
|
||||
li.customize-menu
|
||||
menu(label=env.t('wheelchair'))
|
||||
button.customize-option(ng-repeat='item in ::["black"]', class='button_chair_{{::item}}',
|
||||
ng-class="{selectableInventory: user.preferences.chair == item}",
|
||||
ng-click='user.preferences.chair != item ? set({"preferences.chair":item}) : set({"preferences.chair":"none"})')
|
||||
|
||||
.col-md-4
|
||||
h3(class=mobile?'item item-divider':'')=env.t('bodyHead')
|
||||
menu(type='list')
|
||||
@@ -90,7 +99,7 @@ mixin customizeProfile(mobile)
|
||||
each color in ['pblue','pgreen','porange','ppink','ppurple','pyellow']
|
||||
button(type='button', ng-if='user.purchased.hair.color.#{color}', class='customize-option hair hair_bangs_1_#{color}', ng-click='unlock("hair.color.#{color}")', ng-class='{selectableInventory: user.preferences.hair.color == "#{color}"}')
|
||||
+buyPref('hair.color', ['rainbow','yellow','green','purple','blue','TRUred'], 'rainbowColors')
|
||||
+buyPref('hair.color', ['pblue2','pgreen2','porange2','ppink2','ppurple2','pyellow2'], 'shimmerColors')
|
||||
+buyPref('hair.color', ['pblue2','pgreen2','porange2','ppink2','ppurple2','pyellow2'], 'shimmerColors', 'disabled')
|
||||
+buyPref('hair.color', ['candycorn','ghostwhite','halloween','midnight','pumpkin','zombie'], 'hauntedColors', 'disabled')
|
||||
+buyPref('hair.color', ['aurora','festive','hollygreen','peppermint','snowy','winterstar'], 'winteryColors', 'disabled')
|
||||
|
||||
@@ -157,7 +166,7 @@ mixin customizeProfile(mobile)
|
||||
+buyPref('skin', ['bear','cactus','fox','lion','panda','pig','tiger','wolf'], 'animalSkins')
|
||||
|
||||
// Seasonal event skins. Note that Spooky Skins are a legacy set and should always be disabled for purchase
|
||||
+buyPref('skin', ['pastelPink','pastelOrange','pastelYellow','pastelGreen','pastelBlue','pastelPurple','pastelRainbowChevron','pastelRainbowDiagonal'], 'pastelSkins')
|
||||
+buyPref('skin', ['pastelPink','pastelOrange','pastelYellow','pastelGreen','pastelBlue','pastelPurple','pastelRainbowChevron','pastelRainbowDiagonal'], 'pastelSkins', 'disabled')
|
||||
+buyPref('skin', ['monster','pumpkin','skeleton','zombie','ghost','shadow'], 'spookySkins', 'disabled')
|
||||
+buyPref('skin', ['candycorn','ogre','pumpkin2','reptile','shadow2','skeleton2','transparent','zombie2'], 'supernaturalSkins', 'disabled')
|
||||
+buyPref('skin', ['clownfish','deepocean','merblue','mergold','mergreen','merruby','shark','tropicalwater'], 'splashySkins', 'disabled')
|
||||
|
||||
@@ -1,21 +1,56 @@
|
||||
h2 4/29/2016 - LAST CHANCE FOR APRIL SUBSCRIBER ITEMS AND SPRING FLING ITEMS!
|
||||
h2 5/11/2016 - NEW AVATAR CUSTOMIZATIONS AND CHALLENGE SPOTLIGHT SUBMISSIONS
|
||||
hr
|
||||
tr
|
||||
td
|
||||
.promo_mystery_201604.pull-right
|
||||
h3 Last Chance for April Item Set
|
||||
p Reminder: this is the final day to <a href='/#/options/settings/subscription'>subscribe</a> and receive the Leaf Warrior Item Set! If you want the Crown o' Flowers or the Armor o' Leaves, now's the time. Thanks so much for your support <3
|
||||
.promo_chairs_glasses.pull-right
|
||||
h3 Glasses and New Wheelchair Colors for Avatars
|
||||
p We have two new sets of free <a href='/#/options/profile/avatar'>avatar customizations</a> available: glasses, and additional wheelchair colors! We hope that Habiticans who wear glasses and/or use wheelchairs will enjoy these options.
|
||||
p.small.muted by Breadstrings and Balduranne
|
||||
tr
|
||||
td
|
||||
.promo_spring_classes_2016.pull-right
|
||||
h3 Last Chance for Spring Fling Items and Customizations
|
||||
p On May 1st, everything will be back to normal in Habitica, so if you still have any remaining Spring Fling Items that you want to buy from the Rewards Column or the Seasonal Shop, you'd better do it now! The <a href='/#/options/inventory/seasonalshop'>Seasonal Edition items</a> and <a href='/#/options/profile/avatar'>Hair/Skin Colors</a> won't be back until next March, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot!
|
||||
h3 Challenge Spotlight Submissions
|
||||
p Do you have a broadly-applicable Challenge that you would like to promote to all of Habitica? Now you can submit Challenges to be featured on our official blog! To qualify, the Challenge must have a fixed end date and a Gem prize of any amount. To submit a Challenge for consideration, just <a href='https://docs.google.com/forms/d/1Wb0TJaZrMA3URdANqoNjnhi_EfFTwfZ0WwteB8IU5E8/viewform' target='_blank'>fill out this form.</a>
|
||||
p.small.muted by redphoenix
|
||||
|
||||
if menuItem !== 'oldNews'
|
||||
hr
|
||||
a(href='/static/old-news', target='_blank') Read older news
|
||||
|
||||
mixin oldNews
|
||||
h2 5/3/2016 - iOS UPDATE, MAY BACKGROUNDS, AND MAY ARMOIRE ITEMS
|
||||
tr
|
||||
td
|
||||
h3 iOS Update
|
||||
p We've released <a href='https://itunes.apple.com/us/app/habitica/id994882113?ls=1&mt=8' target='_blank'>a new iOS update</a> which includes social media sharing and the ability to change your class from the app, as well as some bug fixes. Be sure to download it now for a better Habitica experience!
|
||||
br
|
||||
p If you like the improvements that we’ve been making to our app, please consider reviewing this new version. It really helps us out! Old reviews get hidden, but if you go to the review section you can re-post it again with a single tap. We hope you enjoy the update!
|
||||
p.small.muted by viirus
|
||||
tr
|
||||
td
|
||||
.promo_backgrounds_armoire_201605.pull-right
|
||||
h3 May Backgrounds
|
||||
p There are three new avatar backgrounds in the <a href='https://habitica.com/#/options/profile/backgrounds'>Background Shop!</a> Now your avatar can buzz in a Beehive, explore the Tree Roots, or battle a Gazebo.
|
||||
p.small.muted by James Danger and DialFForFunky
|
||||
tr
|
||||
td
|
||||
h3 May Armoire Items Revealed
|
||||
p There is new equipment in the Enchanted Armoire, a 100 GP Reward in the Rewards Column which unlocks after you've attained Ultimate Gear!
|
||||
br
|
||||
p Click on the Enchanted Armoire for a random chance at special Equipment, including the Graduate Set and the Bouquet of Flowers! It may also give you random XP or food items. We'll be adding new equipment to it during the first week of each month, but even when you've exhausted the current supply, you can keep clicking for a chance at food and XP.
|
||||
br
|
||||
p Now go spend all that accumulated Gold! May the Random Number Generator smile upon you...
|
||||
p.small.muted by Breadstrings
|
||||
h2 4/29/2016 - LAST CHANCE FOR APRIL SUBSCRIBER ITEMS AND SPRING FLING ITEMS!
|
||||
tr
|
||||
td
|
||||
.promo_mystery_201604.pull-right
|
||||
h3 Last Chance for April Item Set
|
||||
p Reminder: this is the final day to <a href='/#/options/settings/subscription'>subscribe</a> and receive the Leaf Warrior Item Set! If you want the Crown o' Flowers or the Armor o' Leaves, now's the time. Thanks so much for your support <3
|
||||
tr
|
||||
td
|
||||
.promo_spring_classes_2016.pull-right
|
||||
h3 Last Chance for Spring Fling Items and Customizations
|
||||
p On May 1st, everything will be back to normal in Habitica, so if you still have any remaining Spring Fling Items that you want to buy from the Rewards Column or the Seasonal Shop, you'd better do it now! The <a href='/#/options/inventory/seasonalshop'>Seasonal Edition items</a> and <a href='/#/options/profile/avatar'>Hair/Skin Colors</a> won't be back until next March, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot!
|
||||
h2 4/27/2016 - WORLD BOSS DEFEATED!
|
||||
tr
|
||||
td
|
||||
|
||||
Reference in New Issue
Block a user