Merge branch 'develop' into release

This commit is contained in:
SabreCat
2018-03-01 23:23:57 +00:00
39 changed files with 1140 additions and 666 deletions

View File

@@ -1,6 +1,8 @@
Habitica [![Build Status](https://travis-ci.org/HabitRPG/habitica.svg?branch=develop)](https://travis-ci.org/HabitRPG/habitica) [![Code Climate](https://codeclimate.com/github/HabitRPG/habitrpg.svg)](https://codeclimate.com/github/HabitRPG/habitrpg) [![Coverage Status](https://coveralls.io/repos/github/HabitRPG/habitica/badge.svg?branch=develop)](https://coveralls.io/github/HabitRPG/habitica?branch=develop) [![Bountysource](https://api.bountysource.com/badge/tracker?tracker_id=68393)](https://www.bountysource.com/trackers/68393-habitrpg?utm_source=68393&utm_medium=shield&utm_campaign=TRACKER_BADGE)
Habitica [![Build Status](https://travis-ci.org/HabitRPG/habitica.svg?branch=develop)](https://travis-ci.org/HabitRPG/habitica) [![Code Climate](https://codeclimate.com/github/HabitRPG/habitrpg.svg)](https://codeclimate.com/github/HabitRPG/habitrpg) [![Coverage Status](https://coveralls.io/repos/github/HabitRPG/habitica/badge.svg?branch=develop)](https://coveralls.io/github/HabitRPG/habitica?branch=develop) [![Bountysource](https://api.bountysource.com/badge/tracker?tracker_id=68393)](https://www.bountysource.com/trackers/68393-habitrpg?utm_source=68393&utm_medium=shield&utm_campaign=TRACKER_BADGE) [![Open Source Helpers](https://www.codetriage.com/habitrpg/habitica/badges/users.svg)](https://www.codetriage.com/habitrpg/habitica)
===============
[![Greenkeeper badge](https://badges.greenkeeper.io/HabitRPG/habitica.svg)](https://greenkeeper.io/)
[Habitica](https://habitica.com) is an open source habit building program which treats your life like a Role Playing Game. Level up as you succeed, lose HP as you fail, earn money to buy weapons and armor.
We need more programmers! Your assistance will be greatly appreciated.

View File

@@ -105,5 +105,12 @@
"LOGGLY" : {
"TOKEN" : "example-token",
"SUBDOMAIN" : "exmaple-subdomain"
},
"KAFKA": {
"GROUP_ID": "",
"CLOUDKARAFKA_BROKERS": "",
"CLOUDKARAFKA_USERNAME": "",
"CLOUDKARAFKA_PASSWORD": "",
"CLOUDKARAFKA_TOPIC_PREFIX": ""
}
}

790
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,20 +3,24 @@
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "4.28.2",
"main": "./website/server/index.js",
"greenkeeper": {
"ignore": [
"mongoose"
]
},
"dependencies": {
"@slack/client": "^3.8.1",
"accepts": "^1.3.2",
"amazon-payments": "^0.2.6",
"amplitude": "^3.5.0",
"apidoc": "^0.17.5",
"apn": "^1.7.6",
"autoprefixer": "^8.0.0",
"aws-sdk": "^2.0.25",
"axios": "^0.17.1",
"aws-sdk": "^2.200.0",
"axios": "^0.18.0",
"axios-progress-bar": "^1.1.8",
"babel-core": "^6.0.0",
"babel-eslint": "^8.2.2",
"babel-loader": "^7.1.2",
"babel-eslint": "^8.2.1",
"babel-plugin-syntax-async-functions": "^6.13.0",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-transform-async-to-module-method": "^6.8.0",
@@ -31,20 +35,20 @@
"body-parser": "^1.15.0",
"bootstrap": "^4.0.0",
"bootstrap-vue": "^2.0.0-rc.1",
"compression": "^1.6.1",
"compression": "^1.7.2",
"cookie-session": "^1.2.0",
"coupon-code": "^0.4.5",
"cross-env": "^5.1.3",
"css-loader": "^0.28.0",
"csv-stringify": "^2.0.1",
"csv-stringify": "^2.0.4",
"cwait": "^1.1.1",
"domain-middleware": "~0.1.0",
"express": "^4.16.2",
"express-basic-auth": "^1.0.1",
"express-validator": "^2.18.0",
"express-basic-auth": "^1.1.4",
"express-validator": "^5.0.1",
"extract-text-webpack-plugin": "^3.0.2",
"glob": "^7.1.2",
"got": "^6.1.1",
"got": "^8.2.0",
"gulp": "^4.0.0",
"gulp-babel": "^7.0.1",
"gulp-imagemin": "^4.1.0",
@@ -57,19 +61,20 @@
"in-app-purchase": "^1.1.6",
"intro.js": "^2.6.0",
"jquery": ">=3.0.0",
"js2xmlparser": "~1.0.0",
"js2xmlparser": "^3.0.0",
"lodash": "^4.17.4",
"merge-stream": "^1.0.0",
"method-override": "^2.3.5",
"moment": "^2.13.0",
"moment-recur": "git://github.com/habitrpg/moment-recur.git#f147ef27bbc26ca67638385f3db4a44084c76626",
"mongoose": "^4.13.10",
"moment-recur": "^1.0.7",
"mongoose": "^4.13.11",
"morgan": "^1.7.0",
"nconf": "^0.10.0",
"node-gcm": "^0.14.4",
"node-rdkafka": "^2.2.3",
"node-sass": "^4.5.0",
"nodemailer": "^2.3.2",
"ora": "^1.1.0",
"nodemailer": "^4.5.0",
"ora": "^2.0.0",
"pageres": "^4.1.1",
"passport": "^0.4.0",
"passport-facebook": "^2.0.0",
@@ -82,11 +87,10 @@
"pug": "^2.0.0-rc.4",
"push-notify": "git://github.com/habitrpg/push-notify.git#6bc2b5fdb1bdc9649b9ec1964d79ca50187fc8a9",
"pusher": "^1.3.0",
"request": "^2.83.0",
"rimraf": "^2.4.3",
"sass-loader": "^6.0.2",
"shelljs": "^0.8.1",
"stripe": "^5.4.0",
"stripe": "^5.5.0",
"superagent": "^3.4.3",
"svg-inline-loader": "^0.8.0",
"svg-url-loader": "^2.0.2",
@@ -96,13 +100,13 @@
"url-loader": "^0.6.2",
"useragent": "^2.1.9",
"uuid": "^3.0.1",
"validator": "^4.9.0",
"validator": "^9.4.1",
"vinyl-buffer": "^1.0.1",
"vue": "^2.5.2",
"vue-loader": "^14.1.1",
"vue-mugen-scroll": "^0.2.1",
"vue-router": "^3.0.0",
"vue-style-loader": "^3.0.0",
"vue-style-loader": "^4.0.2",
"vue-template-compiler": "^2.5.2",
"vuedraggable": "^2.15.0",
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec",
@@ -145,12 +149,12 @@
"babel-plugin-istanbul": "^4.0.0",
"chai": "^4.1.2",
"chai-as-promised": "^7.1.1",
"chalk": "^2.3.0",
"chalk": "^2.3.1",
"chromedriver": "^2.27.2",
"connect-history-api-fallback": "^1.1.0",
"coveralls": "^3.0.0",
"cross-spawn": "^6.0.4",
"eslint": "^4.17.0",
"eslint": "^4.18.1",
"eslint-config-habitrpg": "^4.0.0",
"eslint-friendly-formatter": "^3.0.0",
"eslint-loader": "^1.3.0",
@@ -173,13 +177,13 @@
"karma-spec-reporter": "0.0.32",
"karma-webpack": "^2.0.2",
"lcov-result-merger": "^2.0.0",
"mocha": "^5.0.0",
"mocha": "^5.0.1",
"monk": "^6.0.5",
"nightwatch": "^0.9.12",
"puppeteer": "^1.0.0",
"puppeteer": "^1.1.0",
"require-again": "^2.0.0",
"selenium-server": "^3.0.1",
"sinon": "^4.2.2",
"selenium-server": "^3.9.1",
"sinon": "^4.3.0",
"sinon-chai": "^2.8.0",
"sinon-stub-promise": "^4.0.0",
"webpack-bundle-analyzer": "^2.2.1",

View File

@@ -478,8 +478,8 @@ describe('POST /chat', () => {
context('Spam prevention', () => {
it('Returns an error when the user has been posting too many messages', async () => {
// Post as many messages are needed to reach the spam limit
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) { // eslint-disable-line no-await-in-loop
let result = await additionalMember.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage });
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) {
let result = await additionalMember.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage }); // eslint-disable-line no-await-in-loop
expect(result.message.id).to.exist;
}
@@ -494,8 +494,8 @@ describe('POST /chat', () => {
let userSocialite = await member.update({'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL, 'flags.chatRevoked': false});
// Post 1 more message than the spam limit to ensure they do not reach the limit
for (let i = 0; i < SPAM_MESSAGE_LIMIT + 1; i++) { // eslint-disable-line no-await-in-loop
let result = await userSocialite.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage });
for (let i = 0; i < SPAM_MESSAGE_LIMIT + 1; i++) {
let result = await userSocialite.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage }); // eslint-disable-line no-await-in-loop
expect(result.message.id).to.exist;
}
});

View File

@@ -113,10 +113,10 @@ describe('GET /tasks/user', () => {
await user.sync();
let initialTodoCount = user.tasksOrder.todos.length;
for (let i = 0; i < numberOfTodos; i++) { // eslint-disable-line no-await-in-loop
for (let i = 0; i < numberOfTodos; i++) {
let id = todos[i]._id;
await user.post(`/tasks/${id}/score/up`);
await user.post(`/tasks/${id}/score/up`); // eslint-disable-line no-await-in-loop
}
await user.sync();

View File

@@ -33,9 +33,9 @@ describe('POST /tasks/clearCompletedTodos', () => {
let tasks = await user.get('/tasks/user?type=todos');
expect(tasks.length).to.equal(initialTodoCount + 7);
for (let task of tasks) { // eslint-disable-line no-await-in-loop
for (let task of tasks) {
if (['todo 2', 'todo 3', 'todo 6'].indexOf(task.text) !== -1) {
await user.post(`/tasks/${task._id}/score/up`);
await user.post(`/tasks/${task._id}/score/up`); // eslint-disable-line no-await-in-loop
}
}

View File

@@ -187,6 +187,22 @@ describe('POST /user/class/cast/:spellId', () => {
expect(group.chat[0].uuid).to.equal('system');
});
it('cast bulk', async () => {
let { group, groupLeader } = await createAndPopulateGroup({
groupDetails: { type: 'party', privacy: 'private' },
members: 1,
});
await groupLeader.update({'stats.mp': 200, 'stats.class': 'wizard', 'stats.lvl': 13});
await groupLeader.post('/user/class/cast/earth', {quantity: 2});
await sleep(1);
await group.sync();
expect(group.chat[0]).to.exist;
expect(group.chat[0].uuid).to.equal('system');
});
it('searing brightness does not affect challenge or group tasks', async () => {
let guild = await generateGroup(user);
let challenge = await generateChallenge(user, guild);

View File

@@ -34,7 +34,7 @@ describe('PUT /user/webhook/:id', () => {
});
it('returns an error if validation fails', async () => {
await expect(user.put(`/user/webhook/${webhookToUpdate.id}`, { url: 'foo', enabled: true })).to.eventually.be.rejected.and.eql({
await expect(user.put(`/user/webhook/${webhookToUpdate.id}`, { url: 'foo_invalid', enabled: true })).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'User validation failed',

View File

@@ -1,27 +1,11 @@
/* eslint-disable global-require */
import request from 'request';
import got from 'got';
import nconf from 'nconf';
import nodemailer from 'nodemailer';
import Bluebird from 'bluebird';
import requireAgain from 'require-again';
import logger from '../../../../../website/server/libs/logger';
import { TAVERN_ID } from '../../../../../website/server/models/group';
function defer () {
let resolve;
let reject;
let promise = new Bluebird((resolveParam, rejectParam) => {
resolve = resolveParam;
reject = rejectParam;
});
return {
resolve,
reject,
promise,
};
}
import { defer } from '../../../../helpers/api-unit.helper';
function getUser () {
return {
@@ -158,7 +142,7 @@ describe('emails', () => {
describe('sendTxnEmail', () => {
beforeEach(() => {
sandbox.stub(request, 'post');
sandbox.stub(got, 'post').returns(defer().promise);
});
afterEach(() => {
@@ -176,8 +160,9 @@ describe('emails', () => {
};
sendTxnEmail(mailingInfo, emailType);
expect(request.post).to.be.calledWith(sinon.match({
json: {
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: true,
body: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match((value) => {
@@ -199,7 +184,7 @@ describe('emails', () => {
};
sendTxnEmail(mailingInfo, emailType);
expect(request.post).not.to.be.called;
expect(got.post).not.to.be.called;
});
it('uses getUserInfo in case of user data', () => {
@@ -210,8 +195,9 @@ describe('emails', () => {
let mailingInfo = getUser();
sendTxnEmail(mailingInfo, emailType);
expect(request.post).to.be.calledWith(sinon.match({
json: {
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: true,
body: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
@@ -232,8 +218,9 @@ describe('emails', () => {
let variables = [1, 2, 3];
sendTxnEmail(mailingInfo, emailType, variables);
expect(request.post).to.be.calledWith(sinon.match({
json: {
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: true,
body: {
data: {
variables: sinon.match((value) => {
return value[0].name === 'BASE_URL';

View File

@@ -216,6 +216,9 @@ describe('Amazon Payments - Subscribe', () => {
});
it('subscribes with amazon', async () => {
user.guilds.push(groupId);
await user.save();
await amzLib.subscribe({
billingAgreementId,
sub,
@@ -241,8 +244,13 @@ describe('Amazon Payments - Subscribe', () => {
user = new User();
user.guilds.push(groupId);
await user.save();
group.memberCount = 2;
await group.save();
// Add existing users
user = new User();
user.guilds.push(groupId);
await user.save();
// Set expected amount
sub.key = 'group_monthly';
sub.price = 9;
amount = 12;

View File

@@ -227,6 +227,11 @@ describe('checkout with subscription', () => {
sub = data.sub;
groupId = group._id;
email = 'test@test.com';
// Add user to group
user.guilds.push(groupId);
await user.save();
headers = {};
await stripePayments.checkout({
@@ -267,9 +272,15 @@ describe('checkout with subscription', () => {
groupId = group._id;
email = 'test@test.com';
headers = {};
// Add user to group
user.guilds.push(groupId);
await user.save();
user = new User();
user.guilds.push(groupId);
await user.save();
group.memberCount = 2;
await group.save();

View File

@@ -1,16 +1,17 @@
import request from 'request';
import got from 'got';
import {
WebhookSender,
taskScoredWebhook,
groupChatReceivedWebhook,
taskActivityWebhook,
} from '../../../../../website/server/libs/webhook';
import { defer } from '../../../../helpers/api-unit.helper';
describe('webhooks', () => {
let webhooks;
beforeEach(() => {
sandbox.stub(request, 'post');
sandbox.stub(got, 'post').returns(defer().promise);
webhooks = [{
id: 'taskActivity',
@@ -59,8 +60,9 @@ describe('webhooks', () => {
sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body);
expect(WebhookSender.defaultTransformData).to.be.calledOnce;
expect(request.post).to.be.calledOnce;
expect(request.post).to.be.calledWithMatch({
expect(got.post).to.be.calledOnce;
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
json: true,
body,
});
});
@@ -81,8 +83,9 @@ describe('webhooks', () => {
sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body);
expect(WebhookSender.defaultTransformData).to.not.be.called;
expect(request.post).to.be.calledOnce;
expect(request.post).to.be.calledWithMatch({
expect(got.post).to.be.calledOnce;
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
json: true,
body: {
foo: 'bar',
baz: 'biz',
@@ -117,7 +120,7 @@ describe('webhooks', () => {
sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body);
expect(WebhookSender.defaultWebhookFilter).to.not.be.called;
expect(request.post).to.not.be.called;
expect(got.post).to.not.be.called;
});
it('can pass in a webhook filter function that filters on data', () => {
@@ -136,10 +139,8 @@ describe('webhooks', () => {
{ id: 'other-custom-webhook', url: 'http://other-custom-url.com', enabled: true, type: 'custom', options: { foo: 'foo' }},
], body);
expect(request.post).to.be.calledOnce;
expect(request.post).to.be.calledWithMatch({
url: 'http://custom-url.com',
});
expect(got.post).to.be.calledOnce;
expect(got.post).to.be.calledWithMatch('http://custom-url.com');
});
it('ignores disabled webhooks', () => {
@@ -151,7 +152,7 @@ describe('webhooks', () => {
sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: false, type: 'custom'}], body);
expect(request.post).to.not.be.called;
expect(got.post).to.not.be.called;
});
it('ignores webhooks with invalid urls', () => {
@@ -163,7 +164,7 @@ describe('webhooks', () => {
sendWebhook.send([{id: 'custom-webhook', url: 'httxp://custom-url!!', enabled: true, type: 'custom'}], body);
expect(request.post).to.not.be.called;
expect(got.post).to.not.be.called;
});
it('ignores webhooks of other types', () => {
@@ -178,9 +179,8 @@ describe('webhooks', () => {
{ id: 'other-webhook', url: 'http://other-url.com', enabled: true, type: 'other'},
], body);
expect(request.post).to.be.calledOnce;
expect(request.post).to.be.calledWithMatch({
url: 'http://custom-url.com',
expect(got.post).to.be.calledOnce;
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
body,
json: true,
});
@@ -198,14 +198,12 @@ describe('webhooks', () => {
{ id: 'other-custom-webhook', url: 'http://other-url.com', enabled: true, type: 'custom'},
], body);
expect(request.post).to.be.calledTwice;
expect(request.post).to.be.calledWithMatch({
url: 'http://custom-url.com',
expect(got.post).to.be.calledTwice;
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
body,
json: true,
});
expect(request.post).to.be.calledWithMatch({
url: 'http://other-url.com',
expect(got.post).to.be.calledWithMatch('http://other-url.com', {
body,
json: true,
});
@@ -252,8 +250,9 @@ describe('webhooks', () => {
it('sends task and stats data', () => {
taskScoredWebhook.send(webhooks, data);
expect(request.post).to.be.calledOnce;
expect(request.post).to.be.calledWithMatch({
expect(got.post).to.be.calledOnce;
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
json: true,
body: {
type: 'scored',
user: {
@@ -283,7 +282,7 @@ describe('webhooks', () => {
taskScoredWebhook.send(webhooks, data);
expect(request.post).to.not.be.called;
expect(got.post).to.not.be.called;
});
});
@@ -304,8 +303,9 @@ describe('webhooks', () => {
taskActivityWebhook.send(webhooks, data);
expect(request.post).to.be.calledOnce;
expect(request.post).to.be.calledWithMatch({
expect(got.post).to.be.calledOnce;
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
json: true,
body: {
type,
task: data.task,
@@ -319,7 +319,7 @@ describe('webhooks', () => {
taskActivityWebhook.send(webhooks, data);
expect(request.post).to.not.be.called;
expect(got.post).to.not.be.called;
});
});
});
@@ -340,8 +340,9 @@ describe('webhooks', () => {
groupChatReceivedWebhook.send(webhooks, data);
expect(request.post).to.be.calledOnce;
expect(request.post).to.be.calledWithMatch({
expect(got.post).to.be.calledOnce;
expect(got.post).to.be.calledWithMatch(webhooks[webhooks.length - 1].url, {
json: true,
body: {
group: {
id: 'group-id',
@@ -370,7 +371,7 @@ describe('webhooks', () => {
groupChatReceivedWebhook.send(webhooks, data);
expect(request.post).to.not.be.called;
expect(got.post).to.not.be.called;
});
});
});

View File

@@ -352,7 +352,12 @@ describe('User Model', () => {
context('manage unallocated stats points notifications', () => {
it('doesn\'t add a notification if there are no points to allocate', async () => {
let user = new User();
user.flags.classSelected = true;
user.preferences.disableClasses = false;
user.stats.class = 'warrior';
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
user.stats.points = 0;
@@ -363,6 +368,10 @@ describe('User Model', () => {
it('removes a notification if there are no more points to allocate', async () => {
let user = new User();
user.flags.classSelected = true;
user.preferences.disableClasses = false;
user.stats.class = 'warrior';
user.stats.points = 9;
user = await user.save(); // necessary for user.isSelected to work correctly
@@ -377,6 +386,9 @@ describe('User Model', () => {
it('adds a notification if there are points to allocate', async () => {
let user = new User();
user.flags.classSelected = true;
user.preferences.disableClasses = false;
user.stats.class = 'warrior';
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
@@ -391,6 +403,9 @@ describe('User Model', () => {
it('adds a notification if the points to allocate have changed', async () => {
let user = new User();
user.stats.points = 9;
user.flags.classSelected = true;
user.preferences.disableClasses = false;
user.stats.class = 'warrior';
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
@@ -406,6 +421,37 @@ describe('User Model', () => {
expect(user.notifications[0].data.points).to.equal(11);
expect(user.notifications[0].id).to.not.equal(oldNotificationsUUID);
});
it('does not add a notification if the user has disabled classes', async () => {
let user = new User();
user.stats.points = 9;
user.flags.classSelected = true;
user.preferences.disableClasses = true;
user.stats.class = 'warrior';
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
user.stats.points = 9;
user = await user.save();
expect(user.notifications.length).to.equal(oldNotificationsCount);
});
it('does not add a notification if the user has not selected a class', async () => {
let user = new User();
user.stats.points = 9;
user.flags.classSelected = false;
user.stats.class = 'warrior';
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
user.stats.points = 9;
user = await user.save();
expect(user.notifications.length).to.equal(oldNotificationsCount);
});
});
});

View File

@@ -108,3 +108,19 @@ export function generateDaily (user) {
return task;
}
export function defer () {
let resolve;
let reject;
let promise = new Promise((resolveParam, rejectParam) => {
resolve = resolveParam;
reject = rejectParam;
});
return {
resolve,
reject,
promise,
};
}

View File

@@ -13,8 +13,8 @@
:key="group.key",
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", v-model="viewOptions[group.key].selected", :id="group.key")
label.custom-control-label(v-once, :for="group.key") {{ group.label }}
input.custom-control-input(type="checkbox", v-model="viewOptions[group.key].selected", :id="groupBy + group.key")
label.custom-control-label(v-once, :for="groupBy + group.key") {{ group.label }}
.standard-page
.clearfix

View File

@@ -160,6 +160,7 @@ import Vue from 'vue';
import moment from 'moment';
import filter from 'lodash/filter';
import sortBy from 'lodash/sortBy';
import groupBy from 'lodash/groupBy';
import { mapState } from 'client/libs/store';
import styleHelper from 'client/mixins/styleHelper';
@@ -220,45 +221,53 @@ export default {
computed: {
...mapState({user: 'user.data'}),
conversations () {
let conversations = {};
for (let messageId in this.user.inbox.messages) {
let message = this.user.inbox.messages[messageId];
let userId = message.uuid;
const inboxGroup = groupBy(this.user.inbox.messages, 'uuid');
if (!conversations[userId]) {
conversations[userId] = {
name: message.user,
key: userId,
messages: [],
};
// Create conversation objects
const convos = [];
for (let key in inboxGroup) {
const convoSorted = sortBy(inboxGroup[key], [(o) => {
return o.timestamp;
}]);
// Fix poor inbox chat models
const newChatModels = convoSorted.map(chat => {
let newChat = Object.assign({}, chat);
if (newChat.sent) {
newChat.toUUID = newChat.uuid;
newChat.toUser = newChat.user;
newChat.uuid = this.user._id;
newChat.user = this.user.profile.name;
newChat.contributor = this.user.contributor;
newChat.backer = this.user.backer;
}
return newChat;
});
const recentMessage = newChatModels[newChatModels.length - 1];
// Special case where we have placeholder message because conversations are just grouped messages for now
if (!recentMessage.text) {
newChatModels.splice(newChatModels.length - 1, 1);
}
let newMessage = {
text: message.text,
timestamp: message.timestamp,
user: message.user,
uuid: message.uuid,
id: message.id,
contributor: message.contributor,
const convoModel = {
name: recentMessage.toUser ? recentMessage.toUser : recentMessage.user, // Handles case where from user sent the only message or the to user sent the only message
key: recentMessage.toUUID ? recentMessage.toUUID : recentMessage.uuid,
messages: newChatModels,
lastMessageText: recentMessage.text,
date: recentMessage.timestamp,
};
if (message.sent) {
newMessage.user = this.user.profile.name;
newMessage.uuid = this.user._id;
newMessage.contributor = this.user.contributor;
convos.push(convoModel);
}
if (newMessage.text) conversations[userId].messages.push(newMessage);
conversations[userId].lastMessageText = message.text;
conversations[userId].date = message.timestamp;
}
conversations = sortBy(conversations, [(o) => {
// Sort models by most recent
const conversations = sortBy(convos, [(o) => {
return moment(o.date).toDate();
}]);
conversations = conversations.reverse();
return conversations;
return conversations.reverse();
},
filtersConversations () {
if (!this.search) return this.conversations;

View File

@@ -21,7 +21,7 @@ export function fetch (store, options = {}) { // eslint-disable-line no-shadow
export async function set (store, changes) {
const user = store.state.user.data;
for (let key in changes) { // eslint-disable-line no-await-in-loop
for (let key in changes) {
if (key === 'tags') {
// Keep challenge and group tags
const oldTags = user.tags.filter(t => {
@@ -31,7 +31,7 @@ export async function set (store, changes) {
user.tags = changes[key].concat(oldTags);
// Remove deleted tags from tasks
const userTasksByType = (await store.dispatch('tasks:fetchUserTasks')).data;
const userTasksByType = (await store.dispatch('tasks:fetchUserTasks')).data; // eslint-disable-line no-await-in-loop
Object.keys(userTasksByType).forEach(taskType => {
userTasksByType[taskType].forEach(task => {
@@ -119,7 +119,8 @@ export function openMysteryItem () {
return axios.post('/api/v3/user/open-mystery-item');
}
export function newStuffLater () {
export function newStuffLater (store) {
store.state.user.data.flags.newStuff = false;
return axios.post('/api/v3/news/tell-me-later');
}

View File

@@ -12,7 +12,7 @@
"tip10": "You can win gems by competing in Challenges. New ones are added every day!",
"tip11": "Having more than four Party members increases accountability!",
"tip12": "Add checklists to your To-Dos to multiply your rewards!",
"tip13": "Click “Filters” on your task page to make an unwieldy task list very manageable!",
"tip13": "Click “Tags” on your task page to make an unwieldy task list very manageable!",
"tip14": "You can add headers or inspirational quotes to your list as Habits with no (+/-).",
"tip15": "Complete all the Masterclasser Quest-lines to learn about Habiticas secret lore.",
"tip16": "Click the link to the Data Display Tool in the footer for valuable insights on your progress.",

View File

@@ -149,6 +149,8 @@
"taskAliasAlreadyUsed": "Task alias already used on another task.",
"taskNotFound": "Task not found.",
"invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".",
"invalidTasksType": "Task type must be one of \"habits\", \"dailys\", \"todos\", \"rewards\".",
"invalidTasksTypeExtra": "Task type must be one of \"habits\", \"dailys\", \"todos\", \"rewards\", \"completedTodos\".",
"cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.",
"checklistOnlyDailyTodo": "Checklists are supported only on Dailies and To-Dos",
"checklistItemNotFound": "No checklist item was found with given id.",

View File

@@ -247,7 +247,7 @@ api.loginLocal = {
let username = req.body.username;
let password = req.body.password;
if (validator.isEmail(username)) {
if (validator.isEmail(String(username))) {
login = {'auth.local.email': username.toLowerCase()}; // Emails are stored lowercase
} else {
login = {'auth.local.username': username};
@@ -410,7 +410,7 @@ api.pusherAuth = {
}
resourceId = resourceId.join('-'); // the split at the beginning had split resourceId too
if (!validator.isUUID(resourceId)) {
if (!validator.isUUID(String(resourceId))) {
throw new BadRequest('Invalid Pusher resource id, must be a UUID.');
}

View File

@@ -287,7 +287,7 @@ api.getUserTasks = {
async handler (req, res) {
let types = Tasks.tasksTypes.map(type => `${type}s`);
types.push('completedTodos', '_allCompletedTodos'); // _allCompletedTodos is currently in BETA and is likely to be removed in future
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
req.checkQuery('type', res.t('invalidTasksTypeExtra')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
@@ -325,7 +325,7 @@ api.getChallengeTasks = {
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let types = Tasks.tasksTypes.map(type => `${type}s`);
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
req.checkQuery('type', res.t('invalidTasksType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;

View File

@@ -86,7 +86,7 @@ api.getGroupTasks = {
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
req.checkQuery('type', res.t('invalidTasksType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;

View File

@@ -1,16 +1,14 @@
import { authWithHeaders } from '../../middlewares/auth';
import common from '../../../common';
import {
NotFound,
BadRequest,
NotAuthorized,
} from '../../libs/errors';
import * as Tasks from '../../models/task';
import {
basicFields as basicGroupFields,
model as Group,
} from '../../models/group';
import { model as User } from '../../models/user';
import * as Tasks from '../../models/task';
import Bluebird from 'bluebird';
import _ from 'lodash';
import * as passwordUtils from '../../libs/password';
@@ -18,6 +16,7 @@ import {
getUserInfo,
sendTxn as txnEmail,
} from '../../libs/email';
import Queue from '../../libs/queue';
import nconf from 'nconf';
import get from 'lodash/get';
@@ -434,6 +433,8 @@ api.deleteUser = {
]);
}
if (feedback) Queue.sendMessage({feedback, username: user.profile.name}, user._id);
res.analytics.track('account delete', {
uuid: user._id,
hitType: 'event',
@@ -524,224 +525,6 @@ api.getUserAnonymized = {
},
};
const partyMembersFields = 'profile.name stats achievements items.special';
async function castTaskSpell (res, req, targetId, user, spell) {
if (!targetId) throw new BadRequest(res.t('targetIdUUID'));
const task = await Tasks.Task.findOne({
_id: targetId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast'));
if (task.group.id) throw new BadRequest(res.t('groupTasksNoCast'));
spell.cast(user, task, req);
const results = await Bluebird.all([
user.save(),
task.save(),
]);
return results;
}
async function castMultiTaskSpell (req, user, spell) {
const tasks = await Tasks.Task.find({
userId: user._id,
...Tasks.taskIsGroupOrChallengeQuery,
}).exec();
spell.cast(user, tasks, req);
const toSave = tasks
.filter(t => t.isModified())
.map(t => t.save());
toSave.unshift(user.save());
const saved = await Bluebird.all(toSave);
const response = {
tasks: saved,
user,
};
return response;
}
async function castSelfSpell (req, user, spell) {
spell.cast(user, null, req);
await user.save();
}
async function castPartySpell (req, party, partyMembers, user, spell) {
if (!party) {
partyMembers = [user]; // Act as solo party
} else {
partyMembers = await User
.find({
'party._id': party._id,
_id: { $ne: user._id }, // add separately
})
// .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save
// default values for non-selected fields and pre('save') will mess up thinking some values are missing
.exec();
partyMembers.unshift(user);
}
spell.cast(user, partyMembers, req);
await Bluebird.all(partyMembers.map(m => m.save()));
return partyMembers;
}
async function castUserSpell (res, req, party, partyMembers, targetId, user, spell) {
if (!party && (!targetId || user._id === targetId)) {
partyMembers = user;
} else {
if (!targetId) throw new BadRequest(res.t('targetIdUUID'));
if (!party) throw new NotFound(res.t('partyNotFound'));
partyMembers = await User
.findOne({_id: targetId, 'party._id': party._id})
// .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save
// default values for non-selected fields and pre('save') will mess up thinking some values are missing
.exec();
}
if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId}));
spell.cast(user, partyMembers, req);
if (partyMembers !== user) {
await Bluebird.all([
user.save(),
partyMembers.save(),
]);
} else {
await partyMembers.save(); // partyMembers is user
}
return partyMembers;
}
/**
* @api {post} /api/v3/user/class/cast/:spellId Cast a skill (spell) on a target
* @apiName UserCast
* @apiGroup User
*
* @apiParam (Path) {String=fireball, mpheal, earth, frost, smash, defensiveStance, valorousPresence, intimidate, pickPocket, backStab, toolsOfTrade, stealth, heal, protectAura, brightness, healAll} spellId The skill to cast.
* @apiParam (Query) {UUID} targetId Query parameter, necessary if the spell is cast on a party member or task. Not used if the spell is case on the user or the user's current party.
* @apiParamExample {json} Query example:
* Cast "Pickpocket" on a task:
* https://habitica.com/api/v3/user/class/cast/pickPocket?targetId=fd427623...
*
* Cast "Tools of the Trade" on the party:
* https://habitica.com/api/v3/user/class/cast/toolsOfTrade
*
* @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned.
*
* @apiDescription Skill Key to Name Mapping
* Mage
* fireball: "Burst of Flames"
* mpheal: "Ethereal Surge"
* earth: "Earthquake"
* frost: "Chilling Frost"
*
* Warrior
* smash: "Brutal Smash"
* defensiveStance: "Defensive Stance"
* valorousPresence: "Valorous Presence"
* intimidate: "Intimidating Gaze"
*
* Rogue
* pickPocket: "Pickpocket"
* backStab: "Backstab"
* toolsOfTrade: "Tools of the Trade"
* stealth: "Stealth"
*
* Healer
* heal: "Healing Light"
* protectAura: "Protective Aura"
* brightness: "Searing Brightness"
* healAll: "Blessing"
*
* @apiError (400) {NotAuthorized} Not enough mana.
* @apiUse TaskNotFound
* @apiUse PartyNotFound
* @apiUse UserNotFound
*/
api.castSpell = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/class/cast/:spellId',
async handler (req, res) {
let user = res.locals.user;
let spellId = req.params.spellId;
let targetId = req.query.targetId;
// optional because not required by all targetTypes, presence is checked later if necessary
req.checkQuery('targetId', res.t('targetIdUUID')).optional().isUUID();
let reqValidationErrors = req.validationErrors();
if (reqValidationErrors) throw reqValidationErrors;
let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class;
let spell = common.content.spells[klass][spellId];
if (!spell) throw new NotFound(res.t('spellNotFound', {spellId}));
if (spell.mana > user.stats.mp) throw new NotAuthorized(res.t('notEnoughMana'));
if (spell.value > user.stats.gp && !spell.previousPurchase) throw new NotAuthorized(res.t('messageNotEnoughGold'));
if (spell.lvl > user.stats.lvl) throw new NotAuthorized(res.t('spellLevelTooHigh', {level: spell.lvl}));
let targetType = spell.target;
if (targetType === 'task') {
const results = await castTaskSpell(res, req, targetId, user, spell);
res.respond(200, {
user: results[0],
task: results[1],
});
} else if (targetType === 'self') {
await castSelfSpell(req, user, spell);
res.respond(200, { user });
} else if (targetType === 'tasks') { // new target type in v3: when all the user's tasks are necessary
const response = await castMultiTaskSpell(req, user, spell);
res.respond(200, response);
} else if (targetType === 'party' || targetType === 'user') {
const party = await Group.getGroup({groupId: 'party', user});
// arrays of users when targetType is 'party' otherwise single users
let partyMembers;
if (targetType === 'party') {
partyMembers = await castPartySpell(req, party, partyMembers, user, spell);
} else {
partyMembers = await castUserSpell(res, req, party, partyMembers, targetId, user, spell);
}
let partyMembersRes = Array.isArray(partyMembers) ? partyMembers : [partyMembers];
// Only return some fields.
// See comment above on why we can't just select the necessary fields when querying
partyMembersRes = partyMembersRes.map(partyMember => {
return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersFields));
});
res.respond(200, {
partyMembers: partyMembersRes,
user,
});
if (party && !spell.silent) {
let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``;
party.sendChat(message);
await party.save();
}
}
},
};
/**
* @api {post} /api/v3/user/sleep Make the user start / stop sleeping (resting in the Inn)
* @apiName UserSleep

View File

@@ -0,0 +1,140 @@
import { authWithHeaders } from '../../../middlewares/auth';
import common from '../../../../common';
import {
model as Group,
} from '../../../models/group';
import {
NotAuthorized,
NotFound,
} from '../../../libs/errors';
import {
castTaskSpell,
castMultiTaskSpell,
castSelfSpell,
castPartySpell,
castUserSpell,
} from '../../../libs/spells';
const partyMembersFields = 'profile.name stats achievements items.special';
let api = {};
/**
* @api {post} /api/v3/user/class/cast/:spellId Cast a skill (spell) on a target
* @apiName UserCast
* @apiGroup User
*
* @apiParam (Path) {String=fireball, mpheal, earth, frost, smash, defensiveStance, valorousPresence, intimidate, pickPocket, backStab, toolsOfTrade, stealth, heal, protectAura, brightness, healAll} spellId The skill to cast.
* @apiParam (Query) {UUID} targetId Query parameter, necessary if the spell is cast on a party member or task. Not used if the spell is case on the user or the user's current party.
* @apiParamExample {json} Query example:
* Cast "Pickpocket" on a task:
* https://habitica.com/api/v3/user/class/cast/pickPocket?targetId=fd427623...
*
* Cast "Tools of the Trade" on the party:
* https://habitica.com/api/v3/user/class/cast/toolsOfTrade
*
* @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned.
*
* @apiDescription Skill Key to Name Mapping
* Mage
* fireball: "Burst of Flames"
* mpheal: "Ethereal Surge"
* earth: "Earthquake"
* frost: "Chilling Frost"
*
* Warrior
* smash: "Brutal Smash"
* defensiveStance: "Defensive Stance"
* valorousPresence: "Valorous Presence"
* intimidate: "Intimidating Gaze"
*
* Rogue
* pickPocket: "Pickpocket"
* backStab: "Backstab"
* toolsOfTrade: "Tools of the Trade"
* stealth: "Stealth"
*
* Healer
* heal: "Healing Light"
* protectAura: "Protective Aura"
* brightness: "Searing Brightness"
* healAll: "Blessing"
*
* @apiError (400) {NotAuthorized} Not enough mana.
* @apiUse TaskNotFound
* @apiUse PartyNotFound
* @apiUse UserNotFound
*/
api.castSpell = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/class/cast/:spellId',
async handler (req, res) {
let user = res.locals.user;
let spellId = req.params.spellId;
let targetId = req.query.targetId;
const quantity = req.body.quantity || 1;
// optional because not required by all targetTypes, presence is checked later if necessary
req.checkQuery('targetId', res.t('targetIdUUID')).optional().isUUID();
let reqValidationErrors = req.validationErrors();
if (reqValidationErrors) throw reqValidationErrors;
let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class;
let spell = common.content.spells[klass][spellId];
if (!spell) throw new NotFound(res.t('spellNotFound', {spellId}));
if (spell.mana > user.stats.mp) throw new NotAuthorized(res.t('notEnoughMana'));
if (spell.value > user.stats.gp && !spell.previousPurchase) throw new NotAuthorized(res.t('messageNotEnoughGold'));
if (spell.lvl > user.stats.lvl) throw new NotAuthorized(res.t('spellLevelTooHigh', {level: spell.lvl}));
let targetType = spell.target;
if (targetType === 'task') {
const results = await castTaskSpell(res, req, targetId, user, spell, quantity);
res.respond(200, {
user: results[0],
task: results[1],
});
} else if (targetType === 'self') {
await castSelfSpell(req, user, spell, quantity);
res.respond(200, { user });
} else if (targetType === 'tasks') { // new target type in v3: when all the user's tasks are necessary
const response = await castMultiTaskSpell(req, user, spell, quantity);
res.respond(200, response);
} else if (targetType === 'party' || targetType === 'user') {
const party = await Group.getGroup({groupId: 'party', user});
// arrays of users when targetType is 'party' otherwise single users
let partyMembers;
if (targetType === 'party') {
partyMembers = await castPartySpell(req, party, partyMembers, user, spell, quantity);
} else {
partyMembers = await castUserSpell(res, req, party, partyMembers, targetId, user, spell, quantity);
}
let partyMembersRes = Array.isArray(partyMembers) ? partyMembers : [partyMembers];
// Only return some fields.
// See comment above on why we can't just select the necessary fields when querying
partyMembersRes = partyMembersRes.map(partyMember => {
return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersFields));
});
res.respond(200, {
partyMembers: partyMembersRes,
user,
});
if (party && !spell.silent) {
let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``;
party.sendChat(message);
await party.save();
}
}
},
};
module.exports = api;

View File

@@ -144,7 +144,12 @@ api.exportUserDataXml = {
'Content-Type': 'text/xml',
'Content-disposition': 'attachment; filename=habitica-user-data.xml',
});
res.status(200).send(js2xml('user', userData));
res.status(200).send(js2xml.parse('user', userData, {
cdataInvalidChars: true,
declaration: {
include: false,
},
}));
},
};

View File

@@ -270,10 +270,10 @@ api.subscribe = async function subscribe (options) {
let priceOfSingleMember = 3;
if (groupId) {
let groupFields = basicGroupFields.concat(' purchased');
let group = await Group.getGroup({user, groupId, populateLeader: false, groupFields});
amount = sub.price + (group.memberCount - leaderCount) * priceOfSingleMember;
const groupFields = basicGroupFields.concat(' purchased');
const group = await Group.getGroup({user, groupId, populateLeader: false, groupFields});
const membersCount = await group.getMemberCount();
amount = sub.price + (membersCount - leaderCount) * priceOfSingleMember;
}
await this.setBillingAgreementDetails({

View File

@@ -33,16 +33,16 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers
let correctReceipt = false;
// Purchasing one item at a time (processing of await(s) below is sequential not parallel)
for (let index in purchaseDataList) { // eslint-disable-line no-await-in-loop
for (let index in purchaseDataList) {
let purchaseData = purchaseDataList[index];
let token = purchaseData.transactionId;
let existingReceipt = await IapPurchaseReceipt.findOne({
let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop
_id: token,
}).exec();
if (!existingReceipt) {
await IapPurchaseReceipt.create({
await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop
_id: token,
consumed: true,
userId: user._id,

View File

@@ -80,6 +80,10 @@ let bannedWords = [
'oh, god',
'g\\*d',
'bugger',
'buggery',
'buggering',
'buggered',
'shit',
'shitty',
'shitting',

View File

@@ -1,8 +1,8 @@
import { createTransport } from 'nodemailer';
import nodemailer from 'nodemailer';
import nconf from 'nconf';
import { TAVERN_ID } from '../models/group';
import { encrypt } from './encryption';
import request from 'request';
import got from 'got';
import logger from './logger';
import common from '../../common';
@@ -16,7 +16,7 @@ const EMAIL_SERVER = {
};
const BASE_URL = nconf.get('BASE_URL');
let smtpTransporter = createTransport({
let smtpTransporter = nodemailer.createTransport({
service: nconf.get('SMTP_SERVICE'),
auth: {
user: nconf.get('SMTP_USER'),
@@ -150,13 +150,10 @@ export function sendTxn (mailingInfoArray, emailType, variables, personalVariabl
}
if (IS_PROD && mailingInfoArray.length > 0) {
request.post({
url: `${EMAIL_SERVER.url}/job`,
auth: {
user: EMAIL_SERVER.auth.user,
pass: EMAIL_SERVER.auth.password,
},
json: {
got.post(`${EMAIL_SERVER.url}/job`, {
auth: `${EMAIL_SERVER.auth.user}:${EMAIL_SERVER.auth.password}`,
json: true,
body: {
type: 'email',
data: {
emailType,
@@ -170,6 +167,6 @@ export function sendTxn (mailingInfoArray, emailType, variables, personalVariabl
backoff: {delay: 10 * 60 * 1000, type: 'fixed'},
},
},
}, (err) => logger.error(err));
}).catch((err) => logger.error(err));
}
}

View File

@@ -1,8 +1,7 @@
import _ from 'lodash';
import nconf from 'nconf';
// TODO remove this lib and use directly the apn module
// @TODO remove this lib and use directly the apn module
import pushNotify from 'push-notify';
import apnLib from 'apn';
import logger from './logger';
import Bluebird from 'bluebird';
import {
@@ -44,21 +43,9 @@ if (APN_ENABLED) {
apn.on('transmissionError', (errorCode, notification, device) => {
logger.error('APN transmissionError', errorCode, notification, device);
});
let feedback = new apnLib.Feedback({
key,
cert,
batchFeedback: true,
interval: 3600, // Check for feedback once an hour
});
feedback.on('feedback', (devices) => {
if (devices && devices.length > 0) {
logger.info('Delivery of push notifications failed for some Apple devices.', devices);
}
});
});
}
function sendNotification (user, details = {}) {
if (!user) throw new Error('User is required.');
if (user.preferences.pushNotifications.unsubscribeFromAll === true) return;

View File

@@ -0,0 +1,43 @@
import Kafka from 'node-rdkafka';
import nconf from 'nconf';
const GROUP_ID = nconf.get('KAFKA:GROUP_ID');
const CLOUDKARAFKA_BROKERS = nconf.get('KAFKA:CLOUDKARAFKA_BROKERS');
const CLOUDKARAFKA_USERNAME = nconf.get('KAFKA:CLOUDKARAFKA_USERNAME');
const CLOUDKARAFKA_PASSWORD = nconf.get('KAFKA:CLOUDKARAFKA_PASSWORD');
const CLOUDKARAFKA_TOPIC_PREFIX = nconf.get('KAFKA:CLOUDKARAFKA_TOPIC_PREFIX');
const kafkaConf = {
'group.id': GROUP_ID,
'metadata.broker.list': CLOUDKARAFKA_BROKERS ? CLOUDKARAFKA_BROKERS.split(',') : '',
'socket.keepalive.enable': true,
'security.protocol': 'SASL_SSL',
'sasl.mechanisms': 'SCRAM-SHA-256',
'sasl.username': CLOUDKARAFKA_USERNAME,
'sasl.password': CLOUDKARAFKA_PASSWORD,
debug: 'generic,broker,security',
};
const prefix = CLOUDKARAFKA_TOPIC_PREFIX;
const topic = `${prefix}-default`;
const producer = new Kafka.Producer(kafkaConf);
producer.connect();
process.on('exit', () => {
if (producer.isConnected()) producer.disconnect();
});
const api = {};
api.sendMessage = function sendMessage (message, key) {
if (!producer.isConnected()) return;
try {
producer.produce(topic, -1, new Buffer(JSON.stringify(message)), key);
} catch (e) {
// @TODO: Send the to loggly?
}
};
module.exports = api;

View File

@@ -0,0 +1,121 @@
import Bluebird from 'bluebird';
import { model as User } from '../models/user';
import * as Tasks from '../models/task';
import {
NotFound,
BadRequest,
} from './errors';
// @TODO: After refactoring individual spells, move quantity to the calculations
async function castTaskSpell (res, req, targetId, user, spell, quantity = 1) {
if (!targetId) throw new BadRequest(res.t('targetIdUUID'));
const task = await Tasks.Task.findOne({
_id: targetId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast'));
if (task.group.id) throw new BadRequest(res.t('groupTasksNoCast'));
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, task, req);
}
const results = await Bluebird.all([
user.save(),
task.save(),
]);
return results;
}
async function castMultiTaskSpell (req, user, spell, quantity = 1) {
const tasks = await Tasks.Task.find({
userId: user._id,
...Tasks.taskIsGroupOrChallengeQuery,
}).exec();
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, tasks, req);
}
const toSave = tasks
.filter(t => t.isModified())
.map(t => t.save());
toSave.unshift(user.save());
const saved = await Bluebird.all(toSave);
const response = {
tasks: saved,
user,
};
return response;
}
async function castSelfSpell (req, user, spell, quantity = 1) {
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, null, req);
}
await user.save();
}
async function castPartySpell (req, party, partyMembers, user, spell, quantity = 1) {
if (!party) {
partyMembers = [user]; // Act as solo party
} else {
partyMembers = await User
.find({
'party._id': party._id,
_id: { $ne: user._id }, // add separately
})
// .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save
// default values for non-selected fields and pre('save') will mess up thinking some values are missing
.exec();
partyMembers.unshift(user);
}
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, partyMembers, req);
}
await Bluebird.all(partyMembers.map(m => m.save()));
return partyMembers;
}
async function castUserSpell (res, req, party, partyMembers, targetId, user, spell, quantity = 1) {
if (!party && (!targetId || user._id === targetId)) {
partyMembers = user;
} else {
if (!targetId) throw new BadRequest(res.t('targetIdUUID'));
if (!party) throw new NotFound(res.t('partyNotFound'));
partyMembers = await User
.findOne({_id: targetId, 'party._id': party._id})
// .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save
// default values for non-selected fields and pre('save') will mess up thinking some values are missing
.exec();
}
if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId}));
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, partyMembers, req);
}
if (partyMembers !== user) {
await Bluebird.all([
user.save(),
partyMembers.save(),
]);
} else {
await partyMembers.save(); // partyMembers is user
}
return partyMembers;
}
export {castTaskSpell, castMultiTaskSpell, castSelfSpell, castPartySpell, castUserSpell};

View File

@@ -97,9 +97,10 @@ api.checkout = async function checkout (options, stripeInc) {
if (groupId) {
customerObject.quantity = sub.quantity;
let groupFields = basicGroupFields.concat(' purchased');
let group = await Group.getGroup({user, groupId, populateLeader: false, groupFields});
customerObject.quantity = group.memberCount + sub.quantity - 1;
const groupFields = basicGroupFields.concat(' purchased');
const group = await Group.getGroup({user, groupId, populateLeader: false, groupFields});
const membersCount = await group.getMemberCount();
customerObject.quantity = membersCount + sub.quantity - 1;
}
response = await stripeApi.customers.create(customerObject);

View File

@@ -1,21 +1,21 @@
import { post } from 'request';
import got from 'got';
import { isURL } from 'validator';
import logger from './logger';
import nconf from 'nconf';
const IS_PRODUCTION = nconf.get('IS_PROD');
function sendWebhook (url, body) {
post({
url,
got.post(url, {
body,
json: true,
}, (err) => {
if (err) {
logger.error(err);
}
});
}).catch(err => logger.error(err));
}
function isValidWebhook (hook) {
return hook.enabled && isURL(hook.url);
return hook.enabled && isURL(hook.url, {
require_tld: IS_PRODUCTION ? true : false, // eslint-disable-line camelcase
});
}
export class WebhookSender {

View File

@@ -442,6 +442,16 @@ schema.methods.isMember = function isGroupMember (user) {
}
};
schema.methods.getMemberCount = async function getMemberCount () {
let query = { guilds: this._id };
if (this.type === 'party') {
query = { 'party._id': this._id };
}
return await User.count(query).exec();
};
export function chatDefaults (msg, user) {
let message = {
id: shared.uuid(),

View File

@@ -144,7 +144,7 @@ TaskSchema.statics.findByIdOrAlias = async function findByIdOrAlias (identifier,
let query = _.cloneDeep(additionalQueries);
if (validator.isUUID(identifier)) {
if (validator.isUUID(String(identifier))) {
query._id = identifier;
} else {
query.userId = userId;

View File

@@ -253,8 +253,9 @@ schema.pre('save', true, function preSaveUser (next, done) {
}
// Manage unallocated stats points notifications
if (this.isSelected('stats') && this.isSelected('notifications')) {
if (this.isSelected('stats') && this.isSelected('notifications') && this.isSelected('flags') && this.isSelected('preferences')) {
const pointsToAllocate = this.stats.points;
const classNotEnabled = !this.flags.classSelected || this.preferences.disableClasses;
// Sometimes there can be more than 1 notification
const existingNotifications = this.notifications.filter(notification => {
@@ -271,7 +272,7 @@ schema.pre('save', true, function preSaveUser (next, done) {
let notificationsToRemove = outdatedNotification ? existingNotificationsLength : existingNotificationsLength - 1;
// If there are points to allocate and the notification is outdated, add a new notifications
if (pointsToAllocate > 0 && outdatedNotification) {
if (pointsToAllocate > 0 && !classNotEnabled && outdatedNotification) {
this.addNotification('UNALLOCATED_STATS_POINTS', { points: pointsToAllocate });
}

View File

@@ -5,7 +5,9 @@ import shared from '../../common';
import {v4 as uuid} from 'uuid';
import _ from 'lodash';
import { BadRequest } from '../libs/errors';
import nconf from 'nconf';
const IS_PRODUCTION = nconf.get('IS_PROD');
const Schema = mongoose.Schema;
const TASK_ACTIVITY_DEFAULT_OPTIONS = Object.freeze({
@@ -36,7 +38,11 @@ export let schema = new Schema({
url: {
type: String,
required: true,
validate: [validator.isURL, shared.i18n.t('invalidUrl')],
validate: [(v) => {
return validator.isURL(v, {
require_tld: IS_PRODUCTION ? true : false, // eslint-disable-line camelcase
});
}, shared.i18n.t('invalidUrl')],
},
enabled: { type: Boolean, required: true, default: true },
options: {
@@ -72,7 +78,7 @@ schema.methods.formatOptions = function formatOptions (res) {
} else if (this.type === 'groupChatReceived') {
this.options = _.pick(this.options, 'groupId');
if (!validator.isUUID(this.options.groupId)) {
if (!validator.isUUID(String(this.options.groupId))) {
throw new BadRequest(res.t('groupIdRequired'));
}
}