diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c934ad7446..91711f15eb 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -47,6 +47,10 @@ "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", + "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", + "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", + "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", + "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\".", "partyMustbePrivate": "Parties must be private", "userAlreadyInGroup": "User already in that group.", "userAlreadyInvitedToGroup": "User already invited to that group.", diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 671a278d1d..cd0884ddbb 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -14,7 +14,7 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(async () => { - task = await user.post('/tasks', { + task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -42,7 +42,7 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', async () => { let anotherUser = await generateUser(); - let anotherUsersTask = await anotherUser.post('/tasks', { + let anotherUsersTask = await anotherUser.post('/tasks/user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js deleted file mode 100644 index 8d7bd154d0..0000000000 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; -import Q from 'q'; - -describe('GET /tasks', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns all user\'s tasks', async () => { - let createdTasks = await Q.all([ - user.post('/tasks', {text: 'test habit', type: 'habit'}), - ]); - - let length = createdTasks.length; - let tasks = await user.get('/tasks'); - - expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task - }); - - it('returns only a type of user\'s tasks if req.query.type is specified', async () => { - let task = await user.post('/tasks', {text: 'test habit', type: 'habit'}); - let tasks = await user.get('/tasks?type=habit'); - - expect(tasks.length).to.equal(1); - expect(tasks[0]._id).to.equal(task._id); - }); - - // TODO complete after task scoring is done - it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); -}); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 4b0d74543d..1e61feaa7b 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -15,7 +15,7 @@ describe('GET /tasks/:id', () => { let task; beforeEach(async () => { - task = await user.post('/tasks', { + task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -43,7 +43,7 @@ describe('GET /tasks/:id', () => { it('cannot get a task owned by someone else', async () => { let anotherUser = await generateUser(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js new file mode 100644 index 0000000000..6c4d4f5b30 --- /dev/null +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -0,0 +1,27 @@ +import { + generateUser, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tasks/user', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns all user\'s tasks', async () => { + let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user'); + expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task + }); + + it('returns only a type of user\'s tasks if req.query.type is specified', async () => { + let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user?type=habit'); + expect(tasks.length).to.equal(1); + expect(tasks[0]._id).to.equal(createdTasks[0]._id); + }); + + // TODO complete after task scoring is done + it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); +}); diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index cdfb94947e..fab83738f5 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -35,7 +35,7 @@ describe('POST /tasks/:id/score/:direction', () => { let todo; beforeEach(async () => { - todo = await user.post('/tasks', { + todo = await user.post('/tasks/user', { text: 'test todo', type: 'todo', }); @@ -134,7 +134,7 @@ describe('POST /tasks/:id/score/:direction', () => { let daily; beforeEach(async () => { - daily = await user.post('/tasks', { + daily = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -205,24 +205,24 @@ describe('POST /tasks/:id/score/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(async () => { - habit = await user.post('/tasks', { + habit = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); - minusHabit = await user.post('/tasks', { + minusHabit = await user.post('/tasks/user', { text: 'test min habit', type: 'habit', up: false, }); - plusHabit = await user.post('/tasks', { + plusHabit = await user.post('/tasks/user', { text: 'test plus habit', type: 'habit', down: false, }); - neitherHabit = await user.post('/tasks', { + neitherHabit = await user.post('/tasks/user', { text: 'test neither habit', type: 'habit', up: false, @@ -267,7 +267,7 @@ describe('POST /tasks/:id/score/:direction', () => { let reward, updatedUser; beforeEach(async () => { - reward = await user.post('/tasks', { + reward = await user.post('/tasks/user', { text: 'test reward', type: 'reward', value: 5, diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js similarity index 88% rename from test/api/v3/integration/tasks/POST-tasks.test.js rename to test/api/v3/integration/tasks/POST-tasks_user.test.js index e8203457b3..906bc8feed 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-v3-integration.helper'; -describe('POST /tasks', () => { +describe('POST /tasks/user', () => { let user; before(async () => { @@ -12,7 +12,7 @@ describe('POST /tasks', () => { context('validates params', async () => { it('returns an error if req.body.type is absent', async () => { - await expect(user.post('/tasks', { + await expect(user.post('/tasks/user', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -22,7 +22,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - await expect(user.post('/tasks', { + await expect(user.post('/tasks/user', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -32,7 +32,7 @@ describe('POST /tasks', () => { }); it('returns an error if one object inside an array is invalid', async () => { - await expect(user.post('/tasks', [ + await expect(user.post('/tasks/user', [ {type: 'habitF'}, {type: 'habit'}, ])).to.eventually.be.rejected.and.eql({ @@ -43,7 +43,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - await expect(user.post('/tasks', { + await expect(user.post('/tasks/user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -54,7 +54,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - await expect(user.post('/tasks', { + await expect(user.post('/tasks/user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, @@ -68,7 +68,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - await expect(user.post('/tasks', [ + await expect(user.post('/tasks/user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -82,14 +82,18 @@ describe('POST /tasks', () => { }); it('does not save any task sent in an array when 1 is invalid', async () => { - let originalTasks = await user.get('/tasks'); - await expect(user.post('/tasks', [ + let originalTasks = await user.get('/tasks/user'); + await expect(user.post('/tasks/user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, error: 'BadRequest', message: 'habit validation failed', + }).then(async () => { + let updatedTasks = await user.get('/tasks/user'); + + expect(updatedTasks).to.eql(originalTasks); }); let updatedTasks = await user.get('/tasks'); @@ -97,7 +101,7 @@ describe('POST /tasks', () => { }); it('automatically sets "task.userId" to user\'s uuid', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -108,7 +112,7 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', userId: 123, @@ -132,7 +136,7 @@ describe('POST /tasks', () => { }); it('ignores invalid fields', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notValid: true, @@ -144,7 +148,7 @@ describe('POST /tasks', () => { context('habits', () => { it('creates a habit', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', up: false, @@ -162,7 +166,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when a new habit is created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'an habit', }); @@ -174,7 +178,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when multiple habits are created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'habit', text: 'an habit', }, { @@ -189,7 +193,7 @@ describe('POST /tasks', () => { }); it('creates multiple habits', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test habit', type: 'habit', up: false, @@ -219,7 +223,7 @@ describe('POST /tasks', () => { }); it('defaults to setting up and down to true', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', notes: 1976, @@ -230,7 +234,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', checklist: [ @@ -244,7 +248,7 @@ describe('POST /tasks', () => { context('todos', () => { it('creates a todo', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test todo', type: 'todo', notes: 1976, @@ -257,7 +261,7 @@ describe('POST /tasks', () => { }); it('creates multiple todos', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test todo', type: 'todo', notes: 1976, @@ -280,7 +284,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when a new todo is created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'todo', text: 'a todo', }); @@ -292,7 +296,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when multiple todos are created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'todo', text: 'a todo', }, { @@ -307,7 +311,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test todo', type: 'todo', checklist: [ @@ -328,7 +332,7 @@ describe('POST /tasks', () => { it('creates a daily', async () => { let now = new Date(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notes: 1976, @@ -347,7 +351,7 @@ describe('POST /tasks', () => { }); it('creates multiple dailys', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test daily', type: 'daily', notes: 1976, @@ -370,7 +374,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when a new daily is created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'a daily', }); @@ -382,7 +386,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'daily', text: 'a daily', }, { @@ -397,7 +401,7 @@ describe('POST /tasks', () => { }); it('defaults to a weekly frequency, with every day set', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -416,7 +420,7 @@ describe('POST /tasks', () => { }); it('allows repeat field to be configured', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', repeat: { @@ -440,7 +444,7 @@ describe('POST /tasks', () => { it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -449,7 +453,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', checklist: [ @@ -468,7 +472,7 @@ describe('POST /tasks', () => { context('rewards', () => { it('creates a reward', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', notes: 1976, @@ -483,7 +487,7 @@ describe('POST /tasks', () => { }); it('creates multiple rewards', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test reward', type: 'reward', notes: 1976, @@ -510,7 +514,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.rewards when a new reward is created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'reward', text: 'a reward', }); @@ -522,7 +526,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'reward', text: 'a reward', }, { @@ -537,7 +541,7 @@ describe('POST /tasks', () => { }); it('defaults to a 0 value', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', }); @@ -546,7 +550,7 @@ describe('POST /tasks', () => { }); it('requires value to be coerced into a number', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', value: '10', @@ -556,7 +560,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', checklist: [ diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index ff42c9c3de..4751daf7c7 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -14,7 +14,7 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(async () => { - task = await user.post('/tasks', { + task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -61,7 +61,7 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(async () => { - habit = await user.post('/tasks', { + habit = await user.post('/tasks/user', { text: 'test habit', type: 'habit', notes: 1976, @@ -87,7 +87,7 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(async () => { - todo = await user.post('/tasks', { + todo = await user.post('/tasks/user', { text: 'test todo', type: 'todo', notes: 1976, @@ -142,7 +142,7 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(async () => { - daily = await user.post('/tasks', { + daily = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notes: 1976, @@ -244,7 +244,7 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(async () => { - reward = await user.post('/tasks', { + reward = await user.post('/tasks/user', { text: 'test reward', type: 'reward', notes: 1976, diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 094e3ae21f..cdd8cd09f5 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -12,7 +12,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('deletes a checklist item', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -26,7 +26,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('does not work with habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -39,7 +39,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('does not work with rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -60,7 +60,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index 45a6e94150..d7f738007e 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/checklist/', () => { }); it('adds a checklist item to a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -32,7 +32,7 @@ describe('POST /tasks/:taskId/checklist/', () => { }); it('does not add a checklist to habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -47,7 +47,7 @@ describe('POST /tasks/:taskId/checklist/', () => { }); it('does not add a checklist to rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 84fbbe7562..c91e365668 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('scores a checklist item', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -29,7 +29,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -44,7 +44,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -65,7 +65,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }); diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index cbc4ff5b21..a8d9c0536b 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -12,7 +12,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('updates a checklist item', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -35,7 +35,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -48,7 +48,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -69,7 +69,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }); diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index e2b5c8b015..02640f9879 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -12,7 +12,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('removes a tag from a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); @@ -28,7 +28,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 1466be73f1..93313b7a69 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('adds a tag to a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); @@ -24,7 +24,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a tag to a task twice', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); @@ -41,7 +41,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a non existing tag to a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 85ba270935..8203e00b1b 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -117,12 +117,13 @@ api.getGroups = { // If no valid value for type was supplied, return an error if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - let results = await Q.all(queries); // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + let results = _.reduce(await Q.all(queries), (previousValue, currentValue) => { + if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array + return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue + }, []); - res.respond(200, _.reduce(results, (m, v) => { - if (_.isEmpty(v)) return m; - return m.concat(Array.isArray(v) ? v : [v]); - }, [])); + res.respond(200, results); }, }; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 316a4054aa..ffa2d20bf5 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -2,6 +2,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; +import { model as Challenge } from '../../models/challenge'; import { NotFound, NotAuthorized, @@ -16,65 +17,207 @@ import { preenHistory } from '../../../../common/script/api-v3/preening'; let api = {}; +// challenge must be passed only when a challenge task is being created +async function _createTasks (req, res, user, challenge) { + let toSave = Array.isArray(req.body) ? req.body : [req.body]; + + toSave = toSave.map(taskData => { + // Validate that task.type is valid + if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); + + let taskType = taskData.type; + let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); + + if (challenge) { + newTask.challenge.id = challenge.id; + } else { + newTask.userId = user._id; + } + + // Validate that the task is valid and throw if it isn't + // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality + let validationErrors = newTask.validateSync(); + if (validationErrors) throw validationErrors; + + // Otherwise update the user/challenge + (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); + + return newTask; + }).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again + validateBeforeSave: false, + })); + + toSave.unshift((challenge || user).save()); + + let tasks = await Q.all(toSave); + tasks.splice(0, 1); // Remove user or challenge + return tasks; +} + /** - * @api {post} /tasks Create a new task. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @api {post} /tasks/user Create a new task belonging to the autheticated user. Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 - * @apiName CreateTask + * @apiName CreateUserTasks * @apiGroup Task * * @apiSuccess {Object|Array} task The newly created task(s) */ -api.createTask = { +api.createUserTasks = { method: 'POST', - url: '/tasks', + url: '/tasks/user', middlewares: [authWithHeaders(), cron], async handler (req, res) { - let tasksData = Array.isArray(req.body) ? req.body : [req.body]; - let user = res.locals.user; - - let toSave = tasksData.map(taskData => { - // Validate that task.type is valid - if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); - - let taskType = taskData.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); - newTask.userId = user._id; - - // Validate that the task is valid and throw if it isn't - // otherwise since we're saving user and task in parallel it could save the user with a tasksOrder that doens't match reality - let validationErrors = newTask.validateSync(); - if (validationErrors) throw validationErrors; - - // Otherwise update the user - user.tasksOrder[`${taskType}s`].unshift(newTask._id); - - return newTask; - }); - - // If all tasks are valid, save everything, withough running validation again - toSave = toSave.map(task => task.save({ - validateBeforeSave: false, - })); - toSave.unshift(user.save()); - let results = await Q.all(toSave); - - if (results.length === 2) { // Just one task created - res.respond(201, results[1]); - } else { - results.splice(0, 1); // remove the user - res.respond(201, results); - } + let tasks = await _createTasks(req, res, res.locals.user); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); }, }; /** - * @api {get} /tasks Get an user's tasks + * @api {post} /tasks/challenge/:challengeId Create a new task belonging to the challenge. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @apiVersion 3.0.0 + * @apiName CreateChallengeTasks + * @apiGroup Task + * + * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to. + * + * @apiSuccess {Object|Array} task The newly created task(s) + */ +api.createChallengeTasks = { + method: 'POST', + url: '/tasks/challenge/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; + + let user = res.local.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findOne({_id: challengeId}).exec(); + + // If the challenge does not exist, or if it exists but user is not the leader -> throw error + if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + + let tasks = await _createTasks(req, res, user, challenge); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); + + // If adding tasks to a challenge -> sync users + if (challenge) challenge.addTasks(tasks); // TODO catch/log + }, +}; + +// challenge must be passed only when a challenge task is being created +async function _getTasks (req, res, user, challenge) { + let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id}; + let type = req.query.type; + + if (type) { + query.type = type; + if (type === 'todo') query.completed = false; // Exclude completed todos + } else { + query.$or = [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ]; + } + + if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { + if (challenge) throw new BadRequest(res.t('noCompletedTodosChallenge')); // no completed todos for challenges + + let queryCompleted = Tasks.Task.find({ + type: 'todo', + completed: true, + }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos + dateCompleted: 1, + }); + + let results = await Q.all([ + queryCompleted.exec(), + Tasks.Task.find(query).exec(), + ]); + + res.respond(200, results[1].concat(results[0])); + } else { + let tasks = await Tasks.Task.find(query).exec(); + res.respond(200, tasks); + } +} + +/** + * @api {get} /tasks/user Get an user's tasks + * @apiVersion 3.0.0 + * @apiName GetUserTasks + * @apiGroup Task + * + * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". + * + * @apiSuccess {Array} tasks An array of task objects + */ +api.getUserTasks = { + method: 'GET', + url: '/tasks/user', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + await _getTasks(req, res, res.locals.user); + }, +}; + +/** + * @api {get} /tasks/challenge/:challengeId Get a challenge's tasks + * @apiVersion 3.0.0 + * @apiName GetChallengeTasks + * @apiGroup Task + * + * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. + * + * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * + * @apiSuccess {Array} tasks An array of task objects + */ +api.getChallengeTasks = { + method: 'GET', + url: '/tasks/challenge/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.local.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); + + // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('challengeNotFound')); + } + + await _getTasks(req, res, res.locals.user, challenge); + }, +}; + +/** + * @api {get} /tasks/:tasksOwner/:challengeId Get an user's tasks * @apiVersion 3.0.0 * @apiName GetTasks * @apiGroup Task * + * @apiParam {string="user","challenge"} tasksOwner Query parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. + * @apiParam {UUID} challengeId Optional query parameter. If "tasksOwner" is "challenge" then required to specify the challenge id. * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo" + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". * * @apiSuccess {Array} tasks An array of task objects */ @@ -83,13 +226,11 @@ api.getTasks = { url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - let user = res.locals.user; - let query = {userId: user._id}; + let challengeId = req.query.challengeId; + let challenge; + + let query = challenge ? {'challenge.id': challengeId, userId: {$exists: false}} : {userId: user._id}; let type = req.query.type; if (type) { @@ -103,6 +244,8 @@ api.getTasks = { } if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { + if (challengeId) throw new BadRequest(res.t('noCompletedTodosChallenge')); + let queryCompleted = Tasks.Task.find({ type: 'todo', completed: true, @@ -147,10 +290,19 @@ api.getTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + let challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('taskNotFound')); + } + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } + res.respond(200, task); }, }; @@ -171,6 +323,7 @@ api.updateTask = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty @@ -181,10 +334,17 @@ api.updateTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } // If checklist is updated -> replace the original one if (req.body.checklist) { @@ -207,6 +367,7 @@ api.updateTask = { let savedTask = await task.save(); res.respond(200, savedTask); + if (challenge) challenge.updateTask(savedTask); // TODO catch/log }, }; @@ -347,6 +508,7 @@ api.scoreTask = { // completed todos cannot be moved, they'll be returned ordered by date of completion // TODO check that it works when a tag is selected or todos are split between dated and due +// TODO support challenges? /** * @api {post} /tasks/move/:taskId/to/:position Move a task to a new position * @apiVersion 3.0.0 @@ -414,6 +576,7 @@ api.addChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty and is an array @@ -423,16 +586,25 @@ api.addChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); let savedTask = await task.save(); res.respond(200, savedTask); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -495,6 +667,7 @@ api.updateChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -504,10 +677,17 @@ api.updateChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let item = _.find(task.checklist, {_id: req.params.itemId}); @@ -517,6 +697,7 @@ api.updateChecklistItem = { let savedTask = await task.save(); res.respond(200, savedTask); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -537,6 +718,7 @@ api.removeChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -546,10 +728,17 @@ api.removeChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); @@ -557,8 +746,9 @@ api.removeChecklistItem = { task.checklist.splice(itemI, 1); - await task.save(); + let savedTask = await task.save(); res.respond(200, {}); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -646,11 +836,11 @@ api.removeTagFromTask = { }, }; -// Remove a task from user.tasksOrder -function _removeTaskTasksOrder (user, taskId) { +// Remove a task from (user|challenge).tasksOrder +function _removeTaskTasksOrder (userOrChallenge, taskId) { // Loop through all lists and when the task is found, remove it and return for (let i = 0; i < Tasks.tasksTypes.length; i++) { - let list = user.tasksOrder[`${Tasks.tasksTypes[i]}s`]; + let list = userOrChallenge.tasksOrder[`${Tasks.tasksTypes[i]}s`]; let index = list.indexOf(taskId); if (index !== -1) { @@ -678,6 +868,7 @@ api.deleteTask = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -686,16 +877,25 @@ api.deleteTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } else if (task.userId && task.challenge.id) { + throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + } - _removeTaskTasksOrder(user, req.params.taskId); + _removeTaskTasksOrder(challenge || user, req.params.taskId); await Q.all([user.save(), task.remove()]); res.respond(200, {}); + if (challenge) challenge.removeTask(task); }, }; diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 9457926233..aaebc476dc 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -35,6 +35,7 @@ export default function cronMiddleware (req, res, next) { cron({user, tasksByType, now, daysMissed, analytics}); // Clean completed todos - 30 days for free users, 90 for subscribers + // Do not delete challenges completed todos TODO unless the task is broken? Task.remove({ userId: user._id, type: 'todo', @@ -42,6 +43,7 @@ export default function cronMiddleware (req, res, next) { dateCompleted: { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), }, + 'challenge.id': {$exists: false}, }).exec(); // TODO catch error or at least log it let ranCron = user.isModified(); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 00ab26a491..198ea2e476 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -4,6 +4,7 @@ import validator from 'validator'; import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; import * as Tasks from './task'; +import { model as User } from './user'; let Schema = mongoose.Schema; @@ -30,37 +31,18 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); - -// Syncing logic - +// Takes a Task document and return a plain object of attributes that can be synced to the user function _syncableAttrs (task) { - let t = task.toObject(); // lodash doesn't seem to like _.omit on EmbeddedDocument + let t = task.toObject(); // lodash doesn't seem to like _.omit on Document // only sync/compare important attrs - let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO use whitelist instead of blacklist? + let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } -// TODO redo -// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers -/* function comparableData(obj) { - return JSON.stringify( - _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) - .sortBy('id') // we don't want to update if they're sort-order is different - .transform(function(result, task){ - result.push(syncableAttrs(task)); - }) - .value()) -} - -ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { - return comparableData(this) !== comparableData(newData); -}*/ - -// Syncs all new tasks, deleted tasks, etc to the user object -schema.methods.syncToUser = function syncChallengeToUser (user) { - if (!user) throw new Error('User required.'); - +// Sync challenge to user, including tasks and tags. +// Used when user joins the challenge or to force sync. +schema.methods.syncToUser = async function syncChallengeToUser (user) { let challenge = this; challenge.shortName = challenge.shortName || challenge.name; @@ -84,8 +66,7 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { }); } - // Sync new tasks and updated tasks - return Q.all([ + let [challengeTasks, userTasks] = await Q.all([ // Find original challenge tasks Tasks.Task.find({ userId: {$exists: false}, @@ -96,43 +77,112 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { userId: user._id, 'challenge.id': challenge._id, }).exec(), - ]) - .then(results => { - let challengeTasks = results[0]; - let userTasks = results[1]; - let toSave = []; // An array of things to save + ]); - challengeTasks.forEach(chalTask => { - let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); + let toSave = []; // An array of things to save - if (!matchingTask) { // If the task is new, create it - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); - matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; - matchingTask.userId = user._id; - user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); - } else { - _.merge(matchingTask, _syncableAttrs(chalTask)); - // Make sure the task is in user.tasksOrder TODO necessary? - let orderList = user.tasksOrder[`${chalTask.type}s`]; - if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); - } + challengeTasks.forEach(chalTask => { + let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); - if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided - if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing - toSave.push(matchingTask.save()); - }); + if (!matchingTask) { // If the task is new, create it + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; + matchingTask.userId = user._id; + user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); + } else { + _.merge(matchingTask, _syncableAttrs(chalTask)); + // Make sure the task is in user.tasksOrder TODO necessary? + let orderList = user.tasksOrder[`${chalTask.type}s`]; + if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); + } - // Flag deleted tasks as "broken" - userTasks.forEach(userTask => { - if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { - userTask.challenge.broken = 'TASK_DELETED'; - toSave.push(userTask.save()); - } - }); - - toSave.push(user.save()); - return Q.all(toSave); + if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided + if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing + toSave.push(matchingTask.save()); }); + + // Flag deleted tasks as "broken" + userTasks.forEach(userTask => { + if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { + userTask.challenge.broken = 'TASK_DELETED'; + toSave.push(userTask.save()); + } + }); + + toSave.push(user.save()); + return Q.all(toSave); +}; + +async function _fetchMembersIds (challengeId) { + return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id); +} + +// Add a new task to challenge members +schema.methods.addTasks = async function challengeAddTasks (tasks) { + let challenge = this; + let membersIds = await _fetchMembersIds(challenge._id); + + // Sync each user sequentially + for (let memberId of membersIds) { + let updateTasksOrderQ = {$push: {}}; + let toSave = []; + + // TODO eslint complaints about ahving a function inside a loop -> make sure it works + tasks.forEach(chalTask => { // eslint-disable-line no-loop-func + let userTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + userTask.challenge = {taskId: chalTask._id, id: challenge._id}; + userTask.userId = memberId; + + let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`]; + if (!tasksOrderList) { + updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = { + $position: 0, // unshift + $each: [userTask._id], + }; + } else { + tasksOrderList.$each.unshift(userTask._id); + } + + toSave.push(userTask); + }); + + // Update the user + toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec()); + await Q.all(toSave); // eslint-disable-line babel/no-await-in-loop + } +}; + +// Sync updated task to challenge members +schema.methods.updateTask = async function challengeUpdateTask (task) { + let challenge = this; + + let updateCmd = {$set: {}}; + + _syncableAttrs(task).forEach((value, key) => { + updateCmd.$set[key] = value; + }); + + // TODO reveiw + // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks + await Tasks.Task.update({ + userId: {$exists: true}, + 'challenge.id': challenge.id, + 'challenge.taskId': task._id, + }, updateCmd, {multi: true}).exec(); +}; + +// Remove a task from challenge members +schema.methods.removeTask = async function challengeRemoveTask (task) { + let challenge = this; + + // Set the task as broken + await Tasks.Task.update({ + userId: {$exists: true}, + 'challenge.id': challenge.id, + 'challenge.taskId': task._id, + }, { + $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt? + }).lean().exec(); }; export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/task.js b/website/src/models/task.js index b31676da10..3fd574af93 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -28,7 +28,7 @@ export let TaskSchema = new Schema({ challenge: { id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task - taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task + taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task TODO unique index? broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? },