diff --git a/migrations/20180811_inboxOutsideUser.js b/migrations/20180811_inboxOutsideUser.js new file mode 100644 index 0000000000..29a1061827 --- /dev/null +++ b/migrations/20180811_inboxOutsideUser.js @@ -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; diff --git a/test/api/unit/models/group.test.js b/test/api/unit/models/group.test.js index bdb6d7ce45..91a80dad1a 100644 --- a/test/api/unit/models/group.test.js +++ b/test/api/unit/models/group.test.js @@ -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'); diff --git a/test/api/v3/integration/inbox/GET-inbox_messages.test.js b/test/api/v3/integration/inbox/GET-inbox_messages.test.js index 5066958c63..188b961a10 100644 --- a/test/api/v3/integration/inbox/GET-inbox_messages.test.js +++ b/test/api/v3/integration/inbox/GET-inbox_messages.test.js @@ -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'); diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js index ceae749907..bd2579c351 100644 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js @@ -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'; diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js index f8ac9bb5ad..025643cd93 100644 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js @@ -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'; diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js index f614969330..bd4b910c12 100644 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js @@ -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', () => { diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index 0975db52ef..8f6f4e6442 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -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; diff --git a/test/api/v4/coupon/POST-coupons_enter_code.test.js b/test/api/v4/coupon/POST-coupons_enter_code.test.js new file mode 100644 index 0000000000..01fe5fd0b6 --- /dev/null +++ b/test/api/v4/coupon/POST-coupons_enter_code.test.js @@ -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'}); + }); +}); diff --git a/test/api/v4/inbox/DELETE-inbox_clear.test.js b/test/api/v4/inbox/DELETE-inbox_clear.test.js new file mode 100644 index 0000000000..ff0af5cceb --- /dev/null +++ b/test/api/v4/inbox/DELETE-inbox_clear.test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/test/api/v4/user/GET-user.test.js b/test/api/v4/user/GET-user.test.js new file mode 100644 index 0000000000..bf6cd2bb50 --- /dev/null +++ b/test/api/v4/user/GET-user.test.js @@ -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; + }); +}); diff --git a/test/api/v4/user/POST-user_class_cast_spellId.test.js b/test/api/v4/user/POST-user_class_cast_spellId.test.js new file mode 100644 index 0000000000..90c2eae1db --- /dev/null +++ b/test/api/v4/user/POST-user_class_cast_spellId.test.js @@ -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'); +}); diff --git a/test/api/v4/user/POST-user_rebirth.test.js b/test/api/v4/user/POST-user_rebirth.test.js new file mode 100644 index 0000000000..94b91ddf6b --- /dev/null +++ b/test/api/v4/user/POST-user_rebirth.test.js @@ -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); + }); +}); diff --git a/test/api/v4/user/POST-user_reroll.test.js b/test/api/v4/user/POST-user_reroll.test.js new file mode 100644 index 0000000000..10ac0c2843 --- /dev/null +++ b/test/api/v4/user/POST-user_reroll.test.js @@ -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); + }); +}); diff --git a/test/api/v4/user/POST-user_reset.test.js b/test/api/v4/user/POST-user_reset.test.js new file mode 100644 index 0000000000..c8cf7b9f6b --- /dev/null +++ b/test/api/v4/user/POST-user_reset.test.js @@ -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; + }); +}); diff --git a/test/api/v4/user/PUT-user.test.js b/test/api/v4/user/PUT-user.test.js new file mode 100644 index 0000000000..edd4a601ca --- /dev/null +++ b/test/api/v4/user/PUT-user.test.js @@ -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', + }); + }); + }); +}); diff --git a/test/api/v4/user/auth/POST-register_local.test.js b/test/api/v4/user/auth/POST-register_local.test.js new file mode 100644 index 0000000000..c995ac3615 --- /dev/null +++ b/test/api/v4/user/auth/POST-register_local.test.js @@ -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); + }); + }); +}); diff --git a/test/common/ops/clearPMs.test.js b/test/common/ops/clearPMs.test.js deleted file mode 100644 index 8fd29f7ae1..0000000000 --- a/test/common/ops/clearPMs.test.js +++ /dev/null @@ -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({}); - }); -}); diff --git a/test/common/ops/deletePM.test.js b/test/common/ops/deletePM.test.js deleted file mode 100644 index abc7e4f12c..0000000000 --- a/test/common/ops/deletePM.test.js +++ /dev/null @@ -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' }); - }); -}); diff --git a/website/common/script/index.js b/website/common/script/index.js index 57eaa890ca..bc235e9d89 100644 --- a/website/common/script/index.js +++ b/website/common/script/index.js @@ -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, diff --git a/website/common/script/ops/clearPMs.js b/website/common/script/ops/clearPMs.js deleted file mode 100644 index de9760b439..0000000000 --- a/website/common/script/ops/clearPMs.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function clearPMs (user) { - user.inbox.messages = {}; - if (user.markModified) user.markModified('inbox.messages'); - return [ - user.inbox.messages, - ]; -}; diff --git a/website/common/script/ops/deletePM.js b/website/common/script/ops/deletePM.js deleted file mode 100644 index e780c94274..0000000000 --- a/website/common/script/ops/deletePM.js +++ /dev/null @@ -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, - ]; -}; diff --git a/website/common/script/ops/index.js b/website/common/script/ops/index.js index dadecd6815..f9407fad67 100644 --- a/website/common/script/ops/index.js +++ b/website/common/script/ops/index.js @@ -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, diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index 760a9f584b..e8e1119438 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -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; diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js index fe705d3a1a..6405eb59ad 100644 --- a/website/server/controllers/api-v3/challenges.js +++ b/website/server/controllers/api-v3/challenges.js @@ -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; diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index f1b2b7a9cd..b5cf41d489 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -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; diff --git a/website/server/controllers/api-v3/coupon.js b/website/server/controllers/api-v3/coupon.js index f18644306e..7acc406a44 100644 --- a/website/server/controllers/api-v3/coupon.js +++ b/website/server/controllers/api-v3/coupon.js @@ -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(); diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index f386cba456..73d41e2957 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -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; diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index d009d536b6..21dfdadbb0 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -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({ diff --git a/website/server/controllers/api-v3/inbox.js b/website/server/controllers/api-v3/inbox.js index f2684d9e9b..54a577de46 100644 --- a/website/server/controllers/api-v3/inbox.js +++ b/website/server/controllers/api-v3/inbox.js @@ -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); }, }; diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index ac6c855834..ff09a2cd8a 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -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); diff --git a/website/server/controllers/api-v3/news.js b/website/server/controllers/api-v3/news.js index 84ee898091..b006b0afea 100644 --- a/website/server/controllers/api-v3/news.js +++ b/website/server/controllers/api-v3/news.js @@ -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; diff --git a/website/server/controllers/api-v3/notifications.js b/website/server/controllers/api-v3/notifications.js index 48fc61f304..114017c2d8 100644 --- a/website/server/controllers/api-v3/notifications.js +++ b/website/server/controllers/api-v3/notifications.js @@ -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; diff --git a/website/server/controllers/api-v3/pushNotifications.js b/website/server/controllers/api-v3/pushNotifications.js index 4f0586d5ad..27c37f7812 100644 --- a/website/server/controllers/api-v3/pushNotifications.js +++ b/website/server/controllers/api-v3/pushNotifications.js @@ -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; diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index b579cc3862..fd4a724b68 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -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; diff --git a/website/server/controllers/api-v3/shops.js b/website/server/controllers/api-v3/shops.js index 11c0c8fac0..32c46bcd9d 100644 --- a/website/server/controllers/api-v3/shops.js +++ b/website/server/controllers/api-v3/shops.js @@ -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; diff --git a/website/server/controllers/api-v3/tags.js b/website/server/controllers/api-v3/tags.js index 520db3edbf..9b8ea2a596 100644 --- a/website/server/controllers/api-v3/tags.js +++ b/website/server/controllers/api-v3/tags.js @@ -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; diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 39a12edf55..0ee33b26a5 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -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; diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js index 11f45a2259..69f050ad3d 100644 --- a/website/server/controllers/api-v3/tasks/groups.js +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -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(); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 651033919a..a52de6b76c 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -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(); diff --git a/website/server/controllers/api-v3/user/spells.js b/website/server/controllers/api-v3/user/spells.js index 3a8e6a56d9..8740458427 100644 --- a/website/server/controllers/api-v3/user/spells.js +++ b/website/server/controllers/api-v3/user/spells.js @@ -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, + }); }, }; diff --git a/website/server/controllers/api-v3/user/stats.js b/website/server/controllers/api-v3/user/stats.js index 658a3e4fbd..c3d3ed74e1 100644 --- a/website/server/controllers/api-v3/user/stats.js +++ b/website/server/controllers/api-v3/user/stats.js @@ -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; diff --git a/website/server/controllers/api-v3/webhook.js b/website/server/controllers/api-v3/webhook.js index 0084caea85..094e280088 100644 --- a/website/server/controllers/api-v3/webhook.js +++ b/website/server/controllers/api-v3/webhook.js @@ -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; diff --git a/website/server/controllers/api-v4/auth.js b/website/server/controllers/api-v4/auth.js new file mode 100644 index 0000000000..ce194aefd1 --- /dev/null +++ b/website/server/controllers/api-v4/auth.js @@ -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; \ No newline at end of file diff --git a/website/server/controllers/api-v4/coupon.js b/website/server/controllers/api-v4/coupon.js new file mode 100644 index 0000000000..dde0f8c8d0 --- /dev/null +++ b/website/server/controllers/api-v4/coupon.js @@ -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; \ No newline at end of file diff --git a/website/server/controllers/api-v4/inbox.js b/website/server/controllers/api-v4/inbox.js index faaf8fd4a6..bfceab993c 100644 --- a/website/server/controllers/api-v4/inbox.js +++ b/website/server/controllers/api-v4/inbox.js @@ -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; diff --git a/website/server/controllers/api-v4/user.js b/website/server/controllers/api-v4/user.js new file mode 100644 index 0000000000..01cc9e7034 --- /dev/null +++ b/website/server/controllers/api-v4/user.js @@ -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; diff --git a/website/server/controllers/api-v4/user/spells.js b/website/server/controllers/api-v4/user/spells.js new file mode 100644 index 0000000000..b167a1aa0a --- /dev/null +++ b/website/server/controllers/api-v4/user/spells.js @@ -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; diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index af59996f4f..70cb25a91a 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -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 = ''; diff --git a/website/server/libs/auth/index.js b/website/server/libs/auth/index.js new file mode 100644 index 0000000000..39658dee61 --- /dev/null +++ b/website/server/libs/auth/index.js @@ -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; +} \ No newline at end of file diff --git a/website/server/libs/chat/group-chat.js b/website/server/libs/chat/group-chat.js index 55a6089c94..72edb4d52d 100644 --- a/website/server/libs/chat/group-chat.js +++ b/website/server/libs/chat/group-chat.js @@ -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. diff --git a/website/server/libs/chatReporting/groupChatReporter.js b/website/server/libs/chatReporting/groupChatReporter.js index f883b6716d..1e06d24d5c 100644 --- a/website/server/libs/chatReporting/groupChatReporter.js +++ b/website/server/libs/chatReporting/groupChatReporter.js @@ -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'); diff --git a/website/server/libs/coupons/index.js b/website/server/libs/coupons/index.js new file mode 100644 index 0000000000..e32c469962 --- /dev/null +++ b/website/server/libs/coupons/index.js @@ -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); +} \ No newline at end of file diff --git a/website/server/libs/inbox/index.js b/website/server/libs/inbox/index.js index a41479fb44..5f5fd76658 100644 --- a/website/server/libs/inbox/index.js +++ b/website/server/libs/inbox/index.js @@ -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(), + ]); +} diff --git a/website/server/libs/spells.js b/website/server/libs/spells.js index 61f4d83ccb..2af8f7e8b7 100644 --- a/website/server/libs/spells.js +++ b/website/server/libs/spells.js @@ -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, +}; diff --git a/website/server/libs/user/index.js b/website/server/libs/user/index.js new file mode 100644 index 0000000000..2889241de9 --- /dev/null +++ b/website/server/libs/user/index.js @@ -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); +} \ No newline at end of file diff --git a/website/server/middlewares/appRoutes.js b/website/server/middlewares/appRoutes.js index f735771cef..781cfdcb07 100644 --- a/website/server/middlewares/appRoutes.js +++ b/website/server/middlewares/appRoutes.js @@ -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 diff --git a/website/server/models/chat.js b/website/server/models/chat.js deleted file mode 100644 index cbddeb0361..0000000000 --- a/website/server/models/chat.js +++ /dev/null @@ -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); diff --git a/website/server/models/group.js b/website/server/models/group.js index bc6a8c29e2..15f880fd71 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -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; diff --git a/website/server/models/message.js b/website/server/models/message.js new file mode 100644 index 0000000000..5e7089677c --- /dev/null +++ b/website/server/models/message.js @@ -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; +} diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 3c1a035ea9..ea5575fbf0 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -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; +}; diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index 81d7386d5c..2e258d6424 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -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: {