Move inbox to its own model (#10428)

* shared model for chat and inbox

* disable inbox schema

* inbox: use separate model

* remove old code that used group.chat

* add back chat field (not used) and remove old tests

* remove inbox exclusions when loading user

* add GET /api/v3/inbox/messages

* add comment

* implement DELETE /inbox/messages/:messageid in v4

* implement GET /inbox/messages in v4 and update tests

* implement DELETE /api/v4/inbox/clear

* fix url

* fix doc

* update /export/inbox.html

* update other data exports

* add back messages in user schema

* add user.toJSONWithInbox

* add compativility until migration is done

* more compatibility

* fix tojson called twice

* add compatibility methods

* fix common tests

* fix v4 integration tests

* v3 get user -> with inbox

* start to fix tests

* fix v3 integration tests

* wip

* wip, client use new route

* update tests for members/send-private-message

* tests for get user in v4

* add tests for DELETE /inbox/messages/:messageId

* add tests for DELETE /inbox/clear in v4

* update docs

* fix tests

* initial migration

* fix migration

* fix migration

* migration fixes

* migrate api.enterCouponCode

* migrate api.castSpell

* migrate reset, reroll, rebirth

* add routes to v4 version

* fix tests

* fixes

* api.updateUser

* remove .only

* get user -> userLib

* refactor inbox.vue to work with new data model

* fix return message when messaging yourself

* wip fix bug with new conversation

* wip

* fix remaining ui issues

* move api.registerLocal, fixes

* keep only v3 version of GET /inbox/messages
This commit is contained in:
Matteo Pagliazzi
2018-09-21 15:12:20 +02:00
committed by GitHub
parent bb7d447003
commit 26c8323e70
61 changed files with 3174 additions and 1095 deletions

View File

@@ -0,0 +1,123 @@
const migrationName = '20180811_inboxOutsideUser.js';
const authorName = 'paglias'; // in case script author needs to know when their ...
const authorUuid = 'ed4c688c-6652-4a92-9d03-a5a79844174a'; // ... own data is done
/*
* Move inbox messages from the user model to their own collection
*/
const monk = require('monk');
const nconf = require('nconf');
const Inbox = require('../website/server/models/message').inboxModel;
const connectionString = nconf.get('MIGRATION_CONNECT_STRING'); // FOR TEST DATABASE
const dbInboxes = monk(connectionString).get('inboxes', { castIds: false });
const dbUsers = monk(connectionString).get('users', { castIds: false });
function processUsers (lastId) {
let query = {
migration: {$ne: migrationName},
};
if (lastId) {
query._id = {
$gt: lastId,
};
}
dbUsers.find(query, {
sort: {_id: 1},
limit: 1000,
fields: ['_id', 'inbox'],
})
.then(updateUsers)
.catch((err) => {
console.log(err);
return exiting(1, `ERROR! ${ err}`);
});
}
let progressCount = 1000;
let count = 0;
let msgCount = 0;
function updateUsers (users) {
if (!users || users.length === 0) {
console.warn('All appropriate users and their tasks found and modified.');
displayData();
return;
}
let usersPromises = users.map(updateUser);
let lastUser = users[users.length - 1];
return Promise.all(usersPromises)
.then(() => {
return processUsers(lastUser._id);
});
}
function updateUser (user) {
count++;
if (count % progressCount === 0) console.warn(`${count } ${ user._id}`);
if (msgCount % progressCount === 0) console.warn(`${msgCount } messages processed`);
if (user._id === authorUuid) console.warn(`${authorName } being processed`);
const oldInboxMessages = user.inbox.messages || {};
const oldInboxMessagesIds = Object.keys(oldInboxMessages);
msgCount += oldInboxMessagesIds.length;
const newInboxMessages = oldInboxMessagesIds.map(msgId => {
const msg = oldInboxMessages[msgId];
if (!msg || (!msg.id && !msg._id)) { // eslint-disable-line no-extra-parens
console.log('missing message or message _id and id', msg);
throw new Error('error!');
}
if (msg.id && !msg._id) msg._id = msg.id;
if (msg._id && !msg.id) msg.id = msg._id;
const newMsg = new Inbox(msg);
newMsg.ownerId = user._id;
return newMsg.toJSON();
});
return dbInboxes.insert(newInboxMessages)
.then(() => {
return dbUsers.update({_id: user._id}, {
$set: {
migration: migrationName,
'inbox.messages': {},
},
});
})
.catch((err) => {
console.log(err);
return exiting(1, `ERROR! ${ err}`);
});
}
function displayData () {
console.warn(`\n${ count } users processed\n`);
console.warn(`\n${ msgCount } messages processed\n`);
return exiting(0);
}
function exiting (code, msg) {
code = code || 0; // 0 = success
if (code && !msg) {
msg = 'ERROR!';
}
if (msg) {
if (code) {
console.error(msg);
} else {
console.log(msg);
}
}
process.exit(code);
}
module.exports = processUsers;

View File

@@ -1020,32 +1020,6 @@ describe('Group Model', () => {
expect(chat.user).to.not.exist;
});
it('cuts down chat to 200 messages', () => {
for (let i = 0; i < 220; i++) {
party.chat.push({ text: 'a message' });
}
expect(party.chat).to.have.a.lengthOf(220);
party.sendChat('message');
expect(party.chat).to.have.a.lengthOf(200);
});
it('cuts down chat to 400 messages when group is subcribed', () => {
party.purchased.plan.customerId = 'test-customer-id';
for (let i = 0; i < 420; i++) {
party.chat.push({ text: 'a message' });
}
expect(party.chat).to.have.a.lengthOf(420);
party.sendChat('message');
expect(party.chat).to.have.a.lengthOf(400);
});
it('updates users about new messages in party', () => {
party.sendChat('message');

View File

@@ -38,6 +38,7 @@ describe('GET /inbox/messages', () => {
// message to yourself
expect(messages[0].text).to.equal('fourth');
expect(messages[0].sent).to.equal(false);
expect(messages[0].uuid).to.equal(user._id);
expect(messages[1].text).to.equal('third');

View File

@@ -4,7 +4,7 @@ import {
generateUser,
sleep,
} from '../../../../helpers/api-integration/v3';
import { model as Chat } from '../../../../../website/server/models/chat';
import { chatModel as Chat } from '../../../../../website/server/models/message';
describe('POST /groups/:groupId/quests/accept', () => {
const PET_QUEST = 'whale';

View File

@@ -4,7 +4,7 @@ import {
generateUser,
sleep,
} from '../../../../helpers/api-integration/v3';
import { model as Chat } from '../../../../../website/server/models/chat';
import { chatModel as Chat } from '../../../../../website/server/models/message';
describe('POST /groups/:groupId/quests/force-start', () => {
const PET_QUEST = 'whale';

View File

@@ -5,7 +5,7 @@ import {
} from '../../../../helpers/api-integration/v3';
import { v4 as generateUUID } from 'uuid';
import { quests as questScrolls } from '../../../../../website/common/script/content';
import { model as Chat } from '../../../../../website/server/models/chat';
import { chatModel as Chat } from '../../../../../website/server/models/message';
import apiError from '../../../../../website/server/libs/apiError';
describe('POST /groups/:groupId/quests/invite/:questKey', () => {

View File

@@ -5,7 +5,7 @@ import {
sleep,
} from '../../../../helpers/api-integration/v3';
import { v4 as generateUUID } from 'uuid';
import { model as Chat } from '../../../../../website/server/models/chat';
import { chatModel as Chat } from '../../../../../website/server/models/message';
describe('POST /groups/:groupId/quests/reject', () => {
let questingGroup;

View File

@@ -0,0 +1,62 @@
import {
generateUser,
translate as t,
resetHabiticaDB,
} from '../../../helpers/api-integration/v4';
describe('POST /coupons/enter/:code', () => {
let user;
let sudoUser;
before(async () => {
await resetHabiticaDB();
});
beforeEach(async () => {
user = await generateUser();
sudoUser = await generateUser({
'contributor.sudo': true,
});
});
it('returns an error if code is missing', async () => {
await expect(user.post('/coupons/enter')).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: 'Not found.',
});
});
it('returns an error if code is invalid', async () => {
await expect(user.post('/coupons/enter/notValid')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidCoupon'),
});
});
it('returns an error if coupon has been used', async () => {
let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1');
await user.post(`/coupons/enter/${coupon._id}`); // use coupon
await expect(user.post(`/coupons/enter/${coupon._id}`)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('couponUsed'),
});
});
it('should apply the coupon to the user', async () => {
let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1');
let userRes = await user.post(`/coupons/enter/${coupon._id}`);
expect(userRes._id).to.equal(user._id);
expect(userRes.items.gear.owned.eyewear_special_wondercon_red).to.be.true;
expect(userRes.items.gear.owned.eyewear_special_wondercon_black).to.be.true;
expect(userRes.items.gear.owned.back_special_wondercon_black).to.be.true;
expect(userRes.items.gear.owned.back_special_wondercon_red).to.be.true;
expect(userRes.items.gear.owned.body_special_wondercon_red).to.be.true;
expect(userRes.items.gear.owned.body_special_wondercon_black).to.be.true;
expect(userRes.items.gear.owned.body_special_wondercon_gold).to.be.true;
expect(userRes.extra).to.eql({signupEvent: 'wondercon'});
});
});

View File

@@ -0,0 +1,30 @@
import {
generateUser,
} from '../../../helpers/api-integration/v4';
describe('DELETE /inbox/clear', () => {
it('removes all inbox messages for the user', async () => {
const [user, otherUser] = await Promise.all([generateUser(), generateUser()]);
await otherUser.post('/members/send-private-message', {
toUserId: user.id,
message: 'first',
});
await user.post('/members/send-private-message', {
toUserId: otherUser.id,
message: 'second',
});
await otherUser.post('/members/send-private-message', {
toUserId: user.id,
message: 'third',
});
let messages = await user.get('/inbox/messages');
expect(messages.length).to.equal(3);
await user.del('/inbox/clear/');
messages = await user.get('/inbox/messages');
expect(messages.length).to.equal(0);
});
});

View File

@@ -0,0 +1,58 @@
import {
generateUser,
} from '../../../helpers/api-integration/v4';
import common from '../../../../website/common';
describe('GET /user', () => {
let user;
before(async () => {
user = await generateUser();
});
it('returns the authenticated user with computed stats', async () => {
let returnedUser = await user.get('/user');
expect(returnedUser._id).to.equal(user._id);
expect(returnedUser.stats.maxMP).to.exist;
expect(returnedUser.stats.maxHealth).to.equal(common.maxHealth);
expect(returnedUser.stats.toNextLevel).to.equal(common.tnl(returnedUser.stats.lvl));
});
it('does not return private paths (and apiToken)', async () => {
let returnedUser = await user.get('/user');
expect(returnedUser.auth.local.hashed_password).to.not.exist;
expect(returnedUser.auth.local.passwordHashMethod).to.not.exist;
expect(returnedUser.auth.local.salt).to.not.exist;
expect(returnedUser.apiToken).to.not.exist;
});
it('returns only user properties requested', async () => {
let returnedUser = await user.get('/user?userFields=achievements,items.mounts');
expect(returnedUser._id).to.equal(user._id);
expect(returnedUser.achievements).to.exist;
expect(returnedUser.items.mounts).to.exist;
// Notifications are always returned
expect(returnedUser.notifications).to.exist;
expect(returnedUser.stats).to.not.exist;
});
it('does not return new inbox messages', async () => {
const otherUser = await generateUser();
await otherUser.post('/members/send-private-message', {
toUserId: user.id,
message: 'first',
});
await otherUser.post('/members/send-private-message', {
toUserId: user.id,
message: 'second',
});
let returnedUser = await user.get('/user');
expect(returnedUser._id).to.equal(user._id);
expect(returnedUser.inbox.messages).to.be.empty;
});
});

View File

@@ -0,0 +1,324 @@
import {
generateUser,
translate as t,
createAndPopulateGroup,
generateGroup,
generateChallenge,
sleep,
} from '../../../helpers/api-integration/v4';
import { v4 as generateUUID } from 'uuid';
import { find } from 'lodash';
import apiError from '../../../../website/server/libs/apiError';
describe('POST /user/class/cast/:spellId', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('returns an error if spell does not exist', async () => {
await user.update({'stats.class': 'rogue'});
let spellId = 'invalidSpell';
await expect(user.post(`/user/class/cast/${spellId}`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: apiError('spellNotFound', {spellId}),
});
});
it('returns an error if spell does not exist in user\'s class', async () => {
let spellId = 'pickPocket';
await expect(user.post(`/user/class/cast/${spellId}`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: apiError('spellNotFound', {spellId}),
});
});
it('returns an error if spell.mana > user.mana', async () => {
await user.update({'stats.class': 'rogue'});
await expect(user.post('/user/class/cast/backStab'))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('notEnoughMana'),
});
});
it('returns an error if spell.value > user.gold', async () => {
await expect(user.post('/user/class/cast/birthday'))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('messageNotEnoughGold'),
});
});
it('returns an error if spell.lvl > user.level', async () => {
await user.update({'stats.mp': 200, 'stats.class': 'wizard'});
await expect(user.post('/user/class/cast/earth'))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('spellLevelTooHigh', {level: 13}),
});
});
it('returns an error if user doesn\'t own the spell', async () => {
await expect(user.post('/user/class/cast/snowball'))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('spellNotOwned'),
});
});
it('returns an error if targetId is not an UUID', async () => {
await expect(user.post('/user/class/cast/spellId?targetId=notAnUUID'))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns an error if targetId is required but missing', async () => {
await user.update({'stats.class': 'rogue', 'stats.lvl': 11});
await expect(user.post('/user/class/cast/pickPocket'))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('targetIdUUID'),
});
});
it('returns an error if targeted task doesn\'t exist', async () => {
await user.update({'stats.class': 'rogue', 'stats.lvl': 11});
await expect(user.post(`/user/class/cast/pickPocket?targetId=${generateUUID()}`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
it('returns an error if a challenge task was targeted', async () => {
let {group, groupLeader} = await createAndPopulateGroup();
let challenge = await generateChallenge(groupLeader, group);
await groupLeader.post(`/challenges/${challenge._id}/join`);
await groupLeader.post(`/tasks/challenge/${challenge._id}`, [
{type: 'habit', text: 'task text'},
]);
await groupLeader.update({'stats.class': 'rogue', 'stats.lvl': 11});
await sleep(0.5);
await groupLeader.sync();
await expect(groupLeader.post(`/user/class/cast/pickPocket?targetId=${groupLeader.tasksOrder.habits[0]}`))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('challengeTasksNoCast'),
});
});
it('returns an error if a group task was targeted', async () => {
let {group, groupLeader} = await createAndPopulateGroup();
let groupTask = await groupLeader.post(`/tasks/group/${group._id}`, {
text: 'todo group',
type: 'todo',
});
await groupLeader.post(`/tasks/${groupTask._id}/assign/${groupLeader._id}`);
let memberTasks = await groupLeader.get('/tasks/user');
let syncedGroupTask = find(memberTasks, function findAssignedTask (memberTask) {
return memberTask.group.id === group._id;
});
await groupLeader.update({'stats.class': 'rogue', 'stats.lvl': 11});
await sleep(0.5);
await groupLeader.sync();
await expect(groupLeader.post(`/user/class/cast/pickPocket?targetId=${syncedGroupTask._id}`))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('groupTasksNoCast'),
});
});
it('returns an error if targeted party member doesn\'t exist', async () => {
let {groupLeader} = await createAndPopulateGroup({
groupDetails: { type: 'party', privacy: 'private' },
members: 1,
});
await groupLeader.update({'items.special.snowball': 3});
let target = generateUUID();
await expect(groupLeader.post(`/user/class/cast/snowball?targetId=${target}`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithIDNotFound', {userId: target}),
});
});
it('returns an error if party does not exists', async () => {
await user.update({'items.special.snowball': 3});
await expect(user.post(`/user/class/cast/snowball?targetId=${generateUUID()}`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('partyNotFound'),
});
});
it('send message in party chat if party && !spell.silent', 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');
await sleep(1);
const groupMessages = await groupLeader.get(`/groups/${group._id}/chat`);
expect(groupMessages[0]).to.exist;
expect(groupMessages[0].uuid).to.equal('system');
});
it('Ethereal Surge does not recover mp of other mages', async () => {
let group = await createAndPopulateGroup({
groupDetails: { type: 'party', privacy: 'private' },
members: 4,
});
let promises = [];
promises.push(group.groupLeader.update({'stats.mp': 200, 'stats.class': 'wizard', 'stats.lvl': 20}));
promises.push(group.members[0].update({'stats.mp': 0, 'stats.class': 'warrior', 'stats.lvl': 20}));
promises.push(group.members[1].update({'stats.mp': 0, 'stats.class': 'wizard', 'stats.lvl': 20}));
promises.push(group.members[2].update({'stats.mp': 0, 'stats.class': 'rogue', 'stats.lvl': 20}));
promises.push(group.members[3].update({'stats.mp': 0, 'stats.class': 'healer', 'stats.lvl': 20}));
await Promise.all(promises);
await group.groupLeader.post('/user/class/cast/mpheal');
promises = [];
promises.push(group.members[0].sync());
promises.push(group.members[1].sync());
promises.push(group.members[2].sync());
promises.push(group.members[3].sync());
await Promise.all(promises);
expect(group.members[0].stats.mp).to.be.greaterThan(0); // warrior
expect(group.members[1].stats.mp).to.equal(0); // wizard
expect(group.members[2].stats.mp).to.be.greaterThan(0); // rogue
expect(group.members[3].stats.mp).to.be.greaterThan(0); // healer
});
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);
group = await groupLeader.get(`/groups/${group._id}`);
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);
await user.post(`/challenges/${challenge._id}/join`);
await user.post(`/tasks/challenge/${challenge._id}`, {
text: 'test challenge habit',
type: 'habit',
});
let groupTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'todo group',
type: 'todo',
});
await user.update({'stats.class': 'healer', 'stats.mp': 200, 'stats.lvl': 15});
await user.post(`/tasks/${groupTask._id}/assign/${user._id}`);
await user.post('/user/class/cast/brightness');
await user.sync();
let memberTasks = await user.get('/tasks/user');
let syncedGroupTask = find(memberTasks, function findAssignedTask (memberTask) {
return memberTask.group.id === guild._id;
});
let userChallengeTask = find(memberTasks, function findAssignedTask (memberTask) {
return memberTask.challenge.id === challenge._id;
});
expect(userChallengeTask).to.exist;
expect(syncedGroupTask).to.exist;
expect(userChallengeTask.value).to.equal(0);
expect(syncedGroupTask.value).to.equal(0);
});
it('increases both user\'s achievement values', async () => {
let party = await createAndPopulateGroup({
members: 1,
});
let leader = party.groupLeader;
let recipient = party.members[0];
await leader.update({'stats.gp': 10});
await leader.post(`/user/class/cast/birthday?targetId=${recipient._id}`);
await leader.sync();
await recipient.sync();
expect(leader.achievements.birthday).to.equal(1);
expect(recipient.achievements.birthday).to.equal(1);
});
it('only increases user\'s achievement one if target == caster', async () => {
await user.update({'stats.gp': 10});
await user.post(`/user/class/cast/birthday?targetId=${user._id}`);
await user.sync();
expect(user.achievements.birthday).to.equal(1);
});
it('passes correct target to spell when targetType === \'task\'', async () => {
await user.update({'stats.class': 'wizard', 'stats.lvl': 11});
let task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
let result = await user.post(`/user/class/cast/fireball?targetId=${task._id}`);
expect(result.task._id).to.equal(task._id);
});
it('passes correct target to spell when targetType === \'self\'', async () => {
await user.update({'stats.class': 'wizard', 'stats.lvl': 14, 'stats.mp': 50});
let result = await user.post('/user/class/cast/frost');
expect(result.user.stats.mp).to.equal(10);
});
// TODO find a way to have sinon working in integration tests
// it doesn't work when tests are running separately from server
it('passes correct target to spell when targetType === \'tasks\'');
it('passes correct target to spell when targetType === \'party\'');
it('passes correct target to spell when targetType === \'user\'');
it('passes correct target to spell when targetType === \'party\' and user is not in a party');
it('passes correct target to spell when targetType === \'user\' and user is not in a party');
});

View File

@@ -0,0 +1,60 @@
import {
generateUser,
generateDaily,
generateReward,
translate as t,
} from '../../../helpers/api-integration/v4';
describe('POST /user/rebirth', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('returns an error when user balance is too low', async () => {
await expect(user.post('/user/rebirth'))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('notEnoughGems'),
});
});
// More tests in common code unit tests
it('resets user\'s tasks', async () => {
await user.update({
balance: 1.5,
});
let daily = await generateDaily({
text: 'test habit',
type: 'daily',
value: 1,
streak: 1,
userId: user._id,
});
let reward = await generateReward({
text: 'test reward',
type: 'reward',
value: 1,
userId: user._id,
});
let response = await user.post('/user/rebirth');
await user.sync();
expect(user.notifications.length).to.equal(1);
expect(user.notifications[0].type).to.equal('REBIRTH_ACHIEVEMENT');
let updatedDaily = await user.get(`/tasks/${daily._id}`);
let updatedReward = await user.get(`/tasks/${reward._id}`);
expect(response.message).to.equal(t('rebirthComplete'));
expect(updatedDaily.streak).to.equal(0);
expect(updatedDaily.value).to.equal(0);
expect(updatedReward.value).to.equal(1);
});
});

View File

@@ -0,0 +1,54 @@
import {
generateUser,
generateDaily,
generateReward,
translate as t,
} from '../../../helpers/api-integration/v4';
describe('POST /user/reroll', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('returns an error when user balance is too low', async () => {
await expect(user.post('/user/reroll'))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('notEnoughGems'),
});
});
// More tests in common code unit tests
it('resets user\'s tasks', async () => {
await user.update({
balance: 2,
});
let daily = await generateDaily({
text: 'test habit',
type: 'daily',
userId: user._id,
});
let reward = await generateReward({
text: 'test reward',
type: 'reward',
value: 1,
userId: user._id,
});
let response = await user.post('/user/reroll');
await user.sync();
let updatedDaily = await user.get(`/tasks/${daily._id}`);
let updatedReward = await user.get(`/tasks/${reward._id}`);
expect(response.message).to.equal(t('fortifyComplete'));
expect(updatedDaily.value).to.equal(0);
expect(updatedReward.value).to.equal(1);
});
});

View File

@@ -0,0 +1,121 @@
import {
generateUser,
generateGroup,
generateChallenge,
translate as t,
} from '../../../helpers/api-integration/v4';
import { find } from 'lodash';
describe('POST /user/reset', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
// More tests in common code unit tests
it('resets user\'s habits', async () => {
let task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
await user.post('/user/reset');
await user.sync();
await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
expect(user.tasksOrder.habits).to.be.empty;
});
it('resets user\'s dailys', async () => {
let task = await user.post('/tasks/user', {
text: 'test daily',
type: 'daily',
});
await user.post('/user/reset');
await user.sync();
await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
expect(user.tasksOrder.dailys).to.be.empty;
});
it('resets user\'s todos', async () => {
let task = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
});
await user.post('/user/reset');
await user.sync();
await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
expect(user.tasksOrder.todos).to.be.empty;
});
it('resets user\'s rewards', async () => {
let task = await user.post('/tasks/user', {
text: 'test reward',
type: 'reward',
});
await user.post('/user/reset');
await user.sync();
await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
expect(user.tasksOrder.rewards).to.be.empty;
});
it('does not delete challenge or group tasks', async () => {
let guild = await generateGroup(user);
let challenge = await generateChallenge(user, guild);
await user.post(`/challenges/${challenge._id}/join`);
await user.post(`/tasks/challenge/${challenge._id}`, {
text: 'test challenge habit',
type: 'habit',
});
let groupTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'todo group',
type: 'todo',
});
await user.post(`/tasks/${groupTask._id}/assign/${user._id}`);
await user.post('/user/reset');
await user.sync();
let memberTasks = await user.get('/tasks/user');
let syncedGroupTask = find(memberTasks, function findAssignedTask (memberTask) {
return memberTask.group.id === guild._id;
});
let userChallengeTask = find(memberTasks, function findAssignedTask (memberTask) {
return memberTask.challenge.id === challenge._id;
});
expect(userChallengeTask).to.exist;
expect(syncedGroupTask).to.exist;
});
});

View File

@@ -0,0 +1,256 @@
import {
generateUser,
translate as t,
} from '../../../helpers/api-integration/v4';
import { each, get } from 'lodash';
describe('PUT /user', () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
context('Allowed Operations', () => {
it('updates the user', async () => {
await user.put('/user', {
'profile.name': 'Frodo',
'preferences.costume': true,
'stats.hp': 14,
});
await user.sync();
expect(user.profile.name).to.eql('Frodo');
expect(user.preferences.costume).to.eql(true);
expect(user.stats.hp).to.eql(14);
});
it('tags must be an array', async () => {
await expect(user.put('/user', {
tags: {
tag: true,
},
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'mustBeArray',
});
});
it('update tags', async () => {
let userTags = user.tags;
await user.put('/user', {
tags: [...user.tags, {
name: 'new tag',
}],
});
await user.sync();
expect(user.tags.length).to.be.eql(userTags.length + 1);
});
it('profile.name cannot be an empty string or null', async () => {
await expect(user.put('/user', {
'profile.name': ' ', // string should be trimmed
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'User validation failed',
});
await expect(user.put('/user', {
'profile.name': '',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'User validation failed',
});
await expect(user.put('/user', {
'profile.name': null,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'User validation failed',
});
});
});
context('Top Level Protected Operations', () => {
let protectedOperations = {
'gem balance': {balance: 100},
auth: {'auth.blocked': true, 'auth.timestamps.created': new Date()},
contributor: {'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text'},
backer: {'backer.tier': 10, 'backer.npc': 'Bilbo'},
subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000},
'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true},
notifications: [{type: 123}],
webhooks: {webhooks: [{url: 'https://foobar.com'}]},
};
each(protectedOperations, (data, testName) => {
it(`does not allow updating ${testName}`, async () => {
let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] });
await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: errorText,
});
});
});
});
context('Sub-Level Protected Operations', () => {
let protectedOperations = {
'class stat': {'stats.class': 'wizard'},
'flags unless whitelisted': {'flags.dropsEnabled': true},
webhooks: {'preferences.webhooks': [1, 2, 3]},
sleep: {'preferences.sleep': true},
'disable classes': {'preferences.disableClasses': true},
};
each(protectedOperations, (data, testName) => {
it(`does not allow updating ${testName}`, async () => {
let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] });
await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: errorText,
});
});
});
});
context('Default Appearance Preferences', () => {
let testCases = {
shirt: 'yellow',
skin: 'ddc994',
'hair.color': 'blond',
'hair.bangs': 2,
'hair.base': 1,
'hair.flower': 4,
size: 'broad',
};
each(testCases, (item, type) => {
const update = {};
update[`preferences.${type}`] = item;
it(`updates user with ${type} that is a default`, async () => {
let dbUpdate = {};
dbUpdate[`purchased.${type}.${item}`] = true;
await user.update(dbUpdate);
// Sanity checks to make sure user is not already equipped with item
expect(get(user.preferences, type)).to.not.eql(item);
let updatedUser = await user.put('/user', update);
expect(get(updatedUser.preferences, type)).to.eql(item);
});
});
it('returns an error if user tries to update body size with invalid type', async () => {
await expect(user.put('/user', {
'preferences.size': 'round',
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('mustPurchaseToSet', { val: 'round', key: 'preferences.size' }),
});
});
it('can set beard to default', async () => {
await user.update({
'purchased.hair.beard': 3,
'preferences.hair.beard': 3,
});
let updatedUser = await user.put('/user', {
'preferences.hair.beard': 0,
});
expect(updatedUser.preferences.hair.beard).to.eql(0);
});
it('can set mustache to default', async () => {
await user.update({
'purchased.hair.mustache': 2,
'preferences.hair.mustache': 2,
});
let updatedUser = await user.put('/user', {
'preferences.hair.mustache': 0,
});
expect(updatedUser.preferences.hair.mustache).to.eql(0);
});
});
context('Purchasable Appearance Preferences', () => {
let testCases = {
background: 'volcano',
shirt: 'convict',
skin: 'cactus',
'hair.base': 7,
'hair.beard': 2,
'hair.color': 'rainbow',
'hair.mustache': 2,
};
each(testCases, (item, type) => {
const update = {};
update[`preferences.${type}`] = item;
it(`returns an error if user tries to update ${type} with ${type} the user does not own`, async () => {
await expect(user.put('/user', update)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('mustPurchaseToSet', {val: item, key: `preferences.${type}`}),
});
});
it(`updates user with ${type} user does own`, async () => {
let dbUpdate = {};
dbUpdate[`purchased.${type}.${item}`] = true;
await user.update(dbUpdate);
// Sanity check to make sure user is not already equipped with item
expect(get(user.preferences, type)).to.not.eql(item);
let updatedUser = await user.put('/user', update);
expect(get(updatedUser.preferences, type)).to.eql(item);
});
});
});
context('Improvement Categories', () => {
it('sets valid categories', async () => {
await user.put('/user', {
'preferences.improvementCategories': ['work', 'school'],
});
await user.sync();
expect(user.preferences.improvementCategories).to.eql(['work', 'school']);
});
it('discards invalid categories', async () => {
await expect(user.put('/user', {
'preferences.improvementCategories': ['work', 'procrastination', 'school'],
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'User validation failed',
});
});
});
});

View File

@@ -0,0 +1,739 @@
import {
generateUser,
requester,
translate as t,
createAndPopulateGroup,
getProperty,
} from '../../../../helpers/api-integration/v4';
import { ApiUser } from '../../../../helpers/api-integration/api-classes';
import { v4 as uuid } from 'uuid';
import { each } from 'lodash';
import { encrypt } from '../../../../../website/server/libs/encryption';
function generateRandomUserName () {
return (Date.now() + uuid()).substring(0, 20);
}
describe('POST /user/auth/local/register', () => {
context('username and email are free', () => {
let api;
beforeEach(async () => {
api = requester();
});
it('registers a new user', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user._id).to.exist;
expect(user.apiToken).to.exist;
expect(user.auth.local.username).to.eql(username);
expect(user.profile.name).to.eql(username);
expect(user.newUser).to.eql(true);
});
xit('remove spaces from username', async () => {
// TODO can probably delete this test now
let username = ' usernamewithspaces ';
let email = 'test@example.com';
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.auth.local.username).to.eql(username.trim());
expect(user.profile.name).to.eql(username.trim());
});
context('validates username', () => {
const email = 'test@example.com';
const password = 'password';
it('requires to username to be less than 20', async () => {
const username = (Date.now() + uuid()).substring(0, 21);
await expect(api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid request parameters.',
});
});
it('rejects chracters not in [-_a-zA-Z0-9]', async () => {
const username = 'a-zA_Z09*';
await expect(api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid request parameters.',
});
});
it('allows only [-_a-zA-Z0-9] characters', async () => {
const username = 'a-zA_Z09';
const user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.auth.local.username).to.eql(username);
});
});
context('provides default tags and tasks', async () => {
it('for a generic API consumer', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(0);
expect(dailys).to.have.a.lengthOf(0);
expect(todos).to.have.a.lengthOf(1);
expect(rewards).to.have.a.lengthOf(0);
expect(tags).to.have.a.lengthOf(7);
expect(tags[0].name).to.eql(t('defaultTag1'));
expect(tags[1].name).to.eql(t('defaultTag2'));
expect(tags[2].name).to.eql(t('defaultTag3'));
expect(tags[3].name).to.eql(t('defaultTag4'));
expect(tags[4].name).to.eql(t('defaultTag5'));
expect(tags[5].name).to.eql(t('defaultTag6'));
expect(tags[6].name).to.eql(t('defaultTag7'));
});
xit('for Web', async () => {
api = requester(
null,
{'x-client': 'habitica-web'},
);
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(3);
expect(habits[0].text).to.eql(t('defaultHabit1Text'));
expect(habits[0].notes).to.eql('');
expect(habits[1].text).to.eql(t('defaultHabit2Text'));
expect(habits[1].notes).to.eql('');
expect(habits[2].text).to.eql(t('defaultHabit3Text'));
expect(habits[2].notes).to.eql('');
expect(dailys).to.have.a.lengthOf(0);
expect(todos).to.have.a.lengthOf(1);
expect(todos[0].text).to.eql(t('defaultTodo1Text'));
expect(todos[0].notes).to.eql(t('defaultTodoNotes'));
expect(rewards).to.have.a.lengthOf(1);
expect(rewards[0].text).to.eql(t('defaultReward1Text'));
expect(rewards[0].notes).to.eql('');
expect(tags).to.have.a.lengthOf(7);
expect(tags[0].name).to.eql(t('defaultTag1'));
expect(tags[1].name).to.eql(t('defaultTag2'));
expect(tags[2].name).to.eql(t('defaultTag3'));
expect(tags[3].name).to.eql(t('defaultTag4'));
expect(tags[4].name).to.eql(t('defaultTag5'));
expect(tags[5].name).to.eql(t('defaultTag6'));
expect(tags[6].name).to.eql(t('defaultTag7'));
});
});
context('does not provide default tags and tasks', async () => {
it('for Android', async () => {
api = requester(
null,
{'x-client': 'habitica-android'},
);
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(0);
expect(dailys).to.have.a.lengthOf(0);
expect(todos).to.have.a.lengthOf(0);
expect(rewards).to.have.a.lengthOf(0);
expect(tags).to.have.a.lengthOf(0);
});
it('for iOS', async () => {
api = requester(
null,
{'x-client': 'habitica-ios'},
);
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(0);
expect(dailys).to.have.a.lengthOf(0);
expect(todos).to.have.a.lengthOf(0);
expect(rewards).to.have.a.lengthOf(0);
expect(tags).to.have.a.lengthOf(0);
});
});
it('enrolls new users in an A/B test', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
await expect(getProperty('users', user._id, '_ABtests')).to.eventually.be.a('object');
});
it('includes items awarded by default when creating a new user', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.items.quests.dustbunnies).to.equal(1);
expect(user.purchased.background.violet).to.be.ok;
expect(user.preferences.background).to.equal('violet');
});
it('requires password and confirmPassword to match', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let confirmPassword = 'not password';
await expect(api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('requires a username', async () => {
let email = `${generateRandomUserName()}@example.com`;
let password = 'password';
let confirmPassword = 'password';
await expect(api.post('/user/auth/local/register', {
email,
password,
confirmPassword,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('requires an email', async () => {
let username = generateRandomUserName();
let password = 'password';
await expect(api.post('/user/auth/local/register', {
username,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('requires a valid email', async () => {
let username = generateRandomUserName();
let email = 'notanemail@sdf';
let password = 'password';
await expect(api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('sanitizes email params to a lowercase string before creating the user', async () => {
let username = generateRandomUserName();
let email = 'ISANEmAiL@ExAmPle.coM';
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.auth.local.email).to.equal(email.toLowerCase());
});
it('fails on a habitica.com email', async () => {
let username = generateRandomUserName();
let email = `${username}@habitica.com`;
let password = 'password';
await expect(api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'User validation failed',
});
});
it('fails on a habitrpg.com email', async () => {
let username = generateRandomUserName();
let email = `${username}@habitrpg.com`;
let password = 'password';
await expect(api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'User validation failed',
});
});
it('requires a password', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let confirmPassword = 'password';
await expect(api.post('/user/auth/local/register', {
username,
email,
confirmPassword,
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
});
context('attach to facebook user', () => {
let user;
let email = 'some@email.net';
let username = 'some-username';
let password = 'some-password';
beforeEach(async () => {
user = await generateUser();
});
it('checks onlySocialAttachLocal', async () => {
await expect(user.post('/user/auth/local/register', {
email,
username,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('onlySocialAttachLocal'),
});
});
it('succeeds', async () => {
await user.update({ 'auth.facebook.id': 'some-fb-id', 'auth.local': { ok: true } });
await user.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
await user.sync();
expect(user.auth.local.username).to.eql(username);
expect(user.auth.local.email).to.eql(email);
});
});
context('login is already taken', () => {
let username, email, api;
beforeEach(async () => {
api = requester();
username = generateRandomUserName();
email = `${username}@example.com`;
return generateUser({
'auth.local.username': username,
'auth.local.lowerCaseUsername': username,
'auth.local.email': email,
});
});
it('rejects if username is already taken', async () => {
let uniqueEmail = `${generateRandomUserName()}@exampe.com`;
let password = 'password';
await expect(api.post('/user/auth/local/register', {
username,
email: uniqueEmail,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('usernameTaken'),
});
});
it('rejects if email is already taken', async () => {
let uniqueUsername = generateRandomUserName();
let password = 'password';
await expect(api.post('/user/auth/local/register', {
username: uniqueUsername,
email,
password,
confirmPassword: password,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('emailTaken'),
});
});
});
context('req.query.groupInvite', () => {
let api, username, email, password;
beforeEach(() => {
api = requester();
username = generateRandomUserName();
email = `${username}@example.com`;
password = 'password';
});
it('does not crash the signup process when it\'s invalid', async () => {
let user = await api.post('/user/auth/local/register?groupInvite=aaaaInvalid', {
username,
email,
password,
confirmPassword: password,
});
expect(user._id).to.be.a('string');
});
it('supports invite using req.query.groupInvite', async () => {
let { group, groupLeader } = await createAndPopulateGroup({
groupDetails: { type: 'party', privacy: 'private' },
});
let invite = encrypt(JSON.stringify({
id: group._id,
inviter: groupLeader._id,
sentAt: Date.now(), // so we can let it expire
}));
let user = await api.post(`/user/auth/local/register?groupInvite=${invite}`, {
username,
email,
password,
confirmPassword: password,
});
expect(user.invitations.parties[0].id).to.eql(group._id);
expect(user.invitations.parties[0].name).to.eql(group.name);
expect(user.invitations.parties[0].inviter).to.eql(groupLeader._id);
});
it('awards achievement to inviter', async () => {
let { group, groupLeader } = await createAndPopulateGroup({
groupDetails: { type: 'party', privacy: 'private' },
});
let invite = encrypt(JSON.stringify({
id: group._id,
inviter: groupLeader._id,
sentAt: Date.now(),
}));
await api.post(`/user/auth/local/register?groupInvite=${invite}`, {
username,
email,
password,
confirmPassword: password,
});
await groupLeader.sync();
expect(groupLeader.achievements.invitedFriend).to.be.true;
});
it('user not added to a party on expired invite', async () => {
let { group, groupLeader } = await createAndPopulateGroup({
groupDetails: { type: 'party', privacy: 'private' },
});
let invite = encrypt(JSON.stringify({
id: group._id,
inviter: groupLeader._id,
sentAt: Date.now() - 6.912e8, // 8 days old
}));
let user = await api.post(`/user/auth/local/register?groupInvite=${invite}`, {
username,
email,
password,
confirmPassword: password,
});
expect(user.invitations.party).to.eql({});
});
it('adds a user to a guild on an invite of type other than party', async () => {
let { group, groupLeader } = await createAndPopulateGroup({
groupDetails: { type: 'guild', privacy: 'private' },
});
let invite = encrypt(JSON.stringify({
id: group._id,
inviter: groupLeader._id,
sentAt: Date.now(),
}));
let user = await api.post(`/user/auth/local/register?groupInvite=${invite}`, {
username,
email,
password,
confirmPassword: password,
});
expect(user.invitations.guilds[0]).to.eql({
id: group._id,
name: group.name,
inviter: groupLeader._id,
});
});
});
context('successful login via api', () => {
let api, username, email, password;
beforeEach(() => {
api = requester();
username = generateRandomUserName();
email = `${username}@example.com`;
password = 'password';
});
it('sets all site tour values to -2 (already seen)', async () => {
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.flags.tour).to.not.be.empty;
each(user.flags.tour, (value) => {
expect(value).to.eql(-2);
});
});
it('populates user with default todos, not no other task types', async () => {
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.tasksOrder.todos).to.not.be.empty;
expect(user.tasksOrder.dailys).to.be.empty;
expect(user.tasksOrder.habits).to.be.empty;
expect(user.tasksOrder.rewards).to.be.empty;
});
it('populates user with default tags', async () => {
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.tags).to.not.be.empty;
});
});
context('successful login with habitica-web header', () => {
let api, username, email, password;
beforeEach(() => {
api = requester({}, {'x-client': 'habitica-web'});
username = generateRandomUserName();
email = `${username}@example.com`;
password = 'password';
});
it('sets all common tutorial flags to true', async () => {
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.flags.tour).to.not.be.empty;
each(user.flags.tutorial.common, (value) => {
expect(value).to.eql(true);
});
});
it('populates user with default todos, habits, and rewards', async () => {
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.tasksOrder.todos).to.be.empty;
expect(user.tasksOrder.dailys).to.be.empty;
expect(user.tasksOrder.habits).to.be.empty;
expect(user.tasksOrder.rewards).to.be.empty;
});
it('populates user with default tags', async () => {
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.tags).to.not.be.empty;
});
it('adds the correct tags to the correct tasks', async () => {
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let todos = await requests.get('/tasks/user?type=todos');
expect(habits).to.have.a.lengthOf(0);
expect(todos).to.have.a.lengthOf(0);
});
});
});

View File

@@ -1,20 +0,0 @@
import clearPMs from '../../../website/common/script/ops/clearPMs';
import {
generateUser,
} from '../../helpers/common.helper';
describe('shared.ops.clearPMs', () => {
let user;
beforeEach(() => {
user = generateUser();
user.inbox.messages = { first: 'message', second: 'message' };
});
it('clears messages', () => {
expect(user.inbox.messages).to.not.eql({});
let [result] = clearPMs(user);
expect(user.inbox.messages).to.eql({});
expect(result).to.eql({});
});
});

View File

@@ -1,20 +0,0 @@
import deletePM from '../../../website/common/script/ops/deletePM';
import {
generateUser,
} from '../../helpers/common.helper';
describe('shared.ops.deletePM', () => {
let user;
beforeEach(() => {
user = generateUser();
user.inbox.messages = { first: 'message', second: 'message' };
});
it('delete message', () => {
expect(user.inbox.messages).to.not.eql({ second: 'message' });
let [response] = deletePM(user, { params: { id: 'first' } });
expect(user.inbox.messages).to.eql({ second: 'message' });
expect(response).to.eql({ second: 'message' });
});
});

View File

@@ -152,8 +152,6 @@ import unlock from './ops/unlock';
import revive from './ops/revive';
import rebirth from './ops/rebirth';
import blockUser from './ops/blockUser';
import clearPMs from './ops/clearPMs';
import deletePM from './ops/deletePM';
import reroll from './ops/reroll';
import reset from './ops/reset';
import markPmsRead from './ops/markPMSRead';
@@ -182,8 +180,6 @@ api.ops = {
revive,
rebirth,
blockUser,
clearPMs,
deletePM,
reroll,
reset,
markPmsRead,

View File

@@ -1,7 +0,0 @@
module.exports = function clearPMs (user) {
user.inbox.messages = {};
if (user.markModified) user.markModified('inbox.messages');
return [
user.inbox.messages,
];
};

View File

@@ -1,9 +0,0 @@
import get from 'lodash/get';
module.exports = function deletePM (user, req = {}) {
delete user.inbox.messages[get(req, 'params.id')];
if (user.markModified) user.markModified(`inbox.messages.${req.params.id}`);
return [
user.inbox.messages,
];
};

View File

@@ -14,8 +14,6 @@ import addTag from './addTag';
import sortTag from './sortTag';
import updateTag from './updateTag';
import deleteTag from './deleteTag';
import clearPMs from './clearPMs';
import deletePM from './deletePM';
import blockUser from './blockUser';
import feed from './feed';
import releasePets from './releasePets';
@@ -50,8 +48,6 @@ module.exports = {
sortTag,
updateTag,
deleteTag,
clearPMs,
deletePM,
blockUser,
feed,
releasePets,

View File

@@ -11,61 +11,22 @@ import {
NotFound,
} from '../../libs/errors';
import * as passwordUtils from '../../libs/password';
import logger from '../../libs/logger';
import { model as User } from '../../models/user';
import { model as Group } from '../../models/group';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../../libs/email';
import { decrypt, encrypt } from '../../libs/encryption';
import { send as sendEmail } from '../../libs/email';
import pusher from '../../libs/pusher';
import common from '../../../common';
import { validatePasswordResetCodeAndFindUser, convertToBcrypt} from '../../libs/password';
import { encrypt } from '../../libs/encryption';
import * as authLib from '../../libs/auth';
const BASE_URL = nconf.get('BASE_URL');
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL');
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS:COMMUNITY_MANAGER_EMAIL');
const USERNAME_LENGTH_MIN = 1;
const USERNAME_LENGTH_MAX = 20;
let api = {};
// When the user signed up after having been invited to a group, invite them automatically to the group
async function _handleGroupInvitation (user, invite) {
// wrapping the code in a try because we don't want it to prevent the user from signing up
// that's why errors are not translated
try {
let {sentAt, id: groupId, inviter} = JSON.parse(decrypt(invite));
// check that the invite has not expired (after 7 days)
if (sentAt && moment().subtract(7, 'days').isAfter(sentAt)) {
let err = new Error('Invite expired.');
err.privateData = invite;
throw err;
}
let group = await Group.getGroup({user, optionalMembership: true, groupId, fields: 'name type'});
if (!group) throw new NotFound('Group not found.');
if (group.type === 'party') {
user.invitations.party = {id: group._id, name: group.name, inviter};
user.invitations.parties.push(user.invitations.party);
} else {
user.invitations.guilds.push({id: group._id, name: group.name, inviter});
}
// award the inviter with 'Invited a Friend' achievement
inviter = await User.findById(inviter);
if (!inviter.achievements.invitedFriend) {
inviter.achievements.invitedFriend = true;
inviter.addNotification('INVITED_FRIEND_ACHIEVEMENT');
await inviter.save();
}
} catch (err) {
logger.error(err);
}
}
function hasBackupAuth (user, networkToRemove) {
if (user.auth.local.username) {
return true;
@@ -78,6 +39,8 @@ function hasBackupAuth (user, networkToRemove) {
return hasAlternateNetwork;
}
/* NOTE this route has also an API v4 version */
/**
* @api {post} /api/v3/user/auth/local/register Register
* @apiDescription Register a new user with email, login name, and password or attach local auth to a social user
@@ -98,115 +61,7 @@ api.registerLocal = {
})],
url: '/user/auth/local/register',
async handler (req, res) {
let existingUser = res.locals.user; // If adding local auth to social user
req.checkBody({
username: {
notEmpty: true,
errorMessage: res.t('missingUsername'),
// TODO use the constants in the error message above
isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameWrongLength')},
matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameBadCharacters')},
},
email: {
notEmpty: true,
errorMessage: res.t('missingEmail'),
isEmail: {errorMessage: res.t('notAnEmail')},
},
password: {
notEmpty: true,
errorMessage: res.t('missingPassword'),
equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')},
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let { email, username, password } = req.body;
// Get the lowercase version of username to check that we do not have duplicates
// So we can search for it in the database and then reject the choosen username if 1 or more results are found
email = email.toLowerCase();
username = username.trim();
let lowerCaseUsername = username.toLowerCase();
// Search for duplicates using lowercase version of username
let user = await User.findOne({$or: [
{'auth.local.email': email},
{'auth.local.lowerCaseUsername': lowerCaseUsername},
]}, {'auth.local': 1}).exec();
if (user) {
if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken'));
// Check that the lowercase username isn't already used
if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken'));
}
let hashed_password = await passwordUtils.bcryptHash(password); // eslint-disable-line camelcase
let newUser = {
auth: {
local: {
username,
lowerCaseUsername,
email,
hashed_password, // eslint-disable-line camelcase,
passwordHashMethod: 'bcrypt',
},
},
preferences: {
language: req.language,
},
};
if (existingUser) {
let hasSocialAuth = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(network => {
if (existingUser.auth.hasOwnProperty(network.key)) {
return existingUser.auth[network.key].id;
}
});
if (!hasSocialAuth) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
existingUser.auth.local = newUser.auth.local;
newUser = existingUser;
} else {
newUser = new User(newUser);
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
if (req.query.groupInvite || req.query.partyInvite) {
await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite);
}
let savedUser = await newUser.save();
if (existingUser) {
res.respond(200, savedUser.toJSON().auth.local); // We convert to toJSON to hide private fields
} else {
let userJSON = savedUser.toJSON();
userJSON.newUser = true;
res.respond(201, userJSON);
}
// Clean previous email preferences and send welcome email
EmailUnsubscription
.remove({email: savedUser.auth.local.email})
.then(() => {
if (!existingUser) sendTxnEmail(savedUser, 'welcome');
});
if (!existingUser) {
res.analytics.track('register', {
category: 'acquisition',
type: 'local',
gaLabel: 'local',
uuid: savedUser._id,
headers: req.headers,
user: savedUser,
});
}
return null;
await authLib.registerLocal(req, res, { isV3: true });
},
};
@@ -398,9 +253,7 @@ api.loginSocial = {
*/
api.pusherAuth = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/auth/pusher',
async handler (req, res) {
let user = res.locals.user;
@@ -468,9 +321,7 @@ api.pusherAuth = {
**/
api.updateUsername = {
method: 'PUT',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/auth/update-username',
async handler (req, res) {
let user = res.locals.user;
@@ -524,9 +375,7 @@ api.updateUsername = {
**/
api.updatePassword = {
method: 'PUT',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/auth/update-password',
async handler (req, res) {
let user = res.locals.user;
@@ -636,9 +485,7 @@ api.resetPassword = {
*/
api.updateEmail = {
method: 'PUT',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/auth/update-email',
async handler (req, res) {
let user = res.locals.user;
@@ -725,9 +572,7 @@ api.resetPasswordSetNewOne = {
api.deleteSocial = {
method: 'DELETE',
url: '/user/auth/social/:network',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let network = req.params.network;

View File

@@ -184,9 +184,7 @@ let api = {};
api.createChallenge = {
method: 'POST',
url: '/challenges',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -235,9 +233,7 @@ api.createChallenge = {
api.joinChallenge = {
method: 'POST',
url: '/challenges/:challengeId/join',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -294,9 +290,7 @@ api.joinChallenge = {
api.leaveChallenge = {
method: 'POST',
url: '/challenges/:challengeId/leave',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all';
@@ -345,9 +339,7 @@ api.leaveChallenge = {
api.getUserChallenges = {
method: 'GET',
url: '/challenges/user',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
const CHALLENGES_PER_PAGE = 10;
const page = req.query.page;
@@ -508,9 +500,7 @@ api.getGroupChallenges = {
api.getChallenge = {
method: 'GET',
url: '/challenges/:challengeId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
@@ -664,9 +654,7 @@ api.exportChallengeCsv = {
api.updateChallenge = {
method: 'PUT',
url: '/challenges/:challengeId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
@@ -708,9 +696,7 @@ api.updateChallenge = {
api.deleteChallenge = {
method: 'DELETE',
url: '/challenges/:challengeId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -755,9 +741,7 @@ api.deleteChallenge = {
api.selectChallengeWinner = {
method: 'POST',
url: '/challenges/:challengeId/selectWinner/:winnerId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -806,9 +790,7 @@ api.selectChallengeWinner = {
api.cloneChallenge = {
method: 'POST',
url: '/challenges/:challengeId/clone',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;

View File

@@ -1,7 +1,7 @@
import { authWithHeaders } from '../../middlewares/auth';
import { model as Group } from '../../models/group';
import { model as User } from '../../models/user';
import { model as Chat } from '../../models/chat';
import { chatModel as Chat } from '../../models/message';
import {
BadRequest,
NotFound,
@@ -62,9 +62,7 @@ function textContainsBannedSlur (message) {
api.getChat = {
method: 'GET',
url: '/groups/:groupId/chat',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -103,9 +101,7 @@ function getBannedWordsFromText (message) {
api.postChat = {
method: 'POST',
url: '/groups/:groupId/chat',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
@@ -227,9 +223,7 @@ api.postChat = {
api.likeChat = {
method: 'POST',
url: '/groups/:groupId/chat/:chatId/like',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
@@ -286,9 +280,7 @@ api.likeChat = {
api.flagChat = {
method: 'POST',
url: '/groups/:groupId/chat/:chatId/flag',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
const chatReporter = chatReporterFactory('Group', req, res);
const message = await chatReporter.flag();
@@ -317,9 +309,7 @@ api.flagChat = {
api.clearChatFlags = {
method: 'Post',
url: '/groups/:groupId/chat/:chatId/clearflags',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
@@ -389,9 +379,7 @@ api.clearChatFlags = {
api.seenChat = {
method: 'POST',
url: '/groups/:groupId/chat/seen',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
@@ -457,9 +445,7 @@ api.seenChat = {
api.deleteChat = {
method: 'DELETE',
url: '/groups/:groupId/chat/:chatId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;

View File

@@ -4,10 +4,11 @@ import {
authWithSession,
} from '../../middlewares/auth';
import { ensureSudo } from '../../middlewares/ensureAccessRight';
import { model as Coupon } from '../../models/coupon';
import _ from 'lodash';
import * as couponsLib from '../../libs/coupons';
import couponCode from 'coupon-code';
import apiError from '../../libs/apiError';
import { model as Coupon } from '../../models/coupon';
let api = {};
@@ -68,9 +69,7 @@ api.getCoupons = {
api.generateCoupons = {
method: 'POST',
url: '/coupons/generate/:event',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
}), ensureSudo],
middlewares: [authWithHeaders(), ensureSudo],
async handler (req, res) {
req.checkParams('event', apiError('eventRequired')).notEmpty();
req.checkQuery('count', apiError('countRequired')).notEmpty().isNumeric();
@@ -83,6 +82,8 @@ api.generateCoupons = {
},
};
/* NOTE this route has also an API v4 version */
/**
* @api {post} /api/v3/coupons/enter/:code Redeem a coupon code
* @apiName RedeemCouponCode
@@ -95,19 +96,12 @@ api.generateCoupons = {
api.enterCouponCode = {
method: 'POST',
url: '/coupons/enter/:code',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
await Coupon.apply(user, req, req.params.code);
res.respond(200, user);
const user = res.locals.user;
await couponsLib.enterCode(req, res, user);
const userToJSON = await user.toJSONWithInbox();
res.respond(200, userToJSON);
},
};
@@ -125,7 +119,6 @@ api.validateCoupon = {
url: '/coupons/validate/:code',
middlewares: [authWithHeaders({
optional: true,
userFieldsToExclude: ['inbox'],
})],
async handler (req, res) {
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();

View File

@@ -109,9 +109,7 @@ let api = {};
api.createGroup = {
method: 'POST',
url: '/groups',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let group = new Group(Group.sanitize(req.body));
@@ -182,9 +180,7 @@ api.createGroup = {
api.createGroupPlan = {
method: 'POST',
url: '/groups/create-plan',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let group = new Group(Group.sanitize(req.body.groupToCreate));
@@ -293,9 +289,7 @@ api.createGroupPlan = {
api.getGroups = {
method: 'GET',
url: '/groups',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -443,9 +437,7 @@ api.getGroup = {
api.updateGroup = {
method: 'PUT',
url: '/groups/:groupId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -508,9 +500,7 @@ api.updateGroup = {
api.joinGroup = {
method: 'POST',
url: '/groups/:groupId/join',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let inviter;
@@ -682,9 +672,7 @@ api.joinGroup = {
api.rejectGroupInvite = {
method: 'POST',
url: '/groups/:groupId/reject-invite',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -759,9 +747,7 @@ function _removeMessagesFromMember (member, groupId) {
api.leaveGroup = {
method: 'POST',
url: '/groups/:groupId/leave',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
@@ -848,9 +834,7 @@ function _sendMessageToRemoved (group, removedUser, message, isInGroup) {
api.removeGroupMember = {
method: 'POST',
url: '/groups/:groupId/removeMember/:memberId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -1176,7 +1160,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
api.inviteToGroup = {
method: 'POST',
url: '/groups/:groupId/invite',
middlewares: [authWithHeaders({})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -1249,9 +1233,7 @@ api.inviteToGroup = {
api.addGroupManager = {
method: 'POST',
url: '/groups/:groupId/add-manager',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let managerId = req.body.managerId;
@@ -1300,9 +1282,7 @@ api.addGroupManager = {
api.removeGroupManager = {
method: 'POST',
url: '/groups/:groupId/remove-manager',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let managerId = req.body.managerId;
@@ -1355,9 +1335,7 @@ api.removeGroupManager = {
api.getGroupPlans = {
method: 'GET',
url: '/group-plans',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;

View File

@@ -61,9 +61,7 @@ let api = {};
api.getPatrons = {
method: 'GET',
url: '/hall/patrons',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkQuery('page').optional().isInt({min: 0}, apiError('queryPageInteger'));
@@ -123,9 +121,7 @@ api.getPatrons = {
api.getHeroes = {
method: 'GET',
url: '/hall/heroes',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let heroes = await User
.find({

View File

@@ -1,5 +1,5 @@
import { authWithHeaders } from '../../middlewares/auth';
import { toArray, orderBy } from 'lodash';
import * as inboxLib from '../../libs/inbox';
let api = {};
@@ -7,7 +7,6 @@ let api = {};
/**
* @api {get} /api/v3/inbox/messages Get inbox messages for a user
* @apiPrivate
* @apiName GetInboxMessages
* @apiGroup Inbox
* @apiDescription Get inbox messages for a user
@@ -19,10 +18,11 @@ api.getInboxMessages = {
url: '/inbox/messages',
middlewares: [authWithHeaders()],
async handler (req, res) {
const messagesObj = res.locals.user.inbox.messages;
const messagesArray = orderBy(toArray(messagesObj), ['timestamp'], ['desc']);
const user = res.locals.user;
res.respond(200, messagesArray);
const userInbox = await inboxLib.getUserInbox(user);
res.respond(200, userInbox);
},
};

View File

@@ -379,9 +379,7 @@ function _getMembersForItem (type) {
api.getMembersForGroup = {
method: 'GET',
url: '/groups/:groupId/members',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
handler: _getMembersForItem('group-members'),
};
@@ -417,9 +415,7 @@ api.getMembersForGroup = {
api.getInvitesForGroup = {
method: 'GET',
url: '/groups/:groupId/invites',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
handler: _getMembersForItem('group-invites'),
};
@@ -445,9 +441,7 @@ api.getInvitesForGroup = {
api.getMembersForChallenge = {
method: 'GET',
url: '/challenges/:challengeId/members',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
handler: _getMembersForItem('challenge-members'),
};
@@ -509,9 +503,7 @@ api.getMembersForChallenge = {
api.getChallengeMemberProgress = {
method: 'GET',
url: '/challenges/:challengeId/members/:memberId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
@@ -594,7 +586,7 @@ api.getObjectionsToInteraction = {
* @apiParam (Body) {String} message Body parameter - The message
* @apiParam (Body) {UUID} toUserId Body parameter - The user to contact
*
* @apiSuccess {Object} data An empty Object
* @apiSuccess {Object} data.message The message just sent
*
* @apiUse UserNotFound
*/
@@ -617,7 +609,7 @@ api.sendPrivateMessage = {
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));
const newMessage = await sender.sendMessage(receiver, { receiverMsg: message });
const messageSent = await sender.sendMessage(receiver, { receiverMsg: message });
if (receiver.preferences.emailNotifications.newPM !== false) {
sendTxnEmail(receiver, 'new-pm', [
@@ -638,7 +630,7 @@ api.sendPrivateMessage = {
);
}
res.respond(200, { message: newMessage });
res.respond(200, {message: messageSent});
},
};
@@ -682,6 +674,7 @@ api.transferGems = {
receiver.balance += amount;
sender.balance -= amount;
// @TODO necessary? Also saved when sending the inbox message
let promises = [receiver.save(), sender.save()];
await Promise.all(promises);

View File

@@ -62,9 +62,7 @@ api.getNews = {
*/
api.tellMeLaterNews = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/news/tell-me-later',
async handler (req, res) {
const user = res.locals.user;

View File

@@ -23,9 +23,7 @@ let api = {};
api.readNotification = {
method: 'POST',
url: '/notifications/:notificationId/read',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -67,9 +65,7 @@ api.readNotification = {
api.readNotifications = {
method: 'POST',
url: '/notifications/read',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -117,9 +113,7 @@ api.readNotifications = {
api.seeNotification = {
method: 'POST',
url: '/notifications/:notificationId/see',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -168,9 +162,7 @@ api.seeNotification = {
api.seeNotifications = {
method: 'POST',
url: '/notifications/see',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;

View File

@@ -22,9 +22,7 @@ let api = {};
api.addPushDevice = {
method: 'POST',
url: '/user/push-devices',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
const user = res.locals.user;
@@ -72,9 +70,7 @@ api.addPushDevice = {
api.removePushDevice = {
method: 'DELETE',
url: '/user/push-devices/:regId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
const user = res.locals.user;

View File

@@ -55,9 +55,7 @@ let api = {};
api.inviteToQuest = {
method: 'POST',
url: '/groups/:groupId/quests/invite/:questKey',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let questKey = req.params.questKey;
@@ -171,9 +169,7 @@ api.inviteToQuest = {
api.acceptQuest = {
method: 'POST',
url: '/groups/:groupId/quests/accept',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -232,9 +228,7 @@ api.acceptQuest = {
api.rejectQuest = {
method: 'POST',
url: '/groups/:groupId/quests/reject',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -297,9 +291,7 @@ api.rejectQuest = {
api.forceStart = {
method: 'POST',
url: '/groups/:groupId/quests/force-start',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -357,9 +349,7 @@ api.forceStart = {
api.cancelQuest = {
method: 'POST',
url: '/groups/:groupId/quests/cancel',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
// Cancel a quest BEFORE it has begun (i.e., in the invitation stage)
// Quest scroll has not yet left quest owner's inventory so no need to return it.
@@ -413,9 +403,7 @@ api.cancelQuest = {
api.abortQuest = {
method: 'POST',
url: '/groups/:groupId/quests/abort',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
// Abort a quest AFTER it has begun (see questCancel for BEFORE)
let user = res.locals.user;
@@ -475,9 +463,7 @@ api.abortQuest = {
api.leaveQuest = {
method: 'POST',
url: '/groups/:groupId/quests/leave',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;

View File

@@ -15,9 +15,7 @@ let api = {};
api.getMarketItems = {
method: 'GET',
url: '/shops/market',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -38,9 +36,7 @@ api.getMarketItems = {
api.getMarketGear = {
method: 'GET',
url: '/shops/market-gear',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -64,9 +60,7 @@ api.getMarketGear = {
api.getQuestShopItems = {
method: 'GET',
url: '/shops/quests',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -88,9 +82,7 @@ api.getQuestShopItems = {
api.getTimeTravelerShopItems = {
method: 'GET',
url: '/shops/time-travelers',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -112,9 +104,7 @@ api.getTimeTravelerShopItems = {
api.getSeasonalShopItems = {
method: 'GET',
url: '/shops/seasonal',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -136,9 +126,7 @@ api.getSeasonalShopItems = {
api.getBackgroundShopItems = {
method: 'GET',
url: '/shops/backgrounds',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;

View File

@@ -38,9 +38,7 @@ let api = {};
api.createTag = {
method: 'POST',
url: '/tags',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -66,9 +64,7 @@ api.createTag = {
api.getTags = {
method: 'GET',
url: '/tags',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
res.respond(200, user.tags);
@@ -93,9 +89,7 @@ api.getTags = {
api.getTag = {
method: 'GET',
url: '/tags/:tagId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -132,9 +126,7 @@ api.getTag = {
api.updateTag = {
method: 'PUT',
url: '/tags/:tagId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -176,9 +168,7 @@ api.updateTag = {
api.reorderTags = {
method: 'POST',
url: '/reorder-tags',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -217,9 +207,7 @@ api.reorderTags = {
api.deleteTag = {
method: 'DELETE',
url: '/tags/:tagId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;

View File

@@ -158,9 +158,7 @@ let requiredGroupFields = '_id leader tasksOrder name';
api.createUserTasks = {
method: 'POST',
url: '/tasks/user',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let tasks = await createTasks(req, res, {user});
@@ -232,9 +230,7 @@ api.createUserTasks = {
api.createChallengeTasks = {
method: 'POST',
url: '/tasks/challenge/:challengeId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
@@ -328,9 +324,7 @@ api.getUserTasks = {
api.getChallengeTasks = {
method: 'GET',
url: '/tasks/challenge/:challengeId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let types = Tasks.tasksTypes.map(type => `${type}s`);
@@ -380,9 +374,7 @@ api.getChallengeTasks = {
api.getTask = {
method: 'GET',
url: '/tasks/:taskId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let taskId = req.params.taskId;
@@ -436,9 +428,7 @@ api.getTask = {
api.updateTask = {
method: 'PUT',
url: '/tasks/:taskId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
@@ -552,9 +542,7 @@ api.updateTask = {
api.scoreTask = {
method: 'POST',
url: '/tasks/:taskId/score/:direction',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
@@ -728,9 +716,7 @@ api.scoreTask = {
api.moveTask = {
method: 'POST',
url: '/tasks/:taskId/move/to/:position',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
@@ -799,9 +785,7 @@ api.moveTask = {
api.addChecklistItem = {
method: 'POST',
url: '/tasks/:taskId/checklist',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
@@ -861,9 +845,7 @@ api.addChecklistItem = {
api.scoreCheckListItem = {
method: 'POST',
url: '/tasks/:taskId/checklist/:itemId/score',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -917,9 +899,7 @@ api.scoreCheckListItem = {
api.updateChecklistItem = {
method: 'PUT',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
@@ -984,9 +964,7 @@ api.updateChecklistItem = {
api.removeChecklistItem = {
method: 'DELETE',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
@@ -1049,9 +1027,7 @@ api.removeChecklistItem = {
api.addTagToTask = {
method: 'POST',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -1100,9 +1076,7 @@ api.addTagToTask = {
api.removeTagFromTask = {
method: 'DELETE',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -1147,9 +1121,7 @@ api.removeTagFromTask = {
api.unlinkAllTasks = {
method: 'POST',
url: '/tasks/unlink-all/:challengeId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkQuery('keep', apiError('keepOrRemoveAll')).notEmpty().isIn(['keep-all', 'remove-all']);
@@ -1216,9 +1188,7 @@ api.unlinkAllTasks = {
api.unlinkOneTask = {
method: 'POST',
url: '/tasks/unlink-one/:taskId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty().isUUID();
req.checkQuery('keep', apiError('keepOrRemove')).notEmpty().isIn(['keep', 'remove']);
@@ -1268,9 +1238,7 @@ api.unlinkOneTask = {
api.clearCompletedTodos = {
method: 'POST',
url: '/tasks/clearCompletedTodos',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
@@ -1321,9 +1289,7 @@ api.clearCompletedTodos = {
api.deleteTask = {
method: 'DELETE',
url: '/tasks/:taskId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;

View File

@@ -42,9 +42,7 @@ let api = {};
api.createGroupTasks = {
method: 'POST',
url: '/tasks/group/:groupId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty().isUUID();
@@ -88,9 +86,7 @@ api.createGroupTasks = {
api.getGroupTasks = {
method: 'GET',
url: '/tasks/group/:groupId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty().isUUID();
req.checkQuery('type', res.t('invalidTasksType')).optional().isIn(types);
@@ -123,9 +119,7 @@ api.getGroupTasks = {
api.groupMoveTask = {
method: 'POST',
url: '/group-tasks/:taskId/move/to/:position',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
@@ -176,9 +170,7 @@ api.groupMoveTask = {
api.assignTask = {
method: 'POST',
url: '/tasks/:taskId/assign/:assignedUserId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty().isUUID();
req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -238,9 +230,7 @@ api.assignTask = {
api.unassignTask = {
method: 'POST',
url: '/tasks/:taskId/unassign/:assignedUserId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty().isUUID();
req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -290,9 +280,7 @@ api.unassignTask = {
api.approveTask = {
method: 'POST',
url: '/tasks/:taskId/approve/:userId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty().isUUID();
req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -390,9 +378,7 @@ api.approveTask = {
api.taskNeedsWork = {
method: 'POST',
url: '/tasks/:taskId/needs-work/:userId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty().isUUID();
req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -489,9 +475,7 @@ api.taskNeedsWork = {
api.getGroupApprovals = {
method: 'GET',
url: '/approvals/group/:groupId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty().isUUID();

View File

@@ -8,9 +8,6 @@ import {
basicFields as basicGroupFields,
model as Group,
} from '../../models/group';
import {
model as User,
} from '../../models/user';
import * as Tasks from '../../models/task';
import _ from 'lodash';
import * as passwordUtils from '../../libs/password';
@@ -22,6 +19,8 @@ import {
sendTxn as txnEmail,
} from '../../libs/email';
import Queue from '../../libs/queue';
import * as inboxLib from '../../libs/inbox';
import * as userLib from '../../libs/user';
import nconf from 'nconf';
import get from 'lodash/get';
@@ -35,6 +34,8 @@ const DELETE_CONFIRMATION = 'DELETE';
let api = {};
/* NOTE this route has also an API v4 version */
/**
* @api {get} /api/v3/user Get the authenticated user's profile
* @apiName UserGet
@@ -47,7 +48,7 @@ let api = {};
* Flags (including armoire, tutorial, tour etc...)
* Guilds
* History (including timestamps and values)
* Inbox (includes message history)
* Inbox
* Invitations (to parties/guilds)
* Items (character's full inventory)
* New Messages (flags for groups/guilds that have new messages)
@@ -83,20 +84,7 @@ api.getUser = {
middlewares: [authWithHeaders()],
url: '/user',
async handler (req, res) {
let user = res.locals.user;
let userToJSON = user.toJSON();
// Remove apiToken from response TODO make it private at the user level? returned in signup/login
delete userToJSON.apiToken;
if (!req.query.userFields) {
let {daysMissed} = user.daysUserHasMissed(new Date(), req);
userToJSON.needsCron = false;
if (daysMissed > 0) userToJSON.needsCron = true;
User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON);
}
return res.respond(200, userToJSON);
await userLib.get(req, res, { isV3: true });
},
};
@@ -128,9 +116,7 @@ api.getUser = {
*/
api.getBuyList = {
method: 'GET',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/inventory/buy',
async handler (req, res) {
let list = _.cloneDeep(common.updateStore(res.locals.user));
@@ -173,9 +159,7 @@ api.getBuyList = {
*/
api.getInAppRewardsList = {
method: 'GET',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/in-app-rewards',
async handler (req, res) {
let list = common.inAppRewards(res.locals.user);
@@ -191,78 +175,7 @@ api.getInAppRewardsList = {
},
};
let updatablePaths = [
'_ABtests.counter',
'flags.customizationsNotification',
'flags.showTour',
'flags.tour',
'flags.tutorial',
'flags.communityGuidelinesAccepted',
'flags.welcomed',
'flags.cardReceived',
'flags.warnedLowHealth',
'flags.newStuff',
'achievements',
'party.order',
'party.orderAscending',
'party.quest.completed',
'party.quest.RSVPNeeded',
'preferences',
'profile',
'stats',
'inbox.optOut',
'tags',
];
// This tells us for which paths users can call `PUT /user`.
// The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs)
let acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (accumulator, val, leaf) => {
let found = _.find(updatablePaths, (rootPath) => {
return leaf.indexOf(rootPath) === 0;
});
if (found) accumulator[leaf] = true;
return accumulator;
}, {});
let restrictedPUTSubPaths = [
'stats.class',
'preferences.disableClasses',
'preferences.sleep',
'preferences.webhooks',
];
_.each(restrictedPUTSubPaths, (removePath) => {
delete acceptablePUTPaths[removePath];
});
let requiresPurchase = {
'preferences.background': 'background',
'preferences.shirt': 'shirt',
'preferences.size': 'size',
'preferences.skin': 'skin',
'preferences.hair.bangs': 'hair.bangs',
'preferences.hair.base': 'hair.base',
'preferences.hair.beard': 'hair.beard',
'preferences.hair.color': 'hair.color',
'preferences.hair.flower': 'hair.flower',
'preferences.hair.mustache': 'hair.mustache',
};
let checkPreferencePurchase = (user, path, item) => {
let itemPath = `${path}.${item}`;
let appearance = _.get(common.content.appearances, itemPath);
if (!appearance) return false;
if (appearance.price === 0) return true;
return _.get(user.purchased, itemPath);
};
/* NOTE this route has also an API v4 version */
/**
* @api {put} /api/v3/user Update the user
@@ -297,67 +210,7 @@ api.updateUser = {
middlewares: [authWithHeaders()],
url: '/user',
async handler (req, res) {
let user = res.locals.user;
let promisesForTagsRemoval = [];
_.each(req.body, (val, key) => {
let purchasable = requiresPurchase[key];
if (purchasable && !checkPreferencePurchase(user, purchasable, val)) {
throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key }));
}
if (acceptablePUTPaths[key] && key !== 'tags') {
_.set(user, key, val);
} else if (key === 'tags') {
if (!Array.isArray(val)) throw new BadRequest('mustBeArray');
const removedTagsIds = [];
const oldTags = [];
// Keep challenge and group tags
user.tags.forEach(t => {
if (t.group) {
oldTags.push(t);
} else {
removedTagsIds.push(t.id);
}
});
user.tags = oldTags;
val.forEach(t => {
let oldI = removedTagsIds.findIndex(id => id === t.id);
if (oldI > -1) {
removedTagsIds.splice(oldI, 1);
}
user.tags.push(t);
});
// Remove from all the tasks
// NOTE each tag to remove requires a query
promisesForTagsRemoval = removedTagsIds.map(tagId => {
return Tasks.Task.update({
userId: user._id,
}, {
$pull: {
tags: tagId,
},
}, {multi: true}).exec();
});
} else {
throw new NotAuthorized(res.t('messageUserOperationProtected', { operation: key }));
}
});
await Promise.all([user.save()].concat(promisesForTagsRemoval));
return res.respond(200, user);
await userLib.update(req, res, { isV3: true });
},
};
@@ -488,7 +341,7 @@ api.getUserAnonymized = {
middlewares: [authWithHeaders()],
url: '/user/anonymized',
async handler (req, res) {
let user = res.locals.user.toJSON();
let user = await res.locals.user.toJSONWithInbox();
user.stats.toNextLevel = common.tnl(user.stats.lvl);
user.stats.maxHealth = common.maxHealth;
user.stats.maxMP = common.statsComputed(res.locals.user).maxMP;
@@ -513,6 +366,7 @@ api.getUserAnonymized = {
_.forEach(user.inbox.messages, (msg) => {
msg.text = 'inbox message text';
});
_.forEach(user.tags, (tag) => {
tag.name = 'tag';
tag.challenge = 'challenge';
@@ -556,9 +410,7 @@ api.getUserAnonymized = {
*/
api.sleep = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/sleep',
async handler (req, res) {
let user = res.locals.user;
@@ -602,9 +454,7 @@ const buyKnownKeys = ['armoire', 'mystery', 'potion', 'quest', 'special'];
*/
api.buy = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/buy/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -668,9 +518,7 @@ api.buy = {
*/
api.buyGear = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/buy-gear/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -710,9 +558,7 @@ api.buyGear = {
*/
api.buyArmoire = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/buy-armoire',
async handler (req, res) {
let user = res.locals.user;
@@ -752,9 +598,7 @@ api.buyArmoire = {
*/
api.buyHealthPotion = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/buy-health-potion',
async handler (req, res) {
let user = res.locals.user;
@@ -796,9 +640,7 @@ api.buyHealthPotion = {
*/
api.buyMysterySet = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/buy-mystery-set/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -841,9 +683,7 @@ api.buyMysterySet = {
*/
api.buyQuest = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/buy-quest/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -883,9 +723,7 @@ api.buyQuest = {
*/
api.buySpecialSpell = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/buy-special-spell/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -929,9 +767,7 @@ api.buySpecialSpell = {
*/
api.hatch = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/hatch/:egg/:hatchingPotion',
async handler (req, res) {
let user = res.locals.user;
@@ -983,9 +819,7 @@ api.hatch = {
*/
api.equip = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/equip/:type/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -1020,9 +854,7 @@ api.equip = {
*/
api.feed = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/feed/:pet/:food',
async handler (req, res) {
let user = res.locals.user;
@@ -1066,9 +898,7 @@ api.feed = {
*/
api.changeClass = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/change-class',
async handler (req, res) {
let user = res.locals.user;
@@ -1089,9 +919,7 @@ api.changeClass = {
*/
api.disableClasses = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/disable-classes',
async handler (req, res) {
let user = res.locals.user;
@@ -1123,9 +951,7 @@ api.disableClasses = {
*/
api.purchase = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/purchase/:type/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -1172,9 +998,7 @@ api.purchase = {
*/
api.userPurchaseHourglass = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/purchase-hourglass/:type/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -1226,9 +1050,7 @@ api.userPurchaseHourglass = {
*/
api.readCard = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/read-card/:cardType',
async handler (req, res) {
let user = res.locals.user;
@@ -1270,9 +1092,7 @@ api.readCard = {
*/
api.userOpenMysteryItem = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/open-mystery-item',
async handler (req, res) {
let user = res.locals.user;
@@ -1304,9 +1124,7 @@ api.userOpenMysteryItem = {
*/
api.userReleasePets = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/release-pets',
async handler (req, res) {
let user = res.locals.user;
@@ -1355,9 +1173,7 @@ api.userReleasePets = {
*/
api.userReleaseBoth = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/release-both',
async handler (req, res) {
let user = res.locals.user;
@@ -1393,9 +1209,7 @@ api.userReleaseBoth = {
*/
api.userReleaseMounts = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/release-mounts',
async handler (req, res) {
let user = res.locals.user;
@@ -1425,9 +1239,7 @@ api.userReleaseMounts = {
*/
api.userSell = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/sell/:type/:key',
async handler (req, res) {
let user = res.locals.user;
@@ -1470,9 +1282,7 @@ api.userSell = {
*/
api.userUnlock = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/unlock',
async handler (req, res) {
let user = res.locals.user;
@@ -1498,9 +1308,7 @@ api.userUnlock = {
*/
api.userRevive = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/revive',
async handler (req, res) {
let user = res.locals.user;
@@ -1510,6 +1318,8 @@ api.userRevive = {
},
};
/* NOTE this route has also an API v4 version */
/**
* @api {post} /api/v3/user/rebirth Use Orb of Rebirth on user
* @apiName UserRebirth
@@ -1540,27 +1350,10 @@ api.userRevive = {
*/
api.userRebirth = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/rebirth',
async handler (req, res) {
let user = res.locals.user;
let tasks = await Tasks.Task.find({
userId: user._id,
type: {$in: ['daily', 'habit', 'todo']},
...Tasks.taskIsGroupOrChallengeQuery,
}).exec();
let rebirthRes = common.ops.rebirth(user, tasks, req, res.analytics);
let toSave = tasks.map(task => task.save());
toSave.push(user.save());
await Promise.all(toSave);
res.respond(200, ...rebirthRes);
await userLib.rebirth(req, res, { isV3: true });
},
};
@@ -1591,6 +1384,8 @@ api.blockUser = {
},
};
/* NOTE this route has also an API v4 version */
/**
* @api {delete} /api/v3/user/messages/:id Delete a message
* @apiName deleteMessage
@@ -1625,12 +1420,15 @@ api.deleteMessage = {
url: '/user/messages/:id',
async handler (req, res) {
let user = res.locals.user;
let deletePMRes = common.ops.deletePM(user, req);
await user.save();
res.respond(200, ...deletePMRes);
await inboxLib.deleteMessage(user, req.params.id);
res.respond(200, ...[await inboxLib.getUserInbox(user, false)]);
},
};
/* NOTE this route has also an API v4 version */
/**
* @api {delete} /api/v3/user/messages Delete all messages
* @apiName clearMessages
@@ -1647,9 +1445,10 @@ api.clearMessages = {
url: '/user/messages',
async handler (req, res) {
let user = res.locals.user;
let clearPMsRes = common.ops.clearPMs(user, req);
await user.save();
res.respond(200, ...clearPMsRes);
await inboxLib.clearPMs(user);
res.respond(200, ...[]);
},
};
@@ -1658,7 +1457,7 @@ api.clearMessages = {
* @apiName markPmsRead
* @apiGroup User
*
* @apiSuccess {Object} data user.inbox.messages
* @apiSuccess {Object} data user.inbox.newMessages
*
* @apiSuccessExample {json}
* {"success":true,"data":[0,"Your private messages have been marked as read"],"notifications":[]}
@@ -1676,6 +1475,8 @@ api.markPmsRead = {
},
};
/* NOTE this route has also an API v4 version */
/**
* @api {post} /api/v3/user/reroll Reroll a user using the Fortify Potion
* @apiName UserReroll
@@ -1700,29 +1501,15 @@ api.markPmsRead = {
*/
api.userReroll = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/reroll',
async handler (req, res) {
let user = res.locals.user;
let query = {
userId: user._id,
type: {$in: ['daily', 'habit', 'todo']},
...Tasks.taskIsGroupOrChallengeQuery,
};
let tasks = await Tasks.Task.find(query).exec();
let rerollRes = common.ops.reroll(user, tasks, req, res.analytics);
let promises = tasks.map(task => task.save());
promises.push(user.save());
await Promise.all(promises);
res.respond(200, ...rerollRes);
await userLib.reroll(req, res, { isV3: true });
},
};
/* NOTE this route has also an API v4 version */
/**
* @api {post} /api/v3/user/reset Reset user
* @apiName UserReset
@@ -1746,32 +1533,10 @@ api.userReroll = {
*/
api.userReset = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/reset',
async handler (req, res) {
let user = res.locals.user;
let tasks = await Tasks.Task.find({
userId: user._id,
...Tasks.taskIsGroupOrChallengeQuery,
}).select('_id type challenge group').exec();
let resetRes = common.ops.reset(user, tasks);
await Promise.all([
Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}),
user.save(),
]);
res.analytics.track('account reset', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
});
res.respond(200, ...resetRes);
await userLib.reset(req, res, { isV3: true });
},
};
@@ -1799,9 +1564,7 @@ api.userReset = {
*/
api.setCustomDayStart = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/custom-day-start',
async handler (req, res) {
let user = res.locals.user;
@@ -1839,9 +1602,7 @@ api.setCustomDayStart = {
*/
api.togglePinnedItem = {
method: 'GET',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/toggle-pinned-item/:type/:path',
async handler (req, res) {
let user = res.locals.user;
@@ -1879,9 +1640,7 @@ api.togglePinnedItem = {
api.movePinnedItem = {
method: 'POST',
url: '/user/move-pinned-item/:path/move/to/:position',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('path', res.t('taskIdRequired')).notEmpty();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();

View File

@@ -1,25 +1,12 @@
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,
castSpell,
} from '../../../libs/spells';
import apiError from '../../../libs/apiError';
const partyMembersFields = 'profile.name stats achievements items.special';
let api = {};
/* NOTE this route has also an API v4 version */
/**
* @api {post} /api/v3/user/class/cast/:spellId Cast a skill (spell) on a target
* @apiName UserCast
@@ -72,69 +59,9 @@ api.castSpell = {
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(apiError('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'}.\``;
const newChatMessage = party.sendChat(message);
await newChatMessage.save();
}
}
await castSpell(req, res, {
isV3: true,
});
},
};

View File

@@ -27,9 +27,7 @@ let api = {};
*/
api.allocate = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/allocate',
async handler (req, res) {
let user = res.locals.user;
@@ -69,9 +67,7 @@ api.allocate = {
*/
api.allocateBulk = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/allocate-bulk',
async handler (req, res) {
let user = res.locals.user;
@@ -128,9 +124,7 @@ api.allocateBulk = {
*/
api.allocateNow = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/allocate-now',
async handler (req, res) {
let user = res.locals.user;

View File

@@ -73,9 +73,7 @@ let api = {};
*/
api.addWebhook = {
method: 'POST',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/webhook',
async handler (req, res) {
let user = res.locals.user;
@@ -135,9 +133,7 @@ api.addWebhook = {
*/
api.updateWebhook = {
method: 'PUT',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/webhook/:id',
async handler (req, res) {
let user = res.locals.user;
@@ -188,9 +184,7 @@ api.updateWebhook = {
*/
api.deleteWebhook = {
method: 'DELETE',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
})],
middlewares: [authWithHeaders()],
url: '/user/webhook/:id',
async handler (req, res) {
let user = res.locals.user;

View File

@@ -0,0 +1,38 @@
import { authWithHeaders } from '../../middlewares/auth';
import * as authLib from '../../libs/auth';
const api = {};
/*
* NOTE most user routes are still in the v3 controller
* here there are only routes that had to be split from the v3 version because of
* some breaking change (for example because their returned the entire user object).
*/
/* NOTE this route has also an API v3 version */
/**
* @api {post} /api/v4/user/auth/local/register Register
* @apiDescription Register a new user with email, login name, and password or attach local auth to a social user
* @apiName UserRegisterLocal
* @apiGroup User
*
* @apiParam (Body) {String} username Login name of the new user. Must be 1-36 characters, containing only a-z, 0-9, hyphens (-), or underscores (_).
* @apiParam (Body) {String} email Email address of the new user
* @apiParam (Body) {String} password Password for the new user
* @apiParam (Body) {String} confirmPassword Password confirmation
*
* @apiSuccess {Object} data The user object, if local auth was just attached to a social user then only user.auth.local
*/
api.registerLocal = {
method: 'POST',
middlewares: [authWithHeaders({
optional: true,
})],
url: '/user/auth/local/register',
async handler (req, res) {
await authLib.registerLocal(req, res, { isV3: false });
},
};
module.exports = api;

View File

@@ -0,0 +1,34 @@
import { authWithHeaders } from '../../middlewares/auth';
import * as couponsLib from '../../libs/coupons';
/*
* NOTE most coupons routes are still in the v3 controller
* here there are only routes that had to be split from the v3 version because of
* some breaking change (for example because their returned the entire user object).
*/
const api = {};
/* NOTE this route has also an API v3 version */
/**
* @api {post} /api/v4/coupons/enter/:code Redeem a coupon code
* @apiName RedeemCouponCode
* @apiGroup Coupon
*
* @apiParam (Path) {String} code The coupon code to apply
*
* @apiSuccess {Object} data User object
*/
api.enterCouponCode = {
method: 'POST',
url: '/coupons/enter/:code',
middlewares: [authWithHeaders()],
async handler (req, res) {
const user = res.locals.user;
await couponsLib.enterCode(req, res, user);
res.respond(200, user);
},
};
module.exports = api;

View File

@@ -47,4 +47,29 @@ api.deleteMessage = {
},
};
/* NOTE this route has also an API v3 version */
/**
* @api {delete} /api/v4/inbox/clear Delete all messages
* @apiName clearMessages
* @apiGroup User
*
* @apiSuccess {Object} data Empty object
*
* @apiSuccessExample {json}
* {"success":true,"data":{},"notifications":[]}
*/
api.clearMessages = {
method: 'DELETE',
middlewares: [authWithHeaders()],
url: '/inbox/clear',
async handler (req, res) {
const user = res.locals.user;
await inboxLib.clearPMs(user);
res.respond(200, {});
},
};
module.exports = api;

View File

@@ -0,0 +1,209 @@
import { authWithHeaders } from '../../middlewares/auth';
import * as userLib from '../../libs/user';
const api = {};
/*
* NOTE most user routes are still in the v3 controller
* here there are only routes that had to be split from the v3 version because of
* some breaking change (for example because their returned the entire user object).
*/
/* NOTE this route has also an API v3 version */
/**
* @api {get} /api/v4/user Get the authenticated user's profile
* @apiName UserGet
* @apiGroup User
*
* @apiDescription The user profile contains data related to the authenticated user including (but not limited to);
* Achievements
* Authentications (including types and timestamps)
* Challenges
* Flags (including armoire, tutorial, tour etc...)
* Guilds
* History (including timestamps and values)
* Inbox (without messages in v4)
* Invitations (to parties/guilds)
* Items (character's full inventory)
* New Messages (flags for groups/guilds that have new messages)
* Notifications
* Party (includes current quest information)
* Preferences (user selected prefs)
* Profile (name, photo url, blurb)
* Purchased (includes purchase history, gem purchased items, plans)
* PushDevices (identifiers for mobile devices authorized)
* Stats (standard RPG stats, class, buffs, xp, etc..)
* Tags
* TasksOrder (list of all ids for dailys, habits, rewards and todos)
*
* @apiParam (Query) {UUID} userFields A list of comma separated user fields to be returned instead of the entire document. Notifications are always returned.
*
* @apiExample {curl} Example use:
* curl -i https://habitica.com/api/v3/user?userFields=achievements,items.mounts
*
* @apiSuccess {Object} data The user object
*
* @apiSuccessExample {json} Result:
* {
* "success": true,
* "data": {
* -- User data included here, for details of the user model see:
* -- https://github.com/HabitRPG/habitica/tree/develop/website/server/models/user
* }
* }
*
*/
api.getUser = {
method: 'GET',
middlewares: [authWithHeaders()],
url: '/user',
async handler (req, res) {
await userLib.get(req, res, { isV3: false });
},
};
/* NOTE this route has also an API v3 version */
/**
* @api {put} /api/v4/user Update the user
* @apiName UserUpdate
* @apiGroup User
*
* @apiDescription Some of the user items can be updated, such as preferences, flags and stats.
^
* @apiParamExample {json} Request-Example:
* {
* "achievements.habitBirthdays": 2,
* "profile.name": "MadPink",
* "stats.hp": 53,
* "flags.warnedLowHealth":false,
* "preferences.allocationMode":"flat",
* "preferences.hair.bangs": 3
* }
*
* @apiSuccess {Object} data The updated user object, the result is identical to the get user call
*
* @apiError (401) {NotAuthorized} messageUserOperationProtected Returned if the change is not allowed.
*
* @apiErrorExample {json} Error-Response:
* {
* "success": false,
* "error": "NotAuthorized",
* "message": "path `stats.class` was not saved, as it's a protected path."
* }
*/
api.updateUser = {
method: 'PUT',
middlewares: [authWithHeaders()],
url: '/user',
async handler (req, res) {
await userLib.update(req, res, { isV3: false });
},
};
/* NOTE this route has also an API v3 version */
/**
* @api {post} /api/v4/user/rebirth Use Orb of Rebirth on user
* @apiName UserRebirth
* @apiGroup User
*
* @apiSuccess {Object} data.user
* @apiSuccess {Array} data.tasks User's modified tasks (no rewards)
* @apiSuccess {String} message Success message
*
* @apiSuccessExample {json}
* {
* "success": true,
* "data": {
* },
* "message": "You have been reborn!"
* {
* "type": "REBIRTH_ACHIEVEMENT",
* "data": {},
* "id": "424d69fa-3a6d-47db-96a4-6db42ed77a43"
* }
* ]
* }
*
* @apiError {NotAuthorized} Not enough gems
*
* @apiErrorExample {json}
* {"success":false,"error":"NotAuthorized","message":"Not enough Gems"}
*/
api.userRebirth = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/rebirth',
async handler (req, res) {
await userLib.rebirth(req, res, { isV3: false });
},
};
/* NOTE this route has also an API v3 version */
/**
* @api {post} /api/v4/user/reroll Reroll a user using the Fortify Potion
* @apiName UserReroll
* @apiGroup User
*
* @apiSuccess {Object} data.user
* @apiSuccess {Object} data.tasks User's modified tasks (no rewards)
* @apiSuccess {Object} message Success message
*
* @apiSuccessExample {json}
* {
* "success": true,
* "data": {
* },
* "message": "Fortify complete!"
* }
*
* @apiError {NotAuthorized} Not enough gems
*
* @apiErrorExample {json}
* {"success":false,"error":"NotAuthorized","message":"Not enough Gems"}
*/
api.userReroll = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/reroll',
async handler (req, res) {
await userLib.reroll(req, res, { isV3: false });
},
};
/* NOTE this route has also an API v3 version */
/**
* @api {post} /api/v4/user/reset Reset user
* @apiName UserReset
* @apiGroup User
*
* @apiSuccess {Object} data.user
* @apiSuccess {Array} data.tasksToRemove IDs of removed tasks
* @apiSuccess {String} message Success message
*
* @apiSuccessExample {json}
* {
* "success": true,
* "data": {--TRUNCATED--},
* "tasksToRemove": [
* "ebb8748c-0565-431e-9036-b908da25c6b4",
* "12a1cecf-68eb-40a7-b282-4f388c32124c"
* ]
* },
* "message": "Reset complete!"
* }
*/
api.userReset = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/reset',
async handler (req, res) {
await userLib.reset(req, res, { isV3: false });
},
};
module.exports = api;

View File

@@ -0,0 +1,74 @@
import { authWithHeaders } from '../../../middlewares/auth';
import {
castSpell,
} from '../../../libs/spells';
let api = {};
/*
* NOTE most spells routes are still in the v3 controller
* here there are only routes that had to be split from the v3 version because of
* some breaking change (for example because their returned the entire user object).
*/
/* NOTE this route has also an API v3 version */
/**
* @api {post} /api/v4/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) {
await castSpell(req, res, {
isV3: false,
});
},
};
module.exports = api;

View File

@@ -1,5 +1,6 @@
import { authWithSession } from '../../middlewares/auth';
import { model as User } from '../../models/user';
import * as inboxLib from '../../libs/inbox';
import * as Tasks from '../../models/task';
import {
NotFound,
@@ -81,15 +82,23 @@ api.exportUserHistory = {
},
};
// Convert user to json and attach tasks divided by type
// Convert user to json and attach tasks divided by type and inbox messages
// at user.tasks[`${taskType}s`] (user.tasks.{dailys/habits/...})
async function _getUserDataForExport (user, xmlMode = false) {
let userData = user.toJSON();
userData.tasks = {};
let tasks = await Tasks.Task.find({
userId: user._id,
}).exec();
userData.inbox.messages = {};
const [tasks, messages] = await Promise.all([
Tasks.Task.find({
userId: user._id,
}).exec(),
inboxLib.getUserInbox(user, false),
]);
userData.inbox.messages = messages;
_(tasks)
.map(task => task.toJSON())
@@ -296,18 +305,14 @@ api.exportUserPrivateMessages = {
url: '/export/inbox.html',
middlewares: [authWithSession],
async handler (req, res) {
let user = res.locals.user;
const user = res.locals.user;
const timezoneOffset = user.preferences.timezoneOffset;
const dateFormat = user.preferences.dateFormat.toUpperCase();
const TO = res.t('to');
const FROM = res.t('from');
let inbox = Object.keys(user.inbox.messages).map(key => user.inbox.messages[key]);
inbox = _.sortBy(inbox, function sortBy (num) {
return num.sort * -1;
});
const inbox = await inboxLib.getUserInbox(user);
let messages = '<!DOCTYPE html><html><head></head><body>';

View File

@@ -0,0 +1,171 @@
import {
NotAuthorized,
NotFound,
} from '../../libs/errors';
import * as passwordUtils from '../../libs/password';
import { model as User } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../../libs/email';
import common from '../../../common';
import logger from '../../libs/logger';
import { decrypt } from '../../libs/encryption';
import { model as Group } from '../../models/group';
import moment from 'moment';
const USERNAME_LENGTH_MIN = 1;
const USERNAME_LENGTH_MAX = 20;
// When the user signed up after having been invited to a group, invite them automatically to the group
async function _handleGroupInvitation (user, invite) {
// wrapping the code in a try because we don't want it to prevent the user from signing up
// that's why errors are not translated
try {
let {sentAt, id: groupId, inviter} = JSON.parse(decrypt(invite));
// check that the invite has not expired (after 7 days)
if (sentAt && moment().subtract(7, 'days').isAfter(sentAt)) {
let err = new Error('Invite expired.');
err.privateData = invite;
throw err;
}
let group = await Group.getGroup({user, optionalMembership: true, groupId, fields: 'name type'});
if (!group) throw new NotFound('Group not found.');
if (group.type === 'party') {
user.invitations.party = {id: group._id, name: group.name, inviter};
user.invitations.parties.push(user.invitations.party);
} else {
user.invitations.guilds.push({id: group._id, name: group.name, inviter});
}
// award the inviter with 'Invited a Friend' achievement
inviter = await User.findById(inviter);
if (!inviter.achievements.invitedFriend) {
inviter.achievements.invitedFriend = true;
inviter.addNotification('INVITED_FRIEND_ACHIEVEMENT');
await inviter.save();
}
} catch (err) {
logger.error(err);
}
}
export async function registerLocal (req, res, { isV3 = false }) {
const existingUser = res.locals.user; // If adding local auth to social user
req.checkBody({
username: {
notEmpty: true,
errorMessage: res.t('missingUsername'),
// TODO use the constants in the error message above
isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameWrongLength')},
matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameBadCharacters')},
},
email: {
notEmpty: true,
errorMessage: res.t('missingEmail'),
isEmail: {errorMessage: res.t('notAnEmail')},
},
password: {
notEmpty: true,
errorMessage: res.t('missingPassword'),
equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')},
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let { email, username, password } = req.body;
// Get the lowercase version of username to check that we do not have duplicates
// So we can search for it in the database and then reject the choosen username if 1 or more results are found
email = email.toLowerCase();
username = username.trim();
let lowerCaseUsername = username.toLowerCase();
// Search for duplicates using lowercase version of username
let user = await User.findOne({$or: [
{'auth.local.email': email},
{'auth.local.lowerCaseUsername': lowerCaseUsername},
]}, {'auth.local': 1}).exec();
if (user) {
if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken'));
// Check that the lowercase username isn't already used
if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken'));
}
let hashed_password = await passwordUtils.bcryptHash(password); // eslint-disable-line camelcase
let newUser = {
auth: {
local: {
username,
lowerCaseUsername,
email,
hashed_password, // eslint-disable-line camelcase,
passwordHashMethod: 'bcrypt',
},
},
preferences: {
language: req.language,
},
};
if (existingUser) {
let hasSocialAuth = common.constants.SUPPORTED_SOCIAL_NETWORKS.find(network => {
if (existingUser.auth.hasOwnProperty(network.key)) {
return existingUser.auth[network.key].id;
}
});
if (!hasSocialAuth) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
existingUser.auth.local = newUser.auth.local;
newUser = existingUser;
} else {
newUser = new User(newUser);
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
if (req.query.groupInvite || req.query.partyInvite) {
await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite);
}
let savedUser = await newUser.save();
let userToJSON;
if (isV3) {
userToJSON = await savedUser.toJSONWithInbox();
} else {
userToJSON = savedUser.toJSON();
}
if (existingUser) {
res.respond(200, userToJSON.auth.local); // We convert to toJSON to hide private fields
} else {
let userJSON = userToJSON;
userJSON.newUser = true;
res.respond(201, userJSON);
}
// Clean previous email preferences and send welcome email
EmailUnsubscription
.remove({email: savedUser.auth.local.email})
.then(() => {
if (!existingUser) sendTxnEmail(savedUser, 'welcome');
});
if (!existingUser) {
res.analytics.track('register', {
category: 'acquisition',
type: 'local',
gaLabel: 'local',
uuid: savedUser._id,
headers: req.headers,
user: savedUser,
});
}
return null;
}

View File

@@ -1,4 +1,4 @@
import { model as Chat } from '../../models/chat';
import { chatModel as Chat } from '../../models/message';
import { MAX_CHAT_COUNT, MAX_SUBBED_GROUP_CHAT_COUNT } from '../../models/group';
// @TODO: Don't use this method when the group can be saved.

View File

@@ -9,7 +9,7 @@ import {
import { getGroupUrl, sendTxn } from '../email';
import slack from '../slack';
import { model as Group } from '../../models/group';
import { model as Chat } from '../../models/chat';
import { chatModel as Chat } from '../../models/message';
import apiError from '../apiError';
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS:COMMUNITY_MANAGER_EMAIL');

View File

@@ -0,0 +1,10 @@
import { model as Coupon } from '../../models/coupon';
export async function enterCode (req, res, user) {
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
await Coupon.apply(user, req, req.params.code);
}

View File

@@ -1,11 +1,47 @@
import { inboxModel as Inbox } from '../../models/message';
import { toArray, orderBy } from 'lodash';
export async function getUserInbox (user, asArray = true) {
const messages = (await Inbox
.find({ownerId: user._id})
.exec()).map(msg => msg.toJSON());
const messagesObj = Object.assign({}, user.inbox.messages); // copy, shallow clone
if (asArray) {
messages.push(...toArray(messagesObj));
return orderBy(messages, ['timestamp'], ['desc']);
} else {
messages.forEach(msg => messagesObj[msg._id] = msg);
return messagesObj;
}
}
export async function deleteMessage (user, messageId) {
if (user.inbox.messages[messageId]) {
if (user.inbox.messages[messageId]) { // compatibility
delete user.inbox.messages[messageId];
user.markModified(`inbox.messages.${messageId}`);
await user.save();
} else {
return false;
const message = await Inbox.findOne({_id: messageId, ownerId: user._id }).exec();
if (!message) return false;
await Inbox.remove({_id: message._id, ownerId: user._id}).exec();
}
return true;
}
export async function clearPMs (user) {
user.inbox.newMessages = 0;
// compatibility
user.inbox.messages = {};
user.markModified('inbox.messages');
await Promise.all([
user.save(),
Inbox.remove({ownerId: user._id}).exec(),
]);
}

View File

@@ -3,7 +3,15 @@ import * as Tasks from '../models/task';
import {
NotFound,
BadRequest,
NotAuthorized,
} from './errors';
import common from '../../common';
import {
model as Group,
} from '../models/group';
import apiError from '../libs/apiError';
const partyMembersFields = 'profile.name stats achievements items.special';
// @TODO: After refactoring individual spells, move quantity to the calculations
@@ -116,4 +124,91 @@ async function castUserSpell (res, req, party, partyMembers, targetId, user, spe
return partyMembers;
}
export {castTaskSpell, castMultiTaskSpell, castSelfSpell, castPartySpell, castUserSpell};
async function castSpell (req, res, {isV3 = false}) {
const user = res.locals.user;
const spellId = req.params.spellId;
const 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(apiError('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);
let userToJson = results[0];
if (isV3) userToJson = await userToJson.toJSONWithInbox();
res.respond(200, {
user: userToJson,
task: results[1],
});
} else if (targetType === 'self') {
await castSelfSpell(req, user, spell, quantity);
let userToJson = user;
if (isV3) userToJson = await userToJson.toJSONWithInbox();
res.respond(200, {
user: userToJson,
});
} 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);
if (isV3) response.user = await response.user.toJSONWithInbox();
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));
});
let userToJson = user;
if (isV3) userToJson = await userToJson.toJSONWithInbox();
res.respond(200, {
partyMembers: partyMembersRes,
user: userToJson,
});
if (party && !spell.silent) {
let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``;
const newChatMessage = party.sendChat(message);
await newChatMessage.save();
}
}
}
export {
castTaskSpell,
castMultiTaskSpell,
castSelfSpell,
castPartySpell,
castUserSpell,
castSpell,
};

View File

@@ -0,0 +1,242 @@
import common from '../../../common';
import * as Tasks from '../../models/task';
import _ from 'lodash';
import {
BadRequest,
NotAuthorized,
} from '../../libs/errors';
import { model as User } from '../../models/user';
export async function get (req, res, { isV3 = false }) {
const user = res.locals.user;
let userToJSON;
if (isV3) {
userToJSON = await user.toJSONWithInbox();
} else {
userToJSON = user.toJSON();
}
// Remove apiToken from response TODO make it private at the user level? returned in signup/login
delete userToJSON.apiToken;
if (!req.query.userFields) {
let {daysMissed} = user.daysUserHasMissed(new Date(), req);
userToJSON.needsCron = false;
if (daysMissed > 0) userToJSON.needsCron = true;
User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON);
}
return res.respond(200, userToJSON);
}
const updatablePaths = [
'_ABtests.counter',
'flags.customizationsNotification',
'flags.showTour',
'flags.tour',
'flags.tutorial',
'flags.communityGuidelinesAccepted',
'flags.welcomed',
'flags.cardReceived',
'flags.warnedLowHealth',
'flags.newStuff',
'achievements',
'party.order',
'party.orderAscending',
'party.quest.completed',
'party.quest.RSVPNeeded',
'preferences',
'profile',
'stats',
'inbox.optOut',
'tags',
];
// This tells us for which paths users can call `PUT /user`.
// The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs)
let acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (accumulator, val, leaf) => {
let found = _.find(updatablePaths, (rootPath) => {
return leaf.indexOf(rootPath) === 0;
});
if (found) accumulator[leaf] = true;
return accumulator;
}, {});
const restrictedPUTSubPaths = [
'stats.class',
'preferences.disableClasses',
'preferences.sleep',
'preferences.webhooks',
];
_.each(restrictedPUTSubPaths, (removePath) => {
delete acceptablePUTPaths[removePath];
});
const requiresPurchase = {
'preferences.background': 'background',
'preferences.shirt': 'shirt',
'preferences.size': 'size',
'preferences.skin': 'skin',
'preferences.hair.bangs': 'hair.bangs',
'preferences.hair.base': 'hair.base',
'preferences.hair.beard': 'hair.beard',
'preferences.hair.color': 'hair.color',
'preferences.hair.flower': 'hair.flower',
'preferences.hair.mustache': 'hair.mustache',
};
function checkPreferencePurchase (user, path, item) {
let itemPath = `${path}.${item}`;
let appearance = _.get(common.content.appearances, itemPath);
if (!appearance) return false;
if (appearance.price === 0) return true;
return _.get(user.purchased, itemPath);
}
export async function update (req, res, { isV3 = false }) {
const user = res.locals.user;
let promisesForTagsRemoval = [];
_.each(req.body, (val, key) => {
let purchasable = requiresPurchase[key];
if (purchasable && !checkPreferencePurchase(user, purchasable, val)) {
throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key }));
}
if (acceptablePUTPaths[key] && key !== 'tags') {
_.set(user, key, val);
} else if (key === 'tags') {
if (!Array.isArray(val)) throw new BadRequest('mustBeArray');
const removedTagsIds = [];
const oldTags = [];
// Keep challenge and group tags
user.tags.forEach(t => {
if (t.group) {
oldTags.push(t);
} else {
removedTagsIds.push(t.id);
}
});
user.tags = oldTags;
val.forEach(t => {
let oldI = removedTagsIds.findIndex(id => id === t.id);
if (oldI > -1) {
removedTagsIds.splice(oldI, 1);
}
user.tags.push(t);
});
// Remove from all the tasks
// NOTE each tag to remove requires a query
promisesForTagsRemoval = removedTagsIds.map(tagId => {
return Tasks.Task.update({
userId: user._id,
}, {
$pull: {
tags: tagId,
},
}, {multi: true}).exec();
});
} else {
throw new NotAuthorized(res.t('messageUserOperationProtected', { operation: key }));
}
});
await Promise.all([user.save()].concat(promisesForTagsRemoval));
let userToJSON = user;
if (isV3) userToJSON = await user.toJSONWithInbox();
return res.respond(200, userToJSON);
}
export async function reset (req, res, { isV3 = false }) {
const user = res.locals.user;
const tasks = await Tasks.Task.find({
userId: user._id,
...Tasks.taskIsGroupOrChallengeQuery,
}).select('_id type challenge group').exec();
const resetRes = common.ops.reset(user, tasks);
if (isV3) {
resetRes[0].user = await resetRes[0].user.toJSONWithInbox();
}
await Promise.all([
Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}),
user.save(),
]);
res.analytics.track('account reset', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
});
res.respond(200, ...resetRes);
}
export async function reroll (req, res, { isV3 = false }) {
let user = res.locals.user;
let query = {
userId: user._id,
type: {$in: ['daily', 'habit', 'todo']},
...Tasks.taskIsGroupOrChallengeQuery,
};
let tasks = await Tasks.Task.find(query).exec();
const rerollRes = common.ops.reroll(user, tasks, req, res.analytics);
if (isV3) {
rerollRes[0].user = await rerollRes[0].user.toJSONWithInbox();
}
let promises = tasks.map(task => task.save());
promises.push(user.save());
await Promise.all(promises);
res.respond(200, ...rerollRes);
}
export async function rebirth (req, res, { isV3 = false }) {
const user = res.locals.user;
const tasks = await Tasks.Task.find({
userId: user._id,
type: {$in: ['daily', 'habit', 'todo']},
...Tasks.taskIsGroupOrChallengeQuery,
}).exec();
const rebirthRes = common.ops.rebirth(user, tasks, req, res.analytics);
if (isV3) {
rebirthRes[0].user = await rebirthRes[0].user.toJSONWithInbox();
}
const toSave = tasks.map(task => task.save());
toSave.push(user.save());
await Promise.all(toSave);
res.respond(200, ...rebirthRes);
}

View File

@@ -33,7 +33,16 @@ app.use('/api/v3', v3Router);
// A list of v3 routes in the format METHOD-URL to skip
const v4RouterOverrides = [
// 'GET-/status', Example to override the GET /status api call
'POST-/user/auth/local/register',
'GET-/user',
'PUT-/user',
'POST-/user/class/cast/:spellId',
'POST-/user/rebirth',
'POST-/user/reset',
'POST-/user/reroll',
'DELETE-/user/messages/:id',
'DELETE-/user/messages',
'POST-/coupons/enter/:code',
];
const v4Router = express.Router(); // eslint-disable-line new-cap

View File

@@ -1,26 +0,0 @@
import mongoose from 'mongoose';
import baseModel from '../libs/baseModel';
const schema = new mongoose.Schema({
timestamp: Date,
user: String,
text: String,
contributor: {type: mongoose.Schema.Types.Mixed},
backer: {type: mongoose.Schema.Types.Mixed},
uuid: String,
id: String,
groupId: {type: String, ref: 'Group'},
flags: {type: mongoose.Schema.Types.Mixed, default: {}},
flagCount: {type: Number, default: 0},
likes: {type: mongoose.Schema.Types.Mixed},
userStyles: {type: mongoose.Schema.Types.Mixed},
_meta: {type: mongoose.Schema.Types.Mixed},
}, {
minimize: false, // Allow for empty flags to be saved
});
schema.plugin(baseModel, {
noSet: ['_id'],
});
export const model = mongoose.model('Chat', schema);

View File

@@ -7,7 +7,11 @@ import {
import shared from '../../common';
import _ from 'lodash';
import { model as Challenge} from './challenge';
import { model as Chat } from './chat';
import {
chatModel as Chat,
setUserStyles,
messageDefaults,
} from './message';
import * as Tasks from './task';
import validator from 'validator';
import { removeFromArray } from '../libs/collectionManipulators';
@@ -72,17 +76,7 @@ export let schema = new Schema({
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
type: {type: String, enum: ['guild', 'party'], required: true},
privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true},
chat: Array,
/*
# [{
# timestamp: Date
# user: String
# text: String
# contributor: String
# uuid: String
# id: String
# }]
*/
chat: Array, // Used for backward compatibility, but messages aren't stored here
leaderOnly: { // restrict group actions to leader (members can't do them)
challenges: {type: Boolean, default: false, required: true},
// invites: {type: Boolean, default: false, required: true},
@@ -473,81 +467,8 @@ schema.methods.getMemberCount = async function getMemberCount () {
return await User.count(query).exec();
};
export function chatDefaults (msg, user) {
const id = shared.uuid();
const message = {
id,
_id: id,
text: msg.substring(0, 3000),
timestamp: Number(new Date()),
likes: {},
flags: {},
flagCount: 0,
};
if (user) {
_.defaults(message, {
uuid: user._id,
contributor: user.contributor && user.contributor.toObject(),
backer: user.backer && user.backer.toObject(),
user: user.profile.name,
});
} else {
message.uuid = 'system';
}
return message;
}
function setUserStyles (newMessage, user) {
let userStyles = {};
userStyles.items = {gear: {}};
let userCopy = user;
if (user.toObject) userCopy = user.toObject();
if (userCopy.items) {
userStyles.items.gear = {};
userStyles.items.gear.costume = Object.assign({}, userCopy.items.gear.costume);
userStyles.items.gear.equipped = Object.assign({}, userCopy.items.gear.equipped);
userStyles.items.currentMount = userCopy.items.currentMount;
userStyles.items.currentPet = userCopy.items.currentPet;
}
if (userCopy.preferences) {
userStyles.preferences = {};
if (userCopy.preferences.style) userStyles.preferences.style = userCopy.preferences.style;
userStyles.preferences.hair = userCopy.preferences.hair;
userStyles.preferences.skin = userCopy.preferences.skin;
userStyles.preferences.shirt = userCopy.preferences.shirt;
userStyles.preferences.chair = userCopy.preferences.chair;
userStyles.preferences.size = userCopy.preferences.size;
userStyles.preferences.chair = userCopy.preferences.chair;
userStyles.preferences.background = userCopy.preferences.background;
userStyles.preferences.costume = userCopy.preferences.costume;
}
if (userCopy.stats) {
userStyles.stats = {};
userStyles.stats.class = userCopy.stats.class;
if (userCopy.stats.buffs) {
userStyles.stats.buffs = {
seafoam: userCopy.stats.buffs.seafoam,
shinySeed: userCopy.stats.buffs.shinySeed,
spookySparkles: userCopy.stats.buffs.spookySparkles,
snowball: userCopy.stats.buffs.snowball,
};
}
}
newMessage.userStyles = userStyles;
newMessage.markModified('userStyles');
}
schema.methods.sendChat = function sendChat (message, user, metaData) {
let newMessage = chatDefaults(message, user);
let newMessage = messageDefaults(message, user);
let newChatMessage = new Chat();
newChatMessage = Object.assign(newChatMessage, newMessage);
newChatMessage.groupId = this._id;
@@ -560,17 +481,6 @@ schema.methods.sendChat = function sendChat (message, user, metaData) {
newChatMessage._meta = metaData;
}
// @TODO: Completely remove the code below after migration
// this.chat.unshift(newMessage);
let maxCount = MAX_CHAT_COUNT;
if (this.isSubscribed()) {
maxCount = MAX_SUBBED_GROUP_CHAT_COUNT;
}
this.chat.splice(maxCount);
// do not send notifications for guilds with more than 5000 users and for the tavern
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > LARGE_GROUP_COUNT_MESSAGE_CUTOFF) {
return newChatMessage;

View File

@@ -0,0 +1,124 @@
import mongoose from 'mongoose';
import baseModel from '../libs/baseModel';
import { v4 as uuid } from 'uuid';
import { defaults } from 'lodash';
const defaultSchema = () => ({
id: String,
timestamp: Date,
text: String,
// sender properties
user: String, // profile name
contributor: {type: mongoose.Schema.Types.Mixed},
backer: {type: mongoose.Schema.Types.Mixed},
uuid: String, // sender uuid
userStyles: {type: mongoose.Schema.Types.Mixed},
flags: {type: mongoose.Schema.Types.Mixed, default: {}},
flagCount: {type: Number, default: 0},
likes: {type: mongoose.Schema.Types.Mixed},
_meta: {type: mongoose.Schema.Types.Mixed},
});
const chatSchema = new mongoose.Schema({
...defaultSchema(),
groupId: {type: String, ref: 'Group'},
}, {
minimize: false, // Allow for empty flags to be saved
});
chatSchema.plugin(baseModel, {
noSet: ['_id'],
});
const inboxSchema = new mongoose.Schema({
sent: {type: Boolean, default: false}, // if the owner sent this message
// the uuid of the user where the message is stored,
// we store two copies of each inbox messages:
// one for the sender and one for the receiver
ownerId: {type: String, ref: 'User'},
...defaultSchema(),
}, {
minimize: false, // Allow for empty flags to be saved
});
inboxSchema.plugin(baseModel, {
noSet: ['_id'],
});
export const chatModel = mongoose.model('Chat', chatSchema);
export const inboxModel = mongoose.model('Inbox', inboxSchema);
export function setUserStyles (newMessage, user) {
let userStyles = {};
userStyles.items = {gear: {}};
let userCopy = user;
if (user.toObject) userCopy = user.toObject();
if (userCopy.items) {
userStyles.items.gear = {};
userStyles.items.gear.costume = Object.assign({}, userCopy.items.gear.costume);
userStyles.items.gear.equipped = Object.assign({}, userCopy.items.gear.equipped);
userStyles.items.currentMount = userCopy.items.currentMount;
userStyles.items.currentPet = userCopy.items.currentPet;
}
if (userCopy.preferences) {
userStyles.preferences = {};
if (userCopy.preferences.style) userStyles.preferences.style = userCopy.preferences.style;
userStyles.preferences.hair = userCopy.preferences.hair;
userStyles.preferences.skin = userCopy.preferences.skin;
userStyles.preferences.shirt = userCopy.preferences.shirt;
userStyles.preferences.chair = userCopy.preferences.chair;
userStyles.preferences.size = userCopy.preferences.size;
userStyles.preferences.chair = userCopy.preferences.chair;
userStyles.preferences.background = userCopy.preferences.background;
userStyles.preferences.costume = userCopy.preferences.costume;
}
if (userCopy.stats) {
userStyles.stats = {};
userStyles.stats.class = userCopy.stats.class;
if (userCopy.stats.buffs) {
userStyles.stats.buffs = {
seafoam: userCopy.stats.buffs.seafoam,
shinySeed: userCopy.stats.buffs.shinySeed,
spookySparkles: userCopy.stats.buffs.spookySparkles,
snowball: userCopy.stats.buffs.snowball,
};
}
}
newMessage.userStyles = userStyles;
newMessage.markModified('userStyles');
}
export function messageDefaults (msg, user) {
const id = uuid();
const message = {
id,
_id: id,
text: msg.substring(0, 3000),
timestamp: Number(new Date()),
likes: {},
flags: {},
flagCount: 0,
};
if (user) {
defaults(message, {
uuid: user._id,
contributor: user.contributor && user.contributor.toObject(),
backer: user.backer && user.backer.toObject(),
user: user.profile.name,
});
} else {
message.uuid = 'system';
}
return message;
}

View File

@@ -2,15 +2,21 @@ import moment from 'moment';
import common from '../../../common';
import {
chatDefaults,
TAVERN_ID,
model as Group,
} from '../group';
import {defaults, map, flatten, flow, compact, uniq, partialRight} from 'lodash';
import {model as UserNotification} from '../userNotification';
import {
messageDefaults,
setUserStyles,
inboxModel as Inbox,
} from '../message';
import { defaults, map, flatten, flow, compact, uniq, partialRight } from 'lodash';
import { model as UserNotification } from '../userNotification';
import schema from './schema';
import payments from '../../libs/payments/payments';
import * as inboxLib from '../../libs/inbox';
import amazonPayments from '../../libs/payments/amazon';
import stripePayments from '../../libs/payments/stripe';
import paypalPayments from '../../libs/payments/paypal';
@@ -101,16 +107,19 @@ schema.methods.getObjectionsToInteraction = function getObjectionsToInteraction
* @return N/A
*/
schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, options) {
let sender = this;
let senderMsg = options.senderMsg || options.receiverMsg;
const sender = this;
const senderMsg = options.senderMsg || options.receiverMsg;
// whether to save users after sending the message, defaults to true
let saveUsers = options.save === false ? false : true;
const saveUsers = options.save === false ? false : true;
const newReceiverMessage = new Inbox({
ownerId: userToReceiveMessage._id,
});
Object.assign(newReceiverMessage, messageDefaults(options.receiverMsg, sender));
setUserStyles(newReceiverMessage, sender);
const newMessageReceiver = chatDefaults(options.receiverMsg, sender);
common.refPush(userToReceiveMessage.inbox.messages, newMessageReceiver);
userToReceiveMessage.inbox.newMessages++;
userToReceiveMessage._v++;
userToReceiveMessage.markModified('inbox.messages');
/* @TODO disabled until mobile is ready
@@ -134,15 +143,31 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o
*/
const newMessage = defaults({sent: true}, chatDefaults(senderMsg, userToReceiveMessage));
common.refPush(sender.inbox.messages, newMessage);
sender.markModified('inbox.messages');
const sendingToYourself = userToReceiveMessage._id === sender._id;
if (saveUsers) {
await Promise.all([userToReceiveMessage.save(), sender.save()]);
// Do not add the message twice when sending it to yourself
let newSenderMessage;
if (!sendingToYourself) {
newSenderMessage = new Inbox({
sent: true,
ownerId: sender._id,
});
Object.assign(newSenderMessage, messageDefaults(senderMsg, userToReceiveMessage));
setUserStyles(newSenderMessage, userToReceiveMessage);
}
return newMessage;
const promises = [newReceiverMessage.save()];
if (!sendingToYourself) promises.push(newSenderMessage.save());
if (saveUsers) {
promises.push(sender.save());
if (!sendingToYourself) promises.push(userToReceiveMessage.save());
}
await Promise.all(promises);
return sendingToYourself ? newReceiverMessage : newSenderMessage;
};
/**
@@ -367,3 +392,16 @@ schema.methods.isMemberOfGroupPlan = async function isMemberOfGroupPlan () {
schema.methods.isAdmin = function isAdmin () {
return this.contributor && this.contributor.admin;
};
// When converting to json add inbox messages from the Inbox collection
// for backward compatibility in API v3.
schema.methods.toJSONWithInbox = async function userToJSONWithInbox () {
const user = this;
const toJSON = user.toJSON();
if (toJSON.inbox) {
toJSON.inbox.messages = await inboxLib.getUserInbox(user, false);
}
return toJSON;
};

View File

@@ -558,11 +558,13 @@ let schema = new Schema({
tags: [TagSchema],
inbox: {
newMessages: {type: Number, default: 0},
blocks: {type: Array, default: () => []},
// messages are stored in the Inbox collection, this path will be removed
// as soon as the migration has run and all the messages have been removed from here
messages: {type: Schema.Types.Mixed, default: () => {
return {};
}},
newMessages: {type: Number, default: 0},
blocks: {type: Array, default: () => []},
optOut: {type: Boolean, default: false},
},
tasksOrder: {