diff --git a/test/api/unit/libs/taskManager.js b/test/api/unit/libs/taskManager.js index 6a8ebe5949..dc795822fc 100644 --- a/test/api/unit/libs/taskManager.js +++ b/test/api/unit/libs/taskManager.js @@ -1,9 +1,11 @@ import { createTasks, getTasks, +} from '../../../../website/server/libs/tasks'; +import { syncableAttrs, moveTask, -} from '../../../../website/server/libs/taskManager'; +} from '../../../../website/server/libs/tasks/utils'; import i18n from '../../../../website/common/script/i18n'; import shared from '../../../../website/common/script'; import { 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 e64923e14d..7700c5ea56 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -28,7 +28,6 @@ describe('DELETE /tasks/:id', () => { it('deletes a user\'s 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', diff --git a/test/api/v3/integration/tasks/POST-tasks_unlink-all_challengeId.test.js b/test/api/v3/integration/tasks/POST-tasks_unlink-all_challengeId.test.js index 86715bd6dc..638285945a 100644 --- a/test/api/v3/integration/tasks/POST-tasks_unlink-all_challengeId.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_unlink-all_challengeId.test.js @@ -97,7 +97,7 @@ describe('POST /tasks/unlink-all/:challengeId', () => { await user.del(`/challenges/${challenge._id}`); await user.post(`/tasks/unlink-all/${challenge._id}?keep=keep-all`); // Get the task for the second user - const [, anotherUserTask] = await anotherUser.get('/tasks/user'); + const [anotherUserTask] = await anotherUser.get('/tasks/user'); // Expect the second user to still have the task, but unlinked expect(anotherUserTask.challenge).to.eql({ taskId: daily._id, diff --git a/test/api/v3/integration/tasks/POST-tasks_unlink-one_taskId.test.js b/test/api/v3/integration/tasks/POST-tasks_unlink-one_taskId.test.js index e8ee1f2727..3339e064ad 100644 --- a/test/api/v3/integration/tasks/POST-tasks_unlink-one_taskId.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_unlink-one_taskId.test.js @@ -92,16 +92,16 @@ describe('POST /tasks/unlink-one/:taskId', () => { it('unlinks a task from a challenge and saves it on keep=keep', async () => { await user.post(`/tasks/challenge/${challenge._id}`, tasksToTest.daily); - let [, daily] = await user.get('/tasks/user'); + let [daily] = await user.get('/tasks/user'); await user.del(`/challenges/${challenge._id}`); await user.post(`/tasks/unlink-one/${daily._id}?keep=keep`); - [, daily] = await user.get('/tasks/user'); + [daily] = await user.get('/tasks/user'); expect(daily.challenge).to.eql({}); }); it('unlinks a task from a challenge and deletes it on keep=remove', async () => { await user.post(`/tasks/challenge/${challenge._id}`, tasksToTest.daily); - const [, daily] = await user.get('/tasks/user'); + const [daily] = await user.get('/tasks/user'); await user.del(`/challenges/${challenge._id}`); await user.post(`/tasks/unlink-one/${daily._id}?keep=remove`); const tasks = await user.get('/tasks/user'); diff --git a/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js b/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js index 9765ec05a3..66bd555c3d 100644 --- a/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js +++ b/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js @@ -21,10 +21,6 @@ describe('GET /tasks/challenge/:challengeId', () => { up: false, down: true, }, - todo: { - text: 'test todo', - type: 'todo', - }, daily: { text: 'test daily', type: 'daily', @@ -32,6 +28,10 @@ describe('GET /tasks/challenge/:challengeId', () => { everyX: 5, startDate: new Date(), }, + todo: { + text: 'test todo', + type: 'todo', + }, reward: { text: 'test reward', type: 'reward', @@ -84,4 +84,35 @@ describe('GET /tasks/challenge/:challengeId', () => { }); }); }); + + it('maintains challenge task order', async () => { + const orderedTasks = {}; + Object.entries(tasksToTest).forEach(async (taskType, taskValue) => { + const results = []; + for (let i = 0; i < 5; i += 1) { + results.push(user.post(`/tasks/challenge/${challenge._id}`, taskValue)); + } + const taskList = await Promise.all(results); + await user.post(`/tasks/${taskList[0]._id}/move/to/3`); + + const firstTask = taskList.unshift(); + taskList.splice(3, 0, firstTask); + + orderedTasks[taskType] = taskList; + }); + + const results = await user.get(`/tasks/challenge/${challenge._id}`); + const resultTasks = {}; + + results.forEach(result => { + if (!resultTasks[result.type]) { + resultTasks[result.type] = []; + } + resultTasks[result.type].push(result); + }); + + Object.entries(orderedTasks).forEach((taskType, taskList) => { + expect(resultTasks[taskType]).to.eql(taskList); + }); + }); }); 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 index 87988bb78a..277d43a9b2 100644 --- 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 @@ -17,10 +17,6 @@ describe('GET /tasks/group/:groupId', () => { up: false, down: true, }, - todo: { - text: 'test todo', - type: 'todo', - }, daily: { text: 'test daily', type: 'daily', @@ -28,6 +24,10 @@ describe('GET /tasks/group/:groupId', () => { everyX: 5, startDate: new Date(), }, + todo: { + text: 'test todo', + type: 'todo', + }, reward: { text: 'test reward', type: 'reward', @@ -78,4 +78,35 @@ describe('GET /tasks/group/:groupId', () => { }); }); }); + + it('maintains group task order', async () => { + const orderedTasks = {}; + Object.entries(tasksToTest).forEach(async (taskType, taskValue) => { + const results = []; + for (let i = 0; i < 5; i += 1) { + results.push(user.post(`/tasks/group/${group._id}`, taskValue)); + } + const taskList = await Promise.all(results); + await user.post(`/tasks/${taskList[0]._id}/move/to/3`); + + const firstTask = taskList.unshift(); + taskList.splice(3, 0, firstTask); + + orderedTasks[taskType] = taskList; + }); + + const results = await user.get(`/tasks/group/${group._id}`); + const resultTasks = {}; + + results.forEach(result => { + if (!resultTasks[result.type]) { + resultTasks[result.type] = []; + } + resultTasks[result.type].push(result); + }); + + Object.entries(orderedTasks).forEach((taskType, taskList) => { + expect(resultTasks[taskType]).to.eql(taskList); + }); + }); }); diff --git a/website/client/src/components/challenges/challengeDetail.vue b/website/client/src/components/challenges/challengeDetail.vue index 2bc32738f4..470da2d075 100644 --- a/website/client/src/components/challenges/challengeDetail.vue +++ b/website/client/src/components/challenges/challengeDetail.vue @@ -133,6 +133,7 @@ :type="column" :task-list-override="tasksByType[column]" :challenge="challenge" + :draggable-override="isLeader || isAdmin" @editTask="editTask" @taskDestroyed="taskDestroyed" /> @@ -512,7 +513,7 @@ export default { this.creatingTask = null; }, taskCreated (task) { - this.tasksByType[task.type].push(task); + this.tasksByType[task.type].unshift(task); }, taskEdited (task) { const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id); diff --git a/website/client/src/components/group-plans/taskInformation.vue b/website/client/src/components/group-plans/taskInformation.vue index 7bc9c4a5bf..3395bd3d27 100644 --- a/website/client/src/components/group-plans/taskInformation.vue +++ b/website/client/src/components/group-plans/taskInformation.vue @@ -77,6 +77,7 @@ :task-list-override="tasksByType[column]" :group="group" :search-text="searchText" + :draggable-override="canCreateTasks" @editTask="editTask" @loadGroupCompletedTodos="loadGroupCompletedTodos" @taskDestroyed="taskDestroyed" @@ -295,7 +296,7 @@ export default { }, taskCreated (task) { task.group.id = this.group._id; - this.tasksByType[task.type].push(task); + this.tasksByType[task.type].unshift(task); }, taskEdited (task) { const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id); diff --git a/website/client/src/components/tasks/column.vue b/website/client/src/components/tasks/column.vue index 5856f258a0..5740633ba5 100644 --- a/website/client/src/components/tasks/column.vue +++ b/website/client/src/components/tasks/column.vue @@ -86,7 +86,8 @@ v-if="taskList.length > 0" ref="tasksList" class="sortable-tasks" - :options="{disabled: activeFilter.label === 'scheduled' || !isUser, scrollSensitivity: 64}" + :options="{disabled: activeFilter.label === 'scheduled' || !canBeDragged(), + scrollSensitivity: 64}" :delay-on-touch-only="true" :delay="100" @update="taskSorted" @@ -391,6 +392,10 @@ export default { type: Boolean, default: false, }, + draggableOverride: { + type: Boolean, + default: false, + }, searchText: {}, selectedTags: {}, taskListOverride: {}, @@ -767,6 +772,10 @@ export default { taskDestroyed (task) { this.$emit('taskDestroyed', task); }, + canBeDragged () { + return this.isUser + || this.draggableOverride; + }, }, }; diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js index e9b60349b7..51b5bb1229 100644 --- a/website/server/controllers/api-v3/challenges.js +++ b/website/server/controllers/api-v3/challenges.js @@ -19,7 +19,7 @@ import * as Tasks from '../../models/task'; import csvStringify from '../../libs/csvStringify'; import { createTasks, -} from '../../libs/taskManager'; +} from '../../libs/tasks'; import { addUserJoinChallengeNotification, diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index a42f611323..217771e8eb 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -16,35 +16,19 @@ import { import { createTasks, getTasks, + getGroupFromTaskAndUser, + getChallengeFromTask, + scoreTasks, + verifyTaskModification, +} from '../../libs/tasks'; +import { moveTask, setNextDue, - scoreTasks, -} from '../../libs/taskManager'; + requiredGroupFields, +} from '../../libs/tasks/utils'; import common from '../../../common'; import apiError from '../../libs/apiError'; -// @TODO abstract, see api-v3/tasks/groups.js -function canNotEditTasks (group, user, assignedUserId, taskPayload = null) { - const isNotGroupLeader = group.leader !== user._id; - const isManager = Boolean(group.managers[user._id]); - const userIsAssigningToSelf = Boolean(assignedUserId && user._id === assignedUserId); - - const taskPayloadProps = taskPayload - ? Object.keys(taskPayload) - : []; - - // only allow collapseChecklist to be changed by everyone - const allowedByTaskPayload = taskPayloadProps.length === 1 - && taskPayloadProps.includes('collapseChecklist'); - - if (allowedByTaskPayload) { - return false; - } - - return isNotGroupLeader && !isManager - && !userIsAssigningToSelf; -} - /** * @apiDefine TaskNotFound * @apiError (404) {NotFound} TaskNotFound The specified task could not be found. @@ -61,7 +45,6 @@ function canNotEditTasks (group, user, assignedUserId, taskPayload = null) { */ const api = {}; -const requiredGroupFields = '_id leader tasksOrder name'; /** * @api {post} /api/v3/tasks/user Create a new task belonging to the user @@ -613,7 +596,6 @@ api.updateTask = { middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; - let challenge; req.checkParams('taskId', apiError('taskIdRequired')).notEmpty(); @@ -622,26 +604,30 @@ api.updateTask = { const { taskId } = req.params; const task = await Tasks.Task.findByIdOrAlias(taskId, user._id); - let group; + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } + const group = await getGroupFromTaskAndUser(task, user); + const challenge = await getChallengeFromTask(task); + // Verify that the user can modify the task. if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (task.group.id && !task.userId) { - // @TODO: Abstract this access snippet - const fields = requiredGroupFields.concat(' managers'); - group = await Group.getGroup({ user, groupId: task.group.id, fields }); + // If the task is in a group and only modifying `collapseChecklist`, + // the modification should be allowed. if (!group) throw new NotFound(res.t('groupNotFound')); - if (canNotEditTasks(group, user, null, req.body)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + const taskPayloadProps = Object.keys(req.body); - // If the task belongs to a challenge make sure the user has rights - } else if (task.challenge.id && !task.userId) { - challenge = await Challenge.findOne({ _id: task.challenge.id }).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + const allowedByTaskPayload = taskPayloadProps.length === 1 + && taskPayloadProps.includes('collapseChecklist'); - // If the task is owned by a user make it's the current one - } else if (task.userId !== user._id) { - throw new NotFound(res.t('taskNotFound')); + if (!allowedByTaskPayload) { + // Otherwise, verify the task modification normally. + verifyTaskModification(task, user, group, challenge, res); + } + } else { + verifyTaskModification(task, user, group, challenge, res); } const oldCheckList = task.checklist; @@ -832,20 +818,29 @@ api.moveTask = { const { taskId } = req.params; const to = Number(req.params.position); - const task = await Tasks.Task.findByIdOrAlias(taskId, user._id, { userId: user._id }); + const task = await Tasks.Task.findByIdOrAlias(taskId, user._id); + + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } + + const group = await getGroupFromTaskAndUser(task, user); + const challenge = await getChallengeFromTask(task); + verifyTaskModification(task, user, group, challenge, res); - if (!task) throw new NotFound(res.t('taskNotFound')); if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo')); + const owner = group || challenge || user; + // In memory updates - const order = user.tasksOrder[`${task.type}s`]; + const order = owner.tasksOrder[`${task.type}s`]; moveTask(order, task._id, to); // Server updates // Cannot send $pull and $push on same field in one single op const pullQuery = { $pull: {} }; pullQuery.$pull[`tasksOrder.${task.type}s`] = task.id; - await user.update(pullQuery).exec(); + await owner.update(pullQuery).exec(); let position = to; if (to === -1) position = order.length - 1; // push to bottom @@ -855,12 +850,15 @@ api.moveTask = { $each: [task._id], $position: position, }; - await user.update(updateQuery).exec(); + await owner.update(updateQuery).exec(); // Update the user version field manually, // it cannot be updated in the pre update hook // See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info - user._v += 1; + // Only users have a version. + if (!group && !challenge) { + owner._v += 1; + } res.respond(200, order); }, @@ -900,8 +898,6 @@ api.addChecklistItem = { middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; - let challenge; - let group; req.checkParams('taskId', apiError('taskIdRequired')).notEmpty(); @@ -913,22 +909,12 @@ api.addChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); - } else if (task.group.id && !task.userId) { - const fields = requiredGroupFields.concat(' managers'); - group = await Group.getGroup({ user, groupId: task.group.id, fields }); - if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); - - // If the task belongs to a challenge make sure the user has rights - } else if (task.challenge.id && !task.userId) { - challenge = await Challenge.findOne({ _id: task.challenge.id }).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - - // If the task is owned by a user make it's the current one - } else if (task.userId !== user._id) { - throw new NotFound(res.t('taskNotFound')); } + const group = await getGroupFromTaskAndUser(task, user); + const challenge = await getChallengeFromTask(task); + verifyTaskModification(task, user, group, challenge, res); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); const newCheckListItem = Tasks.Task.sanitizeChecklist(req.body); @@ -1018,8 +1004,6 @@ api.updateChecklistItem = { middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; - let challenge; - let group; req.checkParams('taskId', apiError('taskIdRequired')).notEmpty(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -1032,22 +1016,11 @@ api.updateChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); - } else if (task.group.id && !task.userId) { - const fields = requiredGroupFields.concat(' managers'); - group = await Group.getGroup({ user, groupId: task.group.id, fields }); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); - - // If the task belongs to a challenge make sure the user has rights - } else if (task.challenge.id && !task.userId) { - challenge = await Challenge.findOne({ _id: task.challenge.id }).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - - // If the task is owned by a user make it's the current one - } else if (task.userId !== user._id) { - throw new NotFound(res.t('taskNotFound')); } + + const group = await getGroupFromTaskAndUser(task, user); + const challenge = await getChallengeFromTask(task); + verifyTaskModification(task, user, group, challenge, res); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); const item = _.find(task.checklist, { id: req.params.itemId }); @@ -1095,8 +1068,6 @@ api.removeChecklistItem = { middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; - let challenge; - let group; req.checkParams('taskId', apiError('taskIdRequired')).notEmpty(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -1109,22 +1080,11 @@ api.removeChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); - } else if (task.group.id && !task.userId) { - const fields = requiredGroupFields.concat(' managers'); - group = await Group.getGroup({ user, groupId: task.group.id, fields }); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); - - // If the task belongs to a challenge make sure the user has rights - } else if (task.challenge.id && !task.userId) { - challenge = await Challenge.findOne({ _id: task.challenge.id }).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - - // If the task is owned by a user make it's the current one - } else if (task.userId !== user._id) { - throw new NotFound(res.t('taskNotFound')); } + + const group = await getGroupFromTaskAndUser(task, user); + const challenge = await getChallengeFromTask(task); + verifyTaskModification(task, user, group, challenge, res); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); const hasItem = removeFromArray(task.checklist, { id: req.params.itemId }); @@ -1447,30 +1407,19 @@ api.deleteTask = { middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; - let challenge; const { taskId } = req.params; const task = await Tasks.Task.findByIdOrAlias(taskId, user._id); if (!task) { throw new NotFound(res.t('taskNotFound')); - } else if (task.group.id && !task.userId) { - // @TODO: Abstract this access snippet - const fields = requiredGroupFields.concat(' managers'); - const group = await Group.getGroup({ user, groupId: task.group.id, fields }); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + } + const group = await getGroupFromTaskAndUser(task, user); + const challenge = await getChallengeFromTask(task); + verifyTaskModification(task, user, group, challenge, res); + + if (task.group.id && !task.userId) { await group.removeTask(task); - - // If the task belongs to a challenge make sure the user has rights - } else if (task.challenge.id && !task.userId) { - challenge = await Challenge.findOne({ _id: task.challenge.id }).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - - // If the task is owned by a user make it's the current one - } else if (task.userId !== user._id) { - throw new NotFound(res.t('taskNotFound')); } else if (task.userId && task.challenge.id && !task.challenge.broken) { throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); } else if ( diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js index 06385c4524..9b69d63fef 100644 --- a/website/server/controllers/api-v3/tasks/groups.js +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -8,10 +8,13 @@ import { NotAuthorized, } from '../../../libs/errors'; import { + canNotEditTasks, createTasks, getTasks, +} from '../../../libs/tasks'; +import { moveTask, -} from '../../../libs/taskManager'; +} from '../../../libs/tasks/utils'; import { handleSharedCompletion } from '../../../libs/groupTasks'; import apiError from '../../../libs/apiError'; import logger from '../../../libs/logger'; @@ -22,14 +25,6 @@ const types = Tasks.tasksTypes.map(type => `${type}s`); // _allCompletedTodos is currently in BETA and is likely to be removed in future types.push('completedTodos', '_allCompletedTodos'); -// @TODO abstract this snipped (also see api-v3/tasks.js) -function canNotEditTasks (group, user, assignedUserId) { - const isNotGroupLeader = group.leader !== user._id; - const isManager = Boolean(group.managers[user._id]); - const userIsAssigningToSelf = Boolean(assignedUserId && user._id === assignedUserId); - return isNotGroupLeader && !isManager && !userIsAssigningToSelf; -} - const api = {}; /** diff --git a/website/server/controllers/api-v4/tasks.js b/website/server/controllers/api-v4/tasks.js index 4ba53f1206..57d406670c 100644 --- a/website/server/controllers/api-v4/tasks.js +++ b/website/server/controllers/api-v4/tasks.js @@ -1,6 +1,6 @@ import _ from 'lodash'; import { authWithHeaders } from '../../middlewares/auth'; -import { scoreTasks } from '../../libs/taskManager'; +import { scoreTasks } from '../../libs/tasks'; const api = {}; diff --git a/website/server/libs/taskManager.js b/website/server/libs/tasks/index.js similarity index 79% rename from website/server/libs/taskManager.js rename to website/server/libs/tasks/index.js index f2c1ec89a7..3214af421a 100644 --- a/website/server/libs/taskManager.js +++ b/website/server/libs/tasks/index.js @@ -1,73 +1,29 @@ import moment from 'moment'; import _ from 'lodash'; import validator from 'validator'; -import * as Tasks from '../models/task'; -import apiError from './apiError'; +import { + setNextDue, + validateTaskAlias, + requiredGroupFields, +} from './utils'; +import { model as Challenge } from '../../models/challenge'; +import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; +import * as Tasks from '../../models/task'; +import apiError from '../apiError'; import { BadRequest, - NotAuthorized, NotFound, -} from './errors'; + NotAuthorized, +} from '../errors'; import { SHARED_COMPLETION, handleSharedCompletion, -} from './groupTasks'; -import shared from '../../common'; -import { model as Group } from '../models/group'; // eslint-disable-line import/no-cycle -import { model as User } from '../models/user'; // eslint-disable-line import/no-cycle -import { taskScoredWebhook } from './webhook'; // eslint-disable-line import/no-cycle +} from '../groupTasks'; +import shared from '../../../common'; +import { taskScoredWebhook } from '../webhook'; -import logger from './logger'; - -const requiredGroupFields = '_id leader tasksOrder name'; - -async function _validateTaskAlias (tasks, res) { - const tasksWithAliases = tasks.filter(task => task.alias); - const 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 Promise.all(tasksWithAliases.map(task => task.validate())); -} - -export function setNextDue (task, user, dueDateOption) { - if (task.type !== 'daily') return; - - let now = moment().toDate(); - let dateTaskIsDue = Date.now(); - if (dueDateOption) { - // @TODO Add required ISO format - dateTaskIsDue = moment(dueDateOption); - - // If not time is supplied. Let's assume we want start of Custom Day Start day. - if ( - dateTaskIsDue.hour() === 0 - && dateTaskIsDue.minute() === 0 - && dateTaskIsDue.second() === 0 - && dateTaskIsDue.millisecond() === 0 - ) { - dateTaskIsDue.add(user.preferences.timezoneOffset, 'minutes'); - dateTaskIsDue.add(user.preferences.dayStart, 'hours'); - } - - now = dateTaskIsDue; - } - - const optionsForShouldDo = user.preferences.toObject(); - optionsForShouldDo.now = now; - task.isDue = shared.shouldDo(dateTaskIsDue, task, optionsForShouldDo); - - optionsForShouldDo.nextDue = true; - const nextDue = shared.shouldDo(dateTaskIsDue, task, optionsForShouldDo); - if (nextDue && nextDue.length > 0) { - task.nextDue = nextDue.map(dueDate => dueDate.toISOString()); - } -} +import logger from '../logger'; /** * Creates tasks for a user, challenge or group. @@ -81,7 +37,7 @@ export function setNextDue (task, user, dueDateOption) { * @param options.requiresApproval A boolean stating if the task will require approval * @return The created tasks */ -export async function createTasks (req, res, options = {}) { +async function createTasks (req, res, options = {}) { const { user, challenge, @@ -161,7 +117,7 @@ export async function createTasks (req, res, options = {}) { await owner.update(taskOrderUpdateQuery).exec(); // tasks with aliases need to be validated asynchronously - await _validateTaskAlias(toSave, res); + await validateTaskAlias(toSave, res); // If all tasks are valid (this is why it's not in the previous .map()), // save everything, withough running validation again @@ -188,7 +144,7 @@ export async function createTasks (req, res, options = {}) { * @param options.dueDate The date to use for computing the nextDue field for each returned task * @return The tasks found */ -export async function getTasks (req, res, options = {}) { +async function getTasks (req, res, options = {}) { const { user, challenge, @@ -253,63 +209,72 @@ export async function getTasks (req, res, options = {}) { } // Order tasks based on tasksOrder + let order = []; if (type && type !== 'completedTodos' && type !== '_allCompletedTodos') { - const order = owner.tasksOrder[type]; - let orderedTasks = new Array(tasks.length); - const unorderedTasks = []; // what we want to add later - - tasks.forEach((task, index) => { - const taskId = task._id; - const i = order[index] === taskId ? index : order.indexOf(taskId); - if (i === -1) { - unorderedTasks.push(task); - } else { - orderedTasks[i] = task; - } + order = owner.tasksOrder[type]; + } else if (!type) { + Object.values(owner.tasksOrder).forEach(taskOrder => { + order = order.concat(taskOrder); }); - - // Remove empty values from the array and add any unordered task - orderedTasks = _.compact(orderedTasks).concat(unorderedTasks); - return orderedTasks; - } - return tasks; -} - -// Takes a Task document and return a plain object of attributes that can be synced to the user -export function syncableAttrs (task) { - const t = task.toObject(); // lodash doesn't seem to like _.omit on Document - // only sync/compare important attrs - const omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt', 'createdAt', 'group', 'checklist', 'attribute']; - if (t.type !== 'reward') omitAttrs.push('value'); - return _.omit(t, omitAttrs); -} - -/** - * Moves a task to a specified position. - * - * @param order The list of ordered tasks - * @param taskId The Task._id of the task to move - * @param to A integer specifying the index to move the task to - * - * @return Empty - */ -export function moveTask (order, taskId, to) { - const currentIndex = order.indexOf(taskId); - - // If for some reason the task isn't ordered (should never happen), push it in the new position - // if the task is moved to a non existing position - // or if the task is moved to position -1 (push to bottom) - // -> push task at end of list - if (!order[to] && to !== -1) { - order.push(taskId); - return; - } - - if (currentIndex !== -1) order.splice(currentIndex, 1); - if (to === -1) { - order.push(taskId); } else { - order.splice(to, 0, taskId); + return tasks; + } + + let orderedTasks = new Array(tasks.length); + const unorderedTasks = []; // what we want to add later + + tasks.forEach((task, index) => { + const taskId = task._id; + const 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; +} + +function canNotEditTasks (group, user, assignedUserId) { + const isNotGroupLeader = group.leader !== user._id; + const isManager = Boolean(group.managers[user._id]); + const userIsAssigningToSelf = Boolean(assignedUserId && user._id === assignedUserId); + return isNotGroupLeader && !isManager && !userIsAssigningToSelf; +} + +async function getGroupFromTaskAndUser (task, user) { + if (task.group.id && !task.userId) { + const fields = requiredGroupFields.concat(' managers'); + return Group.getGroup({ user, groupId: task.group.id, fields }); + } + return null; +} + +async function getChallengeFromTask (task) { + if (task.challenge.id && !task.userId) { + return Challenge.findOne({ _id: task.challenge.id }).exec(); + } + return null; +} + +function verifyTaskModification (task, user, group, challenge, res) { + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (task.group.id && !task.userId) { + if (!group) throw new NotFound(res.t('groupNotFound')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + + // If the task belongs to a challenge make sure the user has rights + } else if (task.challenge.id && !task.userId) { + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + + // If the task is owned by a user make it's the current one + } else if (task.userId !== user._id) { + throw new NotFound(res.t('taskNotFound')); } } @@ -589,3 +554,13 @@ export async function scoreTasks (user, taskScorings, req, res) { return { id: data.task._id, delta: data.delta, _tmp: data._tmp }; }); } + +export { + createTasks, + getTasks, + scoreTask, + canNotEditTasks, + getGroupFromTaskAndUser, + getChallengeFromTask, + verifyTaskModification, +}; diff --git a/website/server/libs/tasks/utils.js b/website/server/libs/tasks/utils.js new file mode 100644 index 0000000000..5a0c465e6a --- /dev/null +++ b/website/server/libs/tasks/utils.js @@ -0,0 +1,94 @@ +import moment from 'moment'; +import _ from 'lodash'; +import { + BadRequest, +} from '../errors'; +import shared from '../../../common'; + +export const requiredGroupFields = '_id leader tasksOrder name'; + +export async function validateTaskAlias (tasks, res) { + const tasksWithAliases = tasks.filter(task => task.alias); + const 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 Promise.all(tasksWithAliases.map(task => task.validate())); +} + +// Takes a Task document and return a plain object of attributes that can be synced to the user +export function syncableAttrs (task) { + const t = task.toObject(); // lodash doesn't seem to like _.omit on Document + // only sync/compare important attrs + const omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt', 'createdAt', 'group', 'checklist', 'attribute']; + if (t.type !== 'reward') omitAttrs.push('value'); + return _.omit(t, omitAttrs); +} + +/** + * Moves a task to a specified position. + * + * @param order The list of ordered tasks + * @param taskId The Task._id of the task to move + * @param to A integer specifying the index to move the task to + * + * @return Empty + */ +export function moveTask (order, taskId, to) { + const currentIndex = order.indexOf(taskId); + + // If for some reason the task isn't ordered (should never happen), push it in the new position + // if the task is moved to a non existing position + // or if the task is moved to position -1 (push to bottom) + // -> push task at end of list + if (!order[to] && to !== -1) { + order.push(taskId); + return; + } + + if (currentIndex !== -1) order.splice(currentIndex, 1); + if (to === -1) { + order.push(taskId); + } else { + order.splice(to, 0, taskId); + } +} + +export function setNextDue (task, user, dueDateOption) { + if (task.type !== 'daily') return; + + let now = moment().toDate(); + let dateTaskIsDue = Date.now(); + if (dueDateOption) { + // @TODO Add required ISO format + dateTaskIsDue = moment(dueDateOption); + + // If not time is supplied. Let's assume we want start of Custom Day Start day. + if ( + dateTaskIsDue.hour() === 0 + && dateTaskIsDue.minute() === 0 + && dateTaskIsDue.second() === 0 + && dateTaskIsDue.millisecond() === 0 + ) { + dateTaskIsDue.add(user.preferences.timezoneOffset, 'minutes'); + dateTaskIsDue.add(user.preferences.dayStart, 'hours'); + } + + now = dateTaskIsDue; + } + + const optionsForShouldDo = user.preferences.toObject(); + optionsForShouldDo.now = now; + task.isDue = shared.shouldDo(dateTaskIsDue, task, optionsForShouldDo); + + optionsForShouldDo.nextDue = true; + const nextDue = shared.shouldDo(dateTaskIsDue, task, optionsForShouldDo); + if (nextDue && nextDue.length > 0) { + task.nextDue = nextDue.map(dueDate => dueDate.toISOString()); + } +} diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js index f391e472c3..b78f1343ff 100644 --- a/website/server/models/challenge.js +++ b/website/server/models/challenge.js @@ -12,10 +12,7 @@ import { removeFromArray } from '../libs/collectionManipulators'; import shared from '../../common'; import { sendTxn as txnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle -import { // eslint-disable-line import/no-cycle - syncableAttrs, - setNextDue, -} from '../libs/taskManager'; +import { syncableAttrs, setNextDue } from '../libs/tasks/utils'; const { Schema } = mongoose; diff --git a/website/server/models/group.js b/website/server/models/group.js index b4a4ec4ce3..c85252f675 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -31,7 +31,7 @@ import { sendTxn as sendTxnEmail } from '../libs/email'; // eslint-disable-line import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle import { // eslint-disable-line import/no-cycle syncableAttrs, -} from '../libs/taskManager'; +} from '../libs/tasks/utils'; import { schema as SubscriptionPlanSchema, } from './subscriptionPlan';