From 836cee25311ce0a3447085e4970cb676987a4499 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 3 Sep 2016 03:54:55 -0500 Subject: [PATCH] Groups assign tasks (#7887) * Added initial code for creating and reading group tasks * Separated group task routes. Separated shared task functions * Added taskOrder to group * Minor style fixes * Fixed lint issues * Added unit tests for task manager * Updated task helper functions * Fixed history test * Fixed group task query * Removed extra var * Updated with new file structure * Updated noset values * Removed unecessary undefineds, fixed comments, Added apiignore * Separated group task routes. Separated shared task functions * Added unit tests for task manager * Added initial groups assign route and tests * Added sync assigned task to user * Added unassign route and unlink method * Added remove and unlink group task * Updated linking and unlinking. Add test for updating task info * Added delete group task and tests * Added sync on task update and tests * Added multiple users assignment * Updated unassign for multiple users * Added test for delete task with multiple assigend users * Added update task for multiple assigned users * Fixed issue with get tasks * Abstracted syncable attributes and add tests * Fixed merge conflicts * Fixed style issues, limited group query fields, and added await * Fixed group fields needed. Removed api v2 code * Fixed style issues * Moved group field under group sub document. Updated tests. Fixed other broken tests * Renamed linkedTaskId and fixed broken alias tests * Added debug middleware to new routes * Fixed debug middleware import * Added additional user id check for original group tasks * Updated challenge task check to look for challenge id * Added checklist sync fix --- common/locales/en/groups.json | 4 +- .../dataexport/GET-export_history.csv.test.js | 7 +- .../groups/DELETE-group_tasks_id.test.js | 72 +++++++ .../tasks/groups/GET-tasks_group_id.test.js | 80 ++++++++ .../tasks/groups/POST-tasks_group_id.test.js | 119 +++++++++++ ...POST-tasks_group_id_assign_user_id.test.js | 113 +++++++++++ .../POST-tasks_task_id_unassign.test.js | 117 +++++++++++ .../tasks/groups/PUT-group_task_id.test.js | 92 +++++++++ test/api/v3/unit/libs/taskManager.js | 172 ++++++++++++++++ test/api/v3/unit/models/group_tasks.test.js | 174 ++++++++++++++++ test/helpers/api-unit.helper.js | 7 +- website/server/controllers/api-v3/tasks.js | 177 ++++------------- .../server/controllers/api-v3/tasks/groups.js | 185 ++++++++++++++++++ website/server/libs/taskManager.js | 177 +++++++++++++++++ website/server/models/challenge.js | 22 +-- website/server/models/group.js | 123 +++++++++++- website/server/models/task.js | 9 +- 17 files changed, 1488 insertions(+), 162 deletions(-) create mode 100644 test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js create mode 100644 test/api/v3/integration/tasks/groups/GET-tasks_group_id.test.js create mode 100644 test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js create mode 100644 test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js create mode 100644 test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js create mode 100644 test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js create mode 100644 test/api/v3/unit/libs/taskManager.js create mode 100644 test/api/v3/unit/models/group_tasks.test.js create mode 100644 website/server/controllers/api-v3/tasks/groups.js create mode 100644 website/server/libs/taskManager.js diff --git a/common/locales/en/groups.json b/common/locales/en/groups.json index 535ec483ec..5deb87aa92 100644 --- a/common/locales/en/groups.json +++ b/common/locales/en/groups.json @@ -191,5 +191,7 @@ "uuidsMustBeAnArray": "User ID invites must be an array.", "emailsMustBeAnArray": "Email address invites must be an array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!" + "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", + "onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!", + "onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned" } diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js index 22eaf3d99f..df2b9bb680 100644 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -39,12 +39,13 @@ describe('GET /export/history.csv', () => { let res = await user.get('/export/history.csv'); let splitRes = res.split('\n'); + expect(splitRes[0]).to.equal('Task Name,Task ID,Task Type,Date,Value'); expect(splitRes[1]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[0].value}`); expect(splitRes[2]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[1].value}`); - expect(splitRes[3]).to.equal(`daily 1,${tasks[1]._id},daily,${moment(tasks[1].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[1].history[0].value}`); - expect(splitRes[4]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[0].value}`); - expect(splitRes[5]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[1].value}`); + expect(splitRes[3]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[0].value}`); + expect(splitRes[4]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[1].value}`); + expect(splitRes[5]).to.equal(`daily 1,${tasks[1]._id},daily,${moment(tasks[1].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[1].history[0].value}`); expect(splitRes[6]).to.equal(''); }); }); diff --git a/test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js b/test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js new file mode 100644 index 0000000000..1c76957d11 --- /dev/null +++ b/test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js @@ -0,0 +1,72 @@ +import { + translate as t, + createAndPopulateGroup, +} from '../../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('DELETE /tasks/:id', () => { + let user, guild, member, member2, task; + + function findAssignedTask (memberTask) { + return memberTask.group.id === guild._id; + } + + beforeEach(async () => { + let {group, members, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + }, + members: 2, + }); + + guild = group; + user = groupLeader; + member = members[0]; + member2 = members[1]; + + task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + await user.post(`/tasks/${task._id}/assign/${member._id}`); + await user.post(`/tasks/${task._id}/assign/${member2._id}`); + }); + + it('deletes a group task', async () => { + await user.del(`/tasks/${task._id}`); + + await expect(user.get(`/tasks/${task._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('unlinks assigned user', async () => { + await user.del(`/tasks/${task._id}`); + + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(syncedTask.group.broken).to.equal('TASK_DELETED'); + }); + + it('unlinks all assigned users', async () => { + await user.del(`/tasks/${task._id}`); + + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + let member2Tasks = await member2.get('/tasks/user'); + let member2SyncedTask = find(member2Tasks, findAssignedTask); + + expect(syncedTask.group.broken).to.equal('TASK_DELETED'); + expect(member2SyncedTask.group.broken).to.equal('TASK_DELETED'); + }); +}); diff --git a/test/api/v3/integration/tasks/groups/GET-tasks_group_id.test.js b/test/api/v3/integration/tasks/groups/GET-tasks_group_id.test.js new file mode 100644 index 0000000000..e81eef76e6 --- /dev/null +++ b/test/api/v3/integration/tasks/groups/GET-tasks_group_id.test.js @@ -0,0 +1,80 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; +import { each } from 'lodash'; + +describe('GET /tasks/group/:groupId', () => { + let user, group, task, groupWithTask; + let tasks = []; + let tasksToTest = { + habit: { + text: 'test habit', + type: 'habit', + up: false, + down: true, + }, + todo: { + text: 'test todo', + type: 'todo', + }, + daily: { + text: 'test daily', + type: 'daily', + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }, + reward: { + text: 'test reward', + type: 'reward', + }, + }; + + before(async () => { + user = await generateUser(); + group = await generateGroup(user); + }); + + it('returns error when group is not found', async () => { + let dummyId = generateUUID(); + + await expect(user.get(`/tasks/group/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + each(tasksToTest, (taskValue, taskType) => { + context(`${taskType}`, () => { + before(async () => { + task = await user.post(`/tasks/group/${group._id}`, taskValue); + tasks.push(task); + groupWithTask = await user.get(`/groups/${group._id}`); + }); + + it('gets group tasks', async () => { + let getTask = await user.get(`/tasks/group/${groupWithTask._id}`); + expect(getTask).to.eql(tasks); + }); + + it('gets group tasks filtered by type', async () => { + let groupTasks = await user.get(`/tasks/group/${groupWithTask._id}?type=${task.type}s`); + expect(groupTasks).to.eql([task]); + }); + + it('cannot get a task owned by someone else', async () => { + let anotherUser = await generateUser(); + + await expect(anotherUser.get(`/tasks/group/${groupWithTask._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + }); +}); diff --git a/test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js b/test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js new file mode 100644 index 0000000000..382448cbf2 --- /dev/null +++ b/test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js @@ -0,0 +1,119 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/group/:groupid', () => { + let user, guild; + + beforeEach(async () => { + user = await generateUser({balance: 1}); + guild = await generateGroup(user, {type: 'guild'}); + }); + + it('returns error when group is not found', async () => { + await expect(user.post(`/tasks/group/${generateUUID()}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns error when user is not a member of the group', async () => { + let userWithoutChallenge = await generateUser(); + + await expect(userWithoutChallenge.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns error when non leader tries to create a task', async () => { + let userThatIsNotLeaderOfGroup = await generateUser({ + guilds: [guild._id], + }); + + await expect(userThatIsNotLeaderOfGroup.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderCanEditTasks'), + }); + }); + + it('creates a habit', async () => { + let task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + + expect(groupTask[0].group.id).to.equal(guild._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + }); + + it('creates a todo', async () => { + let task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test todo', + type: 'todo', + notes: 1976, + }); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + + expect(groupTask[0].group.id).to.equal(guild._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + }); + + it('creates a daily', async () => { + let now = new Date(); + let task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test daily', + type: 'daily', + notes: 1976, + frequency: 'daily', + everyX: 5, + startDate: now, + }); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + + expect(groupTask[0].group.id).to.equal(guild._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + expect(new Date(task.startDate)).to.eql(now); + }); +}); diff --git a/test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js b/test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js new file mode 100644 index 0000000000..fee02aa02d --- /dev/null +++ b/test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js @@ -0,0 +1,113 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; +import { find } from 'lodash'; + +describe('POST /tasks/:taskId', () => { + let user, guild, member, member2, task; + + function findAssignedTask (memberTask) { + return memberTask.group.id === guild._id; + } + + beforeEach(async () => { + let {group, members, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + }, + members: 2, + }); + + guild = group; + user = groupLeader; + member = members[0]; + member2 = members[1]; + + task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + }); + + it('returns error when task is not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/assign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('returns error when task is not a group task', async () => { + let nonGroupTask = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + await expect(user.post(`/tasks/${nonGroupTask._id}/assign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupTasksCanBeAssigned'), + }); + }); + + it('returns error when user is not a member of the group', async () => { + let nonUser = await generateUser(); + + await expect(nonUser.post(`/tasks/${task._id}/assign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns error when non leader tries to create a task', async () => { + await expect(member.post(`/tasks/${task._id}/assign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderCanEditTasks'), + }); + }); + + it('assigns a task to a user', async () => { + await user.post(`/tasks/${task._id}/assign/${member._id}`); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(groupTask[0].group.assignedUsers).to.contain(member._id); + expect(syncedTask).to.exist; + }); + + it('assigns a task to multiple users', async () => { + await user.post(`/tasks/${task._id}/assign/${member._id}`); + await user.post(`/tasks/${task._id}/assign/${member2._id}`); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + + let memberTasks = await member.get('/tasks/user'); + let member1SyncedTask = find(memberTasks, findAssignedTask); + + let member2Tasks = await member2.get('/tasks/user'); + let member2SyncedTask = find(member2Tasks, findAssignedTask); + + expect(groupTask[0].group.assignedUsers).to.contain(member._id); + expect(groupTask[0].group.assignedUsers).to.contain(member2._id); + expect(member1SyncedTask).to.exist; + expect(member2SyncedTask).to.exist; + }); +}); diff --git a/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js b/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js new file mode 100644 index 0000000000..99009a8a8c --- /dev/null +++ b/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js @@ -0,0 +1,117 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; +import { find } from 'lodash'; + +describe('POST /tasks/:taskId/unassign/:memberId', () => { + let user, guild, member, member2, task; + + function findAssignedTask (memberTask) { + return memberTask.group.id === guild._id; + } + + beforeEach(async () => { + let {group, members, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + }, + members: 2, + }); + + guild = group; + user = groupLeader; + member = members[0]; + member2 = members[1]; + + task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + await user.post(`/tasks/${task._id}/assign/${member._id}`); + }); + + it('returns error when task is not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/unassign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('returns error when task is not a group task', async () => { + let nonGroupTask = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + await expect(user.post(`/tasks/${nonGroupTask._id}/unassign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupTasksCanBeAssigned'), + }); + }); + + it('returns error when user is not a member of the group', async () => { + let nonUser = await generateUser(); + + await expect(nonUser.post(`/tasks/${task._id}/unassign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns error when non leader tries to create a task', async () => { + await expect(member.post(`/tasks/${task._id}/unassign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderCanEditTasks'), + }); + }); + + it('unassigns a user from a task', async () => { + await user.post(`/tasks/${task._id}/unassign/${member._id}`); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(groupTask[0].group.assignedUsers).to.not.contain(member._id); + expect(syncedTask).to.not.exist; + }); + + it('unassigns a user and only that user from a task', async () => { + await user.post(`/tasks/${task._id}/assign/${member2._id}`); + + await user.post(`/tasks/${task._id}/unassign/${member._id}`); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + + let memberTasks = await member.get('/tasks/user'); + let member1SyncedTask = find(memberTasks, findAssignedTask); + + let member2Tasks = await member2.get('/tasks/user'); + let member2SyncedTask = find(member2Tasks, findAssignedTask); + + expect(groupTask[0].group.assignedUsers).to.not.contain(member._id); + expect(member1SyncedTask).to.not.exist; + + expect(groupTask[0].group.assignedUsers).to.contain(member2._id); + expect(member2SyncedTask).to.exist; + }); +}); diff --git a/test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js b/test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js new file mode 100644 index 0000000000..de67b4d3bd --- /dev/null +++ b/test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js @@ -0,0 +1,92 @@ +import { + createAndPopulateGroup, +} from '../../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('PUT /tasks/:id', () => { + let user, guild, member, member2, task; + + function findAssignedTask (memberTask) { + return memberTask.group.id === guild._id; + } + + beforeEach(async () => { + let {group, members, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + }, + members: 2, + }); + + guild = group; + user = groupLeader; + member = members[0]; + member2 = members[1]; + + task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + await user.post(`/tasks/${task._id}/assign/${member._id}`); + await user.post(`/tasks/${task._id}/assign/${member2._id}`); + }); + + it('updates a group task', async () => { + let savedHabit = await user.put(`/tasks/${task._id}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + }); + + expect(savedHabit.text).to.eql('some new text'); + expect(savedHabit.notes).to.eql('some new notes'); + expect(savedHabit.up).to.eql(false); + expect(savedHabit.down).to.eql(false); + }); + + it('updates the linked tasks', async () => { + await user.put(`/tasks/${task._id}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + }); + + + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(syncedTask.text).to.eql('some new text'); + expect(syncedTask.up).to.eql(false); + expect(syncedTask.down).to.eql(false); + }); + + it('updates the linked tasks for all assigned users', async () => { + await user.put(`/tasks/${task._id}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + }); + + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + let member2Tasks = await member2.get('/tasks/user'); + let member2SyncedTask = find(member2Tasks, findAssignedTask); + + expect(syncedTask.text).to.eql('some new text'); + expect(syncedTask.up).to.eql(false); + expect(syncedTask.down).to.eql(false); + + expect(member2SyncedTask.text).to.eql('some new text'); + expect(member2SyncedTask.up).to.eql(false); + expect(member2SyncedTask.down).to.eql(false); + }); +}); diff --git a/test/api/v3/unit/libs/taskManager.js b/test/api/v3/unit/libs/taskManager.js new file mode 100644 index 0000000000..0c40a51c58 --- /dev/null +++ b/test/api/v3/unit/libs/taskManager.js @@ -0,0 +1,172 @@ +import { + createTasks, + getTasks, + syncableAttrs, +} from '../../../../../website/server/libs/taskManager'; +import i18n from '../../../../../common/script/i18n'; +import { + generateUser, + generateGroup, + generateChallenge, +} from '../../../../helpers/api-unit.helper.js'; + +describe('taskManager', () => { + let user, group, challenge; + let testHabit = { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }; + let req = {}; + let res = {}; + + beforeEach(() => { + req = {}; + res = {}; + user = generateUser(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + + challenge = generateChallenge({ + name: 'test challenge', + shortName: 'testc', + group: group._id, + leader: user._id, + }); + }); + + it('creates user tasks', async () => { + req.body = testHabit; + res.t = i18n.t; + + let newTasks = await createTasks(req, res, {user}); + let newTask = newTasks[0]; + + expect(newTask.text).to.equal(testHabit.text); + expect(newTask.type).to.equal(testHabit.type); + expect(newTask.up).to.equal(testHabit.up); + expect(newTask.down).to.equal(testHabit.down); + expect(newTask.createdAt).isNotEmtpy; + }); + + it('gets user tasks', async () => { + req.body = testHabit; + res.t = i18n.t; + + await createTasks(req, res, {user}); + + req.body = {}; + req.query = { + type: 'habits', + }; + + let tasks = await getTasks(req, res, {user}); + let task = tasks[0]; + + expect(task.text).to.equal(testHabit.text); + expect(task.type).to.equal(testHabit.type); + expect(task.up).to.equal(testHabit.up); + expect(task.down).to.equal(testHabit.down); + expect(task.createdAt).isNotEmtpy; + }); + + it('creates group tasks', async () => { + req.body = testHabit; + res.t = i18n.t; + + let newTasks = await createTasks(req, res, {user, group}); + let newTask = newTasks[0]; + + expect(newTask.text).to.equal(testHabit.text); + expect(newTask.type).to.equal(testHabit.type); + expect(newTask.up).to.equal(testHabit.up); + expect(newTask.down).to.equal(testHabit.down); + expect(newTask.createdAt).isNotEmtpy; + expect(newTask.group.id).to.equal(group._id); + }); + + it('gets group tasks', async () => { + req.body = testHabit; + res.t = i18n.t; + + await createTasks(req, res, {user, group}); + + req.body = {}; + req.query = { + type: 'habits', + }; + + let tasks = await getTasks(req, res, {user, group}); + let task = tasks[0]; + + expect(task.text).to.equal(testHabit.text); + expect(task.type).to.equal(testHabit.type); + expect(task.up).to.equal(testHabit.up); + expect(task.down).to.equal(testHabit.down); + expect(task.createdAt).isNotEmtpy; + expect(task.group.id).to.equal(group._id); + }); + + it('creates challenge tasks', async () => { + req.body = testHabit; + res.t = i18n.t; + + let newTasks = await createTasks(req, res, {user, challenge}); + let newTask = newTasks[0]; + + expect(newTask.text).to.equal(testHabit.text); + expect(newTask.type).to.equal(testHabit.type); + expect(newTask.up).to.equal(testHabit.up); + expect(newTask.down).to.equal(testHabit.down); + expect(newTask.createdAt).isNotEmtpy; + expect(newTask.challenge.id).to.equal(challenge._id); + }); + + it('gets challenge tasks', async () => { + req.body = testHabit; + res.t = i18n.t; + + await createTasks(req, res, {user, challenge}); + + req.body = {}; + req.query = { + type: 'habits', + }; + + let tasks = await getTasks(req, res, {user, challenge}); + let task = tasks[0]; + + expect(task.text).to.equal(testHabit.text); + expect(task.type).to.equal(testHabit.type); + expect(task.up).to.equal(testHabit.up); + expect(task.down).to.equal(testHabit.down); + expect(task.createdAt).isNotEmtpy; + expect(task.challenge.id).to.equal(challenge._id); + }); + + it('returns syncable attibutes', async () => { + req.body = testHabit; + res.t = i18n.t; + + let tasks = await createTasks(req, res, {user, challenge}); + + let syncableTask = syncableAttrs(tasks[0]); + + expect(syncableTask._id).to.not.exist; + expect(syncableTask.userId).to.not.exist; + expect(syncableTask.challenge).to.not.exist; + expect(syncableTask.history).to.not.exist; + expect(syncableTask.tags).to.not.exist; + expect(syncableTask.completed).to.not.exist; + expect(syncableTask.streak).to.not.exist; + expect(syncableTask.notes).to.not.exist; + expect(syncableTask.updatedAt).to.not.exist; + }); +}); diff --git a/test/api/v3/unit/models/group_tasks.test.js b/test/api/v3/unit/models/group_tasks.test.js new file mode 100644 index 0000000000..bb43c20d30 --- /dev/null +++ b/test/api/v3/unit/models/group_tasks.test.js @@ -0,0 +1,174 @@ +import { model as Challenge } from '../../../../../website/server/models/challenge'; +import { model as Group } from '../../../../../website/server/models/group'; +import { model as User } from '../../../../../website/server/models/user'; +import * as Tasks from '../../../../../website/server/models/task'; +import { each, find } from 'lodash'; + +describe('Group Task Methods', () => { + let guild, leader, challenge, task; + let tasksToTest = { + habit: { + text: 'test habit', + type: 'habit', + up: false, + down: true, + }, + todo: { + text: 'test todo', + type: 'todo', + }, + daily: { + text: 'test daily', + type: 'daily', + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }, + reward: { + text: 'test reward', + type: 'reward', + }, + }; + + function findLinkedTask (updatedLeadersTask) { + return updatedLeadersTask.group.taskId === task._id; + } + + beforeEach(async () => { + guild = new Group({ + name: 'test party', + type: 'guild', + }); + + leader = new User({ + guilds: [guild._id], + }); + + guild.leader = leader._id; + + challenge = new Challenge({ + name: 'Test Challenge', + shortName: 'Test', + leader: leader._id, + group: guild._id, + }); + + leader.challenges = [challenge._id]; + + await Promise.all([ + guild.save(), + leader.save(), + challenge.save(), + ]); + }); + + each(tasksToTest, (taskValue, taskType) => { + context(`${taskType}`, () => { + beforeEach(async() => { + task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue)); + task.group.id = guild._id; + await task.save(); + }); + + it('syncs an assigned task to a user', async () => { + await guild.syncTask(task, leader); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, findLinkedTask); + + expect(task.group.assignedUsers).to.contain(leader._id); + expect(syncedTask).to.exist; + }); + + it('syncs updated info for assigned task to a user', async () => { + await guild.syncTask(task, leader); + let updatedTaskName = 'Update Task name'; + task.text = updatedTaskName; + await guild.syncTask(task, leader); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, findLinkedTask); + + expect(task.group.assignedUsers).to.contain(leader._id); + expect(syncedTask).to.exist; + expect(syncedTask.text).to.equal(task.text); + }); + + it('syncs updated info for assigned task to all users', async () => { + let newMember = new User({ + guilds: [guild._id], + }); + await newMember.save(); + + await guild.syncTask(task, leader); + await guild.syncTask(task, newMember); + + let updatedTaskName = 'Update Task name'; + task.text = updatedTaskName; + + await guild.updateTask(task); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, findLinkedTask); + + let updatedMember = await User.findOne({_id: newMember._id}); + let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}}); + let syncedMemberTask = find(updatedMemberTasks, findLinkedTask); + + expect(task.group.assignedUsers).to.contain(leader._id); + expect(syncedTask).to.exist; + expect(syncedTask.text).to.equal(task.text); + + expect(task.group.assignedUsers).to.contain(newMember._id); + expect(syncedMemberTask).to.exist; + expect(syncedMemberTask.text).to.equal(task.text); + }); + + it('removes an assigned task and unlinks assignees', async () => { + await guild.syncTask(task, leader); + await guild.removeTask(task); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, findLinkedTask); + + expect(syncedTask.group.broken).to.equal('TASK_DELETED'); + }); + + it('unlinks and deletes group tasks for a user when remove-all is specified', async () => { + await guild.syncTask(task, leader); + await guild.unlinkTask(task, leader, 'remove-all'); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, findLinkedTask); + + expect(task.group.assignedUsers).to.not.contain(leader._id); + expect(syncedTask).to.not.exist; + }); + + it('unlinks and keeps group tasks for a user when keep-all is specified', async () => { + await guild.syncTask(task, leader); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, findLinkedTask); + + await guild.unlinkTask(task, leader, 'keep-all'); + + updatedLeader = await User.findOne({_id: leader._id}); + updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let updatedSyncedTask = find(updatedLeadersTasks, function findUpdatedLinkedTask (updatedLeadersTask) { + return updatedLeadersTask._id === syncedTask._id; + }); + + expect(task.group.assignedUsers).to.not.contain(leader._id); + expect(updatedSyncedTask).to.exist; + expect(updatedSyncedTask.group._id).to.be.empty; + }); + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 42b717ae91..99468ed7d4 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -3,6 +3,7 @@ import mongoose from 'mongoose'; import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/server/models/user'; import { model as Group } from '../../website/server/models/group'; +import { model as Challenge } from '../../website/server/models/challenge'; import mongo from './mongo'; // eslint-disable-line import moment from 'moment'; import i18n from '../../common/script/i18n'; @@ -20,7 +21,11 @@ export function generateUser (options = {}) { } export function generateGroup (options = {}) { - return new Group(options).toObject(); + return new Group(options); +} + +export function generateChallenge (options = {}) { + return new Challenge(options); } export function generateRes (options = {}) { diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index b33a631cc6..c4d37d5cda 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -9,70 +9,17 @@ import { NotAuthorized, BadRequest, } from '../../libs/errors'; +import { + createTasks, + getTasks, +} from '../../libs/taskManager'; import common from '../../../../common'; import Bluebird from 'bluebird'; import _ from 'lodash'; import logger from '../../libs/logger'; let api = {}; - -async function _validateTaskAlias (tasks, res) { - let tasksWithAliases = tasks.filter(task => task.alias); - let aliases = tasksWithAliases.map(task => task.alias); - - // Compares the short names in tasks against - // a Set, where values cannot repeat. If the - // lengths are different, some name was duplicated - if (aliases.length !== [...new Set(aliases)].length) { - throw new BadRequest(res.t('taskAliasAlreadyUsed')); - } - - await Bluebird.map(tasksWithAliases, (task) => { - return task.validate(); - }); -} - -// 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.sanitize(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; - }); - - // tasks with aliases need to be validated asyncronously - await _validateTaskAlias(toSave, res); - - toSave = toSave.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 Bluebird.all(toSave); - tasks.splice(0, 1); // Remove user or challenge - return tasks; -} +let requiredGroupFields = '_id leader tasksOrder name'; /** * @api {post} /api/v3/tasks/user Create a new task belonging to the user @@ -88,7 +35,8 @@ api.createUserTasks = { url: '/tasks/user', middlewares: [authWithHeaders()], async handler (req, res) { - let tasks = await _createTasks(req, res, res.locals.user); + let user = res.locals.user; + let tasks = await createTasks(req, res, {user}); res.respond(201, tasks.length === 1 ? tasks[0] : tasks); }, }; @@ -123,75 +71,15 @@ api.createChallengeTasks = { if (!challenge) 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); + 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); - - return null; }, }; -// 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) { - if (type === 'todos') { - query.completed = false; // Exclude completed todos - query.type = 'todo'; - } else if (type === 'completedTodos' || type === '_allCompletedTodos') { // _allCompletedTodos is currently in BETA and is likely to be removed in future - let limit = 30; - - if (type === '_allCompletedTodos') { - limit = 0; // no limit - } - query = Tasks.Task.find({ - userId: user._id, - type: 'todo', - completed: true, - }).limit(limit).sort({ - dateCompleted: -1, - }); - } else { - query.type = type.slice(0, -1); // removing the final "s" - } - } else { - query.$or = [ // Exclude completed todos - {type: 'todo', completed: false}, - {type: {$in: ['habit', 'daily', 'reward']}}, - ]; - } - - let tasks = await Tasks.Task.find(query).exec(); - - // Order tasks based on tasksOrder - if (type && type !== 'completedTodos' && type !== '_allCompletedTodos') { - let order = (challenge || user).tasksOrder[type]; - let orderedTasks = new Array(tasks.length); - let unorderedTasks = []; // what we want to add later - - tasks.forEach((task, index) => { - let taskId = task._id; - let i = order[index] === taskId ? index : order.indexOf(taskId); - if (i === -1) { - unorderedTasks.push(task); - } else { - orderedTasks[i] = task; - } - }); - - // Remove empty values from the array and add any unordered task - orderedTasks = _.compact(orderedTasks).concat(unorderedTasks); - res.respond(200, orderedTasks); - } else { - res.respond(200, tasks); - } -} - /** * @api {get} /api/v3/tasks/user Get a user's tasks * @apiVersion 3.0.0 @@ -214,7 +102,10 @@ api.getUserTasks = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - return await _getTasks(req, res, res.locals.user); + let user = res.locals.user; + + let tasks = await getTasks(req, res, {user}); + return res.respond(200, tasks); }, }; @@ -249,7 +140,8 @@ api.getChallengeTasks = { let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - return await _getTasks(req, res, res.locals.user, challenge); + let tasks = await getTasks(req, res, {user, challenge}); + return res.respond(200, tasks); }, }; @@ -274,7 +166,7 @@ api.getTask = { 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 + } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights let challenge = await Challenge.find({_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')); @@ -312,10 +204,16 @@ api.updateTask = { let taskId = req.params.taskId; let task = await Tasks.Task.findByIdOrAlias(taskId, user._id); + let group; 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 + } else if (task.group.id && !task.userId) { + // @TODO: Abstract this access snippet + group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); @@ -341,10 +239,13 @@ api.updateTask = { // see https://github.com/Automattic/mongoose/issues/2749 let savedTask = await task.save(); + + if (group && task.group.id && task.group.assignedUsers.length > 0) { + await group.updateTask(savedTask); + } + res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); - - return null; }, }; @@ -461,8 +362,6 @@ api.scoreTask = { direction }); */ - - return null; }, }; @@ -548,7 +447,7 @@ api.addChecklistItem = { 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 + } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); @@ -563,8 +462,6 @@ api.addChecklistItem = { res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); - - return null; }, }; @@ -638,7 +535,7 @@ api.updateChecklistItem = { 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 + } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); @@ -655,8 +552,6 @@ api.updateChecklistItem = { res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); - - return null; }, }; @@ -690,7 +585,7 @@ api.removeChecklistItem = { 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 + } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); @@ -705,8 +600,6 @@ api.removeChecklistItem = { let savedTask = await task.save(); res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); - - return null; }, }; @@ -952,7 +845,13 @@ api.deleteTask = { 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 + } else if (task.group.id && !task.userId) { + // @TODO: Abstract this access snippet + let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + await group.removeTask(task); + } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); @@ -971,8 +870,6 @@ api.deleteTask = { res.respond(200, {}); if (challenge) challenge.removeTask(task); - - return null; }, }; diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js new file mode 100644 index 0000000000..58e11a06f4 --- /dev/null +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -0,0 +1,185 @@ +import { authWithHeaders } from '../../../middlewares/auth'; +import ensureDevelpmentMode from '../../../middlewares/ensureDevelpmentMode'; +import * as Tasks from '../../../models/task'; +import { model as Group } from '../../../models/group'; +import { model as User } from '../../../models/user'; +import { + NotFound, + NotAuthorized, +} from '../../../libs/errors'; +import { + createTasks, + getTasks, +} from '../../../libs/taskManager'; + +let requiredGroupFields = '_id leader tasksOrder name'; +let types = Tasks.tasksTypes.map(type => `${type}s`); +let api = {}; + +/** + * @api {post} /api/v3/tasks/group/:groupId Create a new task belonging to a group + * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @apiVersion 3.0.0 + * @apiName CreateGroupTasks + * @apiGroup Task + * @apiIgnore + * + * @apiParam {UUID} groupId The id of the group the new task(s) will belong to + * + * @apiSuccess data An object if a single task was created, otherwise an array of tasks + */ +api.createGroupTasks = { + method: 'POST', + url: '/tasks/group/:groupId', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; + + let user = res.locals.user; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: requiredGroupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + + let tasks = await createTasks(req, res, {user, group}); + + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); + }, +}; + +/** + * @api {get} /api/v3/tasks/group/:groupId Get a group's tasks + * @apiVersion 3.0.0 + * @apiName GetGroupTasks + * @apiGroup Task + * @apiIgnore + * + * @apiParam {UUID} groupId The id of the group from which to retrieve the tasks + * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks + * + * @apiSuccess {Array} data An array of tasks + */ +api.getGroupTasks = { + method: 'GET', + url: '/tasks/group/:groupId', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: requiredGroupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let tasks = await getTasks(req, res, {user, group}); + res.respond(200, tasks); + }, +}; + +/** + * @api {post} /api/v3/tasks/:taskId/assign/:assignedUserId Assign a group task to a user + * @apiDescription Assigns a user to a group task + * @apiVersion 3.0.0 + * @apiName AssignTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The id of the task that will be assigned + * @apiParam {UUID} userId The id of the user that will be assigned to the task + * + * @apiSuccess data An object if a single task was created, otherwise an array of tasks + */ +api.assignTask = { + method: 'POST', + url: '/tasks/:taskId/assign/:assignedUserId', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID(); + + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; + + let user = res.locals.user; + let assignedUserId = req.params.assignedUserId; + let assignedUser = await User.findById(assignedUserId); + + let taskId = req.params.taskId; + let task = await Tasks.Task.findByIdOrAlias(taskId, user._id); + + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } + + if (!task.group.id) { + throw new NotAuthorized(res.t('onlyGroupTasksCanBeAssigned')); + } + + let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + + await group.syncTask(task, assignedUser); + + res.respond(200, task); + }, +}; + +/** + * @api {post} /api/v3/tasks/:taskId/unassign/:assignedUserId Unassign a user from a task + * @apiDescription Unassigns a user to from a group task + * @apiVersion 3.0.0 + * @apiName UnassignTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The id of the task that will be assigned + * @apiParam {UUID} userId The id of the user that will be assigned to the task + * + * @apiSuccess data An object if a single task was created, otherwise an array of tasks + */ +api.unassignTask = { + method: 'POST', + url: '/tasks/:taskId/unassign/:assignedUserId', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID(); + + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; + + let user = res.locals.user; + let assignedUserId = req.params.assignedUserId; + let assignedUser = await User.findById(assignedUserId); + + let taskId = req.params.taskId; + let task = await Tasks.Task.findByIdOrAlias(taskId, user._id); + + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } + + if (!task.group.id) { + throw new NotAuthorized(res.t('onlyGroupTasksCanBeAssigned')); + } + + let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + + await group.unlinkTask(task, assignedUser); + + res.respond(200, task); + }, +}; + +module.exports = api; diff --git a/website/server/libs/taskManager.js b/website/server/libs/taskManager.js new file mode 100644 index 0000000000..1ba5ef587c --- /dev/null +++ b/website/server/libs/taskManager.js @@ -0,0 +1,177 @@ +import * as Tasks from '../models/task'; +import { + BadRequest, +} from './errors'; +import Bluebird from 'bluebird'; +import _ from 'lodash'; + +async function _validateTaskAlias (tasks, res) { + let tasksWithAliases = tasks.filter(task => task.alias); + let aliases = tasksWithAliases.map(task => task.alias); + + // Compares the short names in tasks against + // a Set, where values cannot repeat. If the + // lengths are different, some name was duplicated + if (aliases.length !== [...new Set(aliases)].length) { + throw new BadRequest(res.t('taskAliasAlreadyUsed')); + } + + await Bluebird.map(tasksWithAliases, (task) => { + return task.validate(); + }); +} + + +/** + * Creates tasks for a user, challenge or group. + * + * @param req The Express req variable + * @param res The Express res variable + * @param options + * @param options.user The user that these tasks belong to + * @param options.challenge The challenge that these tasks belong to + * @param options.group The group that these tasks belong to + * @return The created tasks + */ +export async function createTasks (req, res, options = {}) { + let { + user, + challenge, + group, + } = options; + + let owner = group || challenge || user; + + 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.sanitize(taskData)); + + if (challenge) { + newTask.challenge.id = challenge.id; + } else if (group) { + newTask.group.id = group._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/group and task in parallel it could save the user/challenge/group with a tasksOrder that doens't match reality + let validationErrors = newTask.validateSync(); + if (validationErrors) throw validationErrors; + + // Otherwise update the user/challenge/group + owner.tasksOrder[`${taskType}s`].unshift(newTask._id); + + return newTask; + }); + + // tasks with aliases need to be validated asyncronously + await _validateTaskAlias(toSave, res); + + toSave = toSave.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(owner.save()); + + let tasks = await Bluebird.all(toSave); + tasks.splice(0, 1); // Remove user, challenge, or group promise + return tasks; +} + +/** + * Gets tasks for a user, challenge or group. + * + * @param req The Express req variable + * @param res The Express res variable + * @param options + * @param options.user The user that these tasks belong to + * @param options.challenge The challenge that these tasks belong to + * @param options.group The group that these tasks belong to + * @return The tasks found + */ +export async function getTasks (req, res, options = {}) { + let { + user, + challenge, + group, + } = options; + + let query = {userId: user._id}; + let owner = group || challenge || user; + + if (challenge) { + query = {'challenge.id': challenge.id, userId: {$exists: false}}; + } else if (group) { + query = {'group.id': group._id, userId: {$exists: false}}; + } + + let type = req.query.type; + + if (type) { + if (type === 'todos') { + query.completed = false; // Exclude completed todos + query.type = 'todo'; + } else if (type === 'completedTodos' || type === '_allCompletedTodos') { // _allCompletedTodos is currently in BETA and is likely to be removed in future + let limit = 30; + + if (type === '_allCompletedTodos') { + limit = 0; // no limit + } + query = Tasks.Task.find({ + userId: user._id, + type: 'todo', + completed: true, + }).limit(limit).sort({ + dateCompleted: -1, + }); + } else { + query.type = type.slice(0, -1); // removing the final "s" + } + } else { + query.$or = [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ]; + } + + let tasks = await Tasks.Task.find(query).exec(); + + // Order tasks based on tasksOrder + if (type && type !== 'completedTodos' && type !== '_allCompletedTodos') { + let order = owner.tasksOrder[type]; + let orderedTasks = new Array(tasks.length); + let unorderedTasks = []; // what we want to add later + + tasks.forEach((task, index) => { + let taskId = task._id; + let i = order[index] === taskId ? index : order.indexOf(taskId); + if (i === -1) { + unorderedTasks.push(task); + } else { + orderedTasks[i] = task; + } + }); + + // Remove empty values from the array and add any unordered task + orderedTasks = _.compact(orderedTasks).concat(unorderedTasks); + return orderedTasks; + } else { + return tasks; + } +} + +// Takes a Task document and return a plain object of attributes that can be synced to the user + +export function syncableAttrs (task) { + let t = task.toObject(); // lodash doesn't seem to like _.omit on Document + // only sync/compare important attrs + let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt', 'group', 'checklist']; + if (t.type !== 'reward') omitAttrs.push('value'); + return _.omit(t, omitAttrs); +} diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js index bcf8984b95..9c444389e9 100644 --- a/website/server/models/challenge.js +++ b/website/server/models/challenge.js @@ -14,6 +14,7 @@ import shared from '../../../common'; import { sendTxn as txnEmail } from '../libs/email'; import sendPushNotification from '../libs/pushNotifications'; import cwait from 'cwait'; +import { syncableAttrs } from '../libs/taskManager'; const Schema = mongoose.Schema; @@ -71,15 +72,6 @@ schema.methods.canView = function canViewChallenge (user, group) { return this.hasAccess(user, group); }; -// 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 Document - // only sync/compare important attrs - let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt', 'checklist']; - if (t.type !== 'reward') omitAttrs.push('value'); - return _.omit(t, omitAttrs); -} - // 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) { @@ -125,12 +117,12 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); if (!matchingTask) { // If the task is new, create it - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitize(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)); + _.merge(matchingTask, syncableAttrs(chalTask)); // Make sure the task is in user.tasksOrder let orderList = user.tasksOrder[`${chalTask.type}s`]; if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); @@ -162,7 +154,7 @@ async function _addTaskFn (challenge, tasks, memberId) { let toSave = []; tasks.forEach(chalTask => { - let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); + let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(syncableAttrs(chalTask))); userTask.challenge = {taskId: chalTask._id, id: challenge._id}; userTask.userId = memberId; @@ -204,9 +196,9 @@ schema.methods.updateTask = async function challengeUpdateTask (task) { let updateCmd = {$set: {}}; - let syncableAttrs = _syncableAttrs(task); - for (let key in syncableAttrs) { - updateCmd.$set[key] = syncableAttrs[key]; + let syncableTask = syncableAttrs(task); + for (let key in syncableTask) { + updateCmd.$set[key] = syncableTask[key]; } let taskSchema = Tasks[task.type]; diff --git a/website/server/models/group.js b/website/server/models/group.js index f009d5e3ab..68393172ad 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -6,6 +6,7 @@ import { import shared from '../../../common'; import _ from 'lodash'; import { model as Challenge} from './challenge'; +import * as Tasks from './task'; import validator from 'validator'; import { removeFromArray } from '../libs/collectionManipulators'; import { @@ -18,6 +19,9 @@ import Bluebird from 'bluebird'; import nconf from 'nconf'; import sendPushNotification from '../libs/pushNotifications'; import pusher from '../libs/pusher'; +import { + syncableAttrs, +} from '../libs/taskManager'; const questScrolls = shared.content.quests; const Schema = mongoose.Schema; @@ -79,13 +83,19 @@ export let schema = new Schema({ return {}; }}, }, + tasksOrder: { + habits: [{type: String, ref: 'Task'}], + dailys: [{type: String, ref: 'Task'}], + todos: [{type: String, ref: 'Task'}], + rewards: [{type: String, ref: 'Task'}], + }, }, { strict: true, minimize: false, // So empty objects are returned }); schema.plugin(baseModel, { - noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'], + noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount', 'tasksOrder'], }); // A list of additional fields that cannot be updated (but can be set on creation) @@ -760,6 +770,117 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { return await Bluebird.all(promises); }; +schema.methods.updateTask = async function updateTask (taskToSync) { + let group = this; + + let updateCmd = {$set: {}}; + + let syncableAttributes = syncableAttrs(taskToSync); + for (let key in syncableAttributes) { + updateCmd.$set[key] = syncableAttributes[key]; + } + + let taskSchema = Tasks[taskToSync.type]; + // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks + await taskSchema.update({ + userId: {$exists: true}, + 'group.id': group.id, + 'group.taskId': taskToSync._id, + }, updateCmd, {multi: true}).exec(); +}; + +schema.methods.syncTask = async function groupSyncTask (taskToSync, user) { + let group = this; + let toSave = []; + + if (taskToSync.group.assignedUsers.indexOf(user._id) === -1) { + taskToSync.group.assignedUsers.push(user._id); + } + + // Sync tags + let userTags = user.tags; + let i = _.findIndex(userTags, {id: group._id}); + + if (i !== -1) { + if (userTags[i].name !== group.name) { + // update the name - it's been changed since + userTags[i].name = group.name; + } + } else { + userTags.push({ + id: group._id, + name: group.name, + }); + } + + let findQuery = { + 'group.taskId': taskToSync._id, + userId: user._id, + 'group.id': group._id, + }; + + let matchingTask = await Tasks.Task.findOne(findQuery).exec(); + + if (!matchingTask) { // If the task is new, create it + matchingTask = new Tasks[taskToSync.type](Tasks.Task.sanitize(syncableAttrs(taskToSync))); + matchingTask.group.id = taskToSync.group.id; + matchingTask.userId = user._id; + matchingTask.group.taskId = taskToSync._id; + user.tasksOrder[`${taskToSync.type}s`].push(matchingTask._id); + } else { + _.merge(matchingTask, syncableAttrs(taskToSync)); + // Make sure the task is in user.tasksOrder + let orderList = user.tasksOrder[`${taskToSync.type}s`]; + if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); + } + + if (!matchingTask.notes) matchingTask.notes = taskToSync.notes; // don't override the notes, but provide it if not provided + if (matchingTask.tags.indexOf(group._id) === -1) matchingTask.tags.push(group._id); // add tag if missing + + toSave.push(matchingTask.save(), taskToSync.save(), user.save()); + return Bluebird.all(toSave); +}; + +schema.methods.unlinkTask = async function groupUnlinkTask (unlinkingTask, user, keep) { + let findQuery = { + 'group.taskId': unlinkingTask._id, + userId: user._id, + }; + + let assignedUserIndex = unlinkingTask.group.assignedUsers.indexOf(user._id); + unlinkingTask.group.assignedUsers.splice(assignedUserIndex, 1); + + if (keep === 'keep-all') { + await Tasks.Task.update(findQuery, { + $set: {group: {}}, + }).exec(); + + await user.save(); + } else { // keep = 'remove-all' + let task = await Tasks.Task.findOne(findQuery).select('_id type completed').exec(); + // Remove task from user.tasksOrder and delete them + if (task.type !== 'todo' || !task.completed) { + removeFromArray(user.tasksOrder[`${task.type}s`], task._id); + user.markModified('tasksOrder'); + } + + return Bluebird.all([task.remove(), user.save(), unlinkingTask.save()]); + } +}; + +schema.methods.removeTask = async function groupRemoveTask (task) { + let group = this; + + // Set the task as broken + await Tasks.Task.update({ + userId: {$exists: true}, + 'group.id': group.id, + 'group.taskId': task._id, + }, { + $set: {'group.broken': 'TASK_DELETED'}, + }, {multi: true}).exec(); +}; + export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) diff --git a/website/server/models/task.js b/website/server/models/task.js index a35e4b6c42..4a7539a5b4 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -64,6 +64,13 @@ export let TaskSchema = new Schema({ winner: String, // user.profile.name of the winner }, + group: { + id: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}, + broken: {type: String, enum: ['GROUP_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED']}, + assignedUsers: [{type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}], + taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, + }, + reminders: [{ _id: false, id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true}, @@ -76,7 +83,7 @@ export let TaskSchema = new Schema({ }, discriminatorOptions)); TaskSchema.plugin(baseModel, { - noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId'], + noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId', 'group'], sanitizeTransform (taskObj) { if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards delete taskObj.value;