mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 21:57:22 +01:00
Drag challenge tasks (#12204)
* Allow challenge tasks to be draggable by leaders and admins * Drag challenge tasks, ensure they're ordered * Ensure group tasks are ordered properly, make draggable * Add tests, fix broken tests * Resolve merge conflict * Remove console.log() * Address code review comments * Code review fixes * Fix lint * Fix importing * taskManager * Lint * Fix collapseChecklist test Co-authored-by: Sabe Jones <sabrecat@gmail.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
createTasks,
|
createTasks,
|
||||||
getTasks,
|
getTasks,
|
||||||
|
} from '../../../../website/server/libs/tasks';
|
||||||
|
import {
|
||||||
syncableAttrs,
|
syncableAttrs,
|
||||||
moveTask,
|
moveTask,
|
||||||
} from '../../../../website/server/libs/taskManager';
|
} from '../../../../website/server/libs/tasks/utils';
|
||||||
import i18n from '../../../../website/common/script/i18n';
|
import i18n from '../../../../website/common/script/i18n';
|
||||||
import shared from '../../../../website/common/script';
|
import shared from '../../../../website/common/script';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ describe('DELETE /tasks/:id', () => {
|
|||||||
|
|
||||||
it('deletes a user\'s task', async () => {
|
it('deletes a user\'s task', async () => {
|
||||||
await user.del(`/tasks/${task._id}`);
|
await user.del(`/tasks/${task._id}`);
|
||||||
|
|
||||||
await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({
|
await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({
|
||||||
code: 404,
|
code: 404,
|
||||||
error: 'NotFound',
|
error: 'NotFound',
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe('POST /tasks/unlink-all/:challengeId', () => {
|
|||||||
await user.del(`/challenges/${challenge._id}`);
|
await user.del(`/challenges/${challenge._id}`);
|
||||||
await user.post(`/tasks/unlink-all/${challenge._id}?keep=keep-all`);
|
await user.post(`/tasks/unlink-all/${challenge._id}?keep=keep-all`);
|
||||||
// Get the task for the second user
|
// 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 the second user to still have the task, but unlinked
|
||||||
expect(anotherUserTask.challenge).to.eql({
|
expect(anotherUserTask.challenge).to.eql({
|
||||||
taskId: daily._id,
|
taskId: daily._id,
|
||||||
|
|||||||
@@ -92,16 +92,16 @@ describe('POST /tasks/unlink-one/:taskId', () => {
|
|||||||
|
|
||||||
it('unlinks a task from a challenge and saves it on keep=keep', async () => {
|
it('unlinks a task from a challenge and saves it on keep=keep', async () => {
|
||||||
await user.post(`/tasks/challenge/${challenge._id}`, tasksToTest.daily);
|
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.del(`/challenges/${challenge._id}`);
|
||||||
await user.post(`/tasks/unlink-one/${daily._id}?keep=keep`);
|
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({});
|
expect(daily.challenge).to.eql({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unlinks a task from a challenge and deletes it on keep=remove', async () => {
|
it('unlinks a task from a challenge and deletes it on keep=remove', async () => {
|
||||||
await user.post(`/tasks/challenge/${challenge._id}`, tasksToTest.daily);
|
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.del(`/challenges/${challenge._id}`);
|
||||||
await user.post(`/tasks/unlink-one/${daily._id}?keep=remove`);
|
await user.post(`/tasks/unlink-one/${daily._id}?keep=remove`);
|
||||||
const tasks = await user.get('/tasks/user');
|
const tasks = await user.get('/tasks/user');
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ describe('GET /tasks/challenge/:challengeId', () => {
|
|||||||
up: false,
|
up: false,
|
||||||
down: true,
|
down: true,
|
||||||
},
|
},
|
||||||
todo: {
|
|
||||||
text: 'test todo',
|
|
||||||
type: 'todo',
|
|
||||||
},
|
|
||||||
daily: {
|
daily: {
|
||||||
text: 'test daily',
|
text: 'test daily',
|
||||||
type: 'daily',
|
type: 'daily',
|
||||||
@@ -32,6 +28,10 @@ describe('GET /tasks/challenge/:challengeId', () => {
|
|||||||
everyX: 5,
|
everyX: 5,
|
||||||
startDate: new Date(),
|
startDate: new Date(),
|
||||||
},
|
},
|
||||||
|
todo: {
|
||||||
|
text: 'test todo',
|
||||||
|
type: 'todo',
|
||||||
|
},
|
||||||
reward: {
|
reward: {
|
||||||
text: 'test reward',
|
text: 'test reward',
|
||||||
type: '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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ describe('GET /tasks/group/:groupId', () => {
|
|||||||
up: false,
|
up: false,
|
||||||
down: true,
|
down: true,
|
||||||
},
|
},
|
||||||
todo: {
|
|
||||||
text: 'test todo',
|
|
||||||
type: 'todo',
|
|
||||||
},
|
|
||||||
daily: {
|
daily: {
|
||||||
text: 'test daily',
|
text: 'test daily',
|
||||||
type: 'daily',
|
type: 'daily',
|
||||||
@@ -28,6 +24,10 @@ describe('GET /tasks/group/:groupId', () => {
|
|||||||
everyX: 5,
|
everyX: 5,
|
||||||
startDate: new Date(),
|
startDate: new Date(),
|
||||||
},
|
},
|
||||||
|
todo: {
|
||||||
|
text: 'test todo',
|
||||||
|
type: 'todo',
|
||||||
|
},
|
||||||
reward: {
|
reward: {
|
||||||
text: 'test reward',
|
text: 'test reward',
|
||||||
type: '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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
:type="column"
|
:type="column"
|
||||||
:task-list-override="tasksByType[column]"
|
:task-list-override="tasksByType[column]"
|
||||||
:challenge="challenge"
|
:challenge="challenge"
|
||||||
|
:draggable-override="isLeader || isAdmin"
|
||||||
@editTask="editTask"
|
@editTask="editTask"
|
||||||
@taskDestroyed="taskDestroyed"
|
@taskDestroyed="taskDestroyed"
|
||||||
/>
|
/>
|
||||||
@@ -512,7 +513,7 @@ export default {
|
|||||||
this.creatingTask = null;
|
this.creatingTask = null;
|
||||||
},
|
},
|
||||||
taskCreated (task) {
|
taskCreated (task) {
|
||||||
this.tasksByType[task.type].push(task);
|
this.tasksByType[task.type].unshift(task);
|
||||||
},
|
},
|
||||||
taskEdited (task) {
|
taskEdited (task) {
|
||||||
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
|
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
:task-list-override="tasksByType[column]"
|
:task-list-override="tasksByType[column]"
|
||||||
:group="group"
|
:group="group"
|
||||||
:search-text="searchText"
|
:search-text="searchText"
|
||||||
|
:draggable-override="canCreateTasks"
|
||||||
@editTask="editTask"
|
@editTask="editTask"
|
||||||
@loadGroupCompletedTodos="loadGroupCompletedTodos"
|
@loadGroupCompletedTodos="loadGroupCompletedTodos"
|
||||||
@taskDestroyed="taskDestroyed"
|
@taskDestroyed="taskDestroyed"
|
||||||
@@ -295,7 +296,7 @@ export default {
|
|||||||
},
|
},
|
||||||
taskCreated (task) {
|
taskCreated (task) {
|
||||||
task.group.id = this.group._id;
|
task.group.id = this.group._id;
|
||||||
this.tasksByType[task.type].push(task);
|
this.tasksByType[task.type].unshift(task);
|
||||||
},
|
},
|
||||||
taskEdited (task) {
|
taskEdited (task) {
|
||||||
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
|
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
|
||||||
|
|||||||
@@ -86,7 +86,8 @@
|
|||||||
v-if="taskList.length > 0"
|
v-if="taskList.length > 0"
|
||||||
ref="tasksList"
|
ref="tasksList"
|
||||||
class="sortable-tasks"
|
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-on-touch-only="true"
|
||||||
:delay="100"
|
:delay="100"
|
||||||
@update="taskSorted"
|
@update="taskSorted"
|
||||||
@@ -391,6 +392,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
draggableOverride: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
searchText: {},
|
searchText: {},
|
||||||
selectedTags: {},
|
selectedTags: {},
|
||||||
taskListOverride: {},
|
taskListOverride: {},
|
||||||
@@ -767,6 +772,10 @@ export default {
|
|||||||
taskDestroyed (task) {
|
taskDestroyed (task) {
|
||||||
this.$emit('taskDestroyed', task);
|
this.$emit('taskDestroyed', task);
|
||||||
},
|
},
|
||||||
|
canBeDragged () {
|
||||||
|
return this.isUser
|
||||||
|
|| this.draggableOverride;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import * as Tasks from '../../models/task';
|
|||||||
import csvStringify from '../../libs/csvStringify';
|
import csvStringify from '../../libs/csvStringify';
|
||||||
import {
|
import {
|
||||||
createTasks,
|
createTasks,
|
||||||
} from '../../libs/taskManager';
|
} from '../../libs/tasks';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addUserJoinChallengeNotification,
|
addUserJoinChallengeNotification,
|
||||||
|
|||||||
@@ -16,35 +16,19 @@ import {
|
|||||||
import {
|
import {
|
||||||
createTasks,
|
createTasks,
|
||||||
getTasks,
|
getTasks,
|
||||||
|
getGroupFromTaskAndUser,
|
||||||
|
getChallengeFromTask,
|
||||||
|
scoreTasks,
|
||||||
|
verifyTaskModification,
|
||||||
|
} from '../../libs/tasks';
|
||||||
|
import {
|
||||||
moveTask,
|
moveTask,
|
||||||
setNextDue,
|
setNextDue,
|
||||||
scoreTasks,
|
requiredGroupFields,
|
||||||
} from '../../libs/taskManager';
|
} from '../../libs/tasks/utils';
|
||||||
import common from '../../../common';
|
import common from '../../../common';
|
||||||
import apiError from '../../libs/apiError';
|
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
|
* @apiDefine TaskNotFound
|
||||||
* @apiError (404) {NotFound} TaskNotFound The specified task could not be found.
|
* @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 api = {};
|
||||||
const requiredGroupFields = '_id leader tasksOrder name';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {post} /api/v3/tasks/user Create a new task belonging to the user
|
* @api {post} /api/v3/tasks/user Create a new task belonging to the user
|
||||||
@@ -613,7 +596,6 @@ api.updateTask = {
|
|||||||
middlewares: [authWithHeaders()],
|
middlewares: [authWithHeaders()],
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
const { user } = res.locals;
|
const { user } = res.locals;
|
||||||
let challenge;
|
|
||||||
|
|
||||||
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
||||||
|
|
||||||
@@ -622,26 +604,30 @@ api.updateTask = {
|
|||||||
|
|
||||||
const { taskId } = req.params;
|
const { taskId } = req.params;
|
||||||
const task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
|
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) {
|
if (!task) {
|
||||||
throw new NotFound(res.t('taskNotFound'));
|
throw new NotFound(res.t('taskNotFound'));
|
||||||
} else if (task.group.id && !task.userId) {
|
} else if (task.group.id && !task.userId) {
|
||||||
// @TODO: Abstract this access snippet
|
// If the task is in a group and only modifying `collapseChecklist`,
|
||||||
const fields = requiredGroupFields.concat(' managers');
|
// the modification should be allowed.
|
||||||
group = await Group.getGroup({ user, groupId: task.group.id, fields });
|
|
||||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
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
|
const allowedByTaskPayload = taskPayloadProps.length === 1
|
||||||
} else if (task.challenge.id && !task.userId) {
|
&& taskPayloadProps.includes('collapseChecklist');
|
||||||
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
|
if (!allowedByTaskPayload) {
|
||||||
} else if (task.userId !== user._id) {
|
// Otherwise, verify the task modification normally.
|
||||||
throw new NotFound(res.t('taskNotFound'));
|
verifyTaskModification(task, user, group, challenge, res);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
verifyTaskModification(task, user, group, challenge, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldCheckList = task.checklist;
|
const oldCheckList = task.checklist;
|
||||||
@@ -832,20 +818,29 @@ api.moveTask = {
|
|||||||
const { taskId } = req.params;
|
const { taskId } = req.params;
|
||||||
const to = Number(req.params.position);
|
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'));
|
if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo'));
|
||||||
|
|
||||||
|
const owner = group || challenge || user;
|
||||||
|
|
||||||
// In memory updates
|
// In memory updates
|
||||||
const order = user.tasksOrder[`${task.type}s`];
|
const order = owner.tasksOrder[`${task.type}s`];
|
||||||
moveTask(order, task._id, to);
|
moveTask(order, task._id, to);
|
||||||
|
|
||||||
// Server updates
|
// Server updates
|
||||||
// Cannot send $pull and $push on same field in one single op
|
// Cannot send $pull and $push on same field in one single op
|
||||||
const pullQuery = { $pull: {} };
|
const pullQuery = { $pull: {} };
|
||||||
pullQuery.$pull[`tasksOrder.${task.type}s`] = task.id;
|
pullQuery.$pull[`tasksOrder.${task.type}s`] = task.id;
|
||||||
await user.update(pullQuery).exec();
|
await owner.update(pullQuery).exec();
|
||||||
|
|
||||||
let position = to;
|
let position = to;
|
||||||
if (to === -1) position = order.length - 1; // push to bottom
|
if (to === -1) position = order.length - 1; // push to bottom
|
||||||
@@ -855,12 +850,15 @@ api.moveTask = {
|
|||||||
$each: [task._id],
|
$each: [task._id],
|
||||||
$position: position,
|
$position: position,
|
||||||
};
|
};
|
||||||
await user.update(updateQuery).exec();
|
await owner.update(updateQuery).exec();
|
||||||
|
|
||||||
// Update the user version field manually,
|
// Update the user version field manually,
|
||||||
// it cannot be updated in the pre update hook
|
// it cannot be updated in the pre update hook
|
||||||
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
|
// 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);
|
res.respond(200, order);
|
||||||
},
|
},
|
||||||
@@ -900,8 +898,6 @@ api.addChecklistItem = {
|
|||||||
middlewares: [authWithHeaders()],
|
middlewares: [authWithHeaders()],
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
const { user } = res.locals;
|
const { user } = res.locals;
|
||||||
let challenge;
|
|
||||||
let group;
|
|
||||||
|
|
||||||
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
||||||
|
|
||||||
@@ -913,22 +909,12 @@ api.addChecklistItem = {
|
|||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFound(res.t('taskNotFound'));
|
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'));
|
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
|
||||||
|
|
||||||
const newCheckListItem = Tasks.Task.sanitizeChecklist(req.body);
|
const newCheckListItem = Tasks.Task.sanitizeChecklist(req.body);
|
||||||
@@ -1018,8 +1004,6 @@ api.updateChecklistItem = {
|
|||||||
middlewares: [authWithHeaders()],
|
middlewares: [authWithHeaders()],
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
const { user } = res.locals;
|
const { user } = res.locals;
|
||||||
let challenge;
|
|
||||||
let group;
|
|
||||||
|
|
||||||
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
||||||
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
|
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
|
||||||
@@ -1032,22 +1016,11 @@ api.updateChecklistItem = {
|
|||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFound(res.t('taskNotFound'));
|
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'));
|
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
|
||||||
|
|
||||||
const item = _.find(task.checklist, { id: req.params.itemId });
|
const item = _.find(task.checklist, { id: req.params.itemId });
|
||||||
@@ -1095,8 +1068,6 @@ api.removeChecklistItem = {
|
|||||||
middlewares: [authWithHeaders()],
|
middlewares: [authWithHeaders()],
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
const { user } = res.locals;
|
const { user } = res.locals;
|
||||||
let challenge;
|
|
||||||
let group;
|
|
||||||
|
|
||||||
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
req.checkParams('taskId', apiError('taskIdRequired')).notEmpty();
|
||||||
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
|
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
|
||||||
@@ -1109,22 +1080,11 @@ api.removeChecklistItem = {
|
|||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFound(res.t('taskNotFound'));
|
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'));
|
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
|
||||||
|
|
||||||
const hasItem = removeFromArray(task.checklist, { id: req.params.itemId });
|
const hasItem = removeFromArray(task.checklist, { id: req.params.itemId });
|
||||||
@@ -1447,30 +1407,19 @@ api.deleteTask = {
|
|||||||
middlewares: [authWithHeaders()],
|
middlewares: [authWithHeaders()],
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
const { user } = res.locals;
|
const { user } = res.locals;
|
||||||
let challenge;
|
|
||||||
|
|
||||||
const { taskId } = req.params;
|
const { taskId } = req.params;
|
||||||
const task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
|
const task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFound(res.t('taskNotFound'));
|
throw new NotFound(res.t('taskNotFound'));
|
||||||
} else if (task.group.id && !task.userId) {
|
}
|
||||||
// @TODO: Abstract this access snippet
|
const group = await getGroupFromTaskAndUser(task, user);
|
||||||
const fields = requiredGroupFields.concat(' managers');
|
const challenge = await getChallengeFromTask(task);
|
||||||
const group = await Group.getGroup({ user, groupId: task.group.id, fields });
|
verifyTaskModification(task, user, group, challenge, res);
|
||||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
||||||
if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
|
if (task.group.id && !task.userId) {
|
||||||
await group.removeTask(task);
|
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) {
|
} else if (task.userId && task.challenge.id && !task.challenge.broken) {
|
||||||
throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
|
throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
NotAuthorized,
|
NotAuthorized,
|
||||||
} from '../../../libs/errors';
|
} from '../../../libs/errors';
|
||||||
import {
|
import {
|
||||||
|
canNotEditTasks,
|
||||||
createTasks,
|
createTasks,
|
||||||
getTasks,
|
getTasks,
|
||||||
|
} from '../../../libs/tasks';
|
||||||
|
import {
|
||||||
moveTask,
|
moveTask,
|
||||||
} from '../../../libs/taskManager';
|
} from '../../../libs/tasks/utils';
|
||||||
import { handleSharedCompletion } from '../../../libs/groupTasks';
|
import { handleSharedCompletion } from '../../../libs/groupTasks';
|
||||||
import apiError from '../../../libs/apiError';
|
import apiError from '../../../libs/apiError';
|
||||||
import logger from '../../../libs/logger';
|
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
|
// _allCompletedTodos is currently in BETA and is likely to be removed in future
|
||||||
types.push('completedTodos', '_allCompletedTodos');
|
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 = {};
|
const api = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { authWithHeaders } from '../../middlewares/auth';
|
import { authWithHeaders } from '../../middlewares/auth';
|
||||||
import { scoreTasks } from '../../libs/taskManager';
|
import { scoreTasks } from '../../libs/tasks';
|
||||||
|
|
||||||
const api = {};
|
const api = {};
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,29 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import * as Tasks from '../models/task';
|
import {
|
||||||
import apiError from './apiError';
|
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 {
|
import {
|
||||||
BadRequest,
|
BadRequest,
|
||||||
NotAuthorized,
|
|
||||||
NotFound,
|
NotFound,
|
||||||
} from './errors';
|
NotAuthorized,
|
||||||
|
} from '../errors';
|
||||||
import {
|
import {
|
||||||
SHARED_COMPLETION,
|
SHARED_COMPLETION,
|
||||||
handleSharedCompletion,
|
handleSharedCompletion,
|
||||||
} from './groupTasks';
|
} from '../groupTasks';
|
||||||
import shared from '../../common';
|
import shared from '../../../common';
|
||||||
import { model as Group } from '../models/group'; // eslint-disable-line import/no-cycle
|
import { taskScoredWebhook } from '../webhook';
|
||||||
import { model as User } from '../models/user'; // eslint-disable-line import/no-cycle
|
|
||||||
import { taskScoredWebhook } from './webhook'; // eslint-disable-line import/no-cycle
|
|
||||||
|
|
||||||
import logger from './logger';
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates tasks for a user, challenge or group.
|
* 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
|
* @param options.requiresApproval A boolean stating if the task will require approval
|
||||||
* @return The created tasks
|
* @return The created tasks
|
||||||
*/
|
*/
|
||||||
export async function createTasks (req, res, options = {}) {
|
async function createTasks (req, res, options = {}) {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
challenge,
|
challenge,
|
||||||
@@ -161,7 +117,7 @@ export async function createTasks (req, res, options = {}) {
|
|||||||
await owner.update(taskOrderUpdateQuery).exec();
|
await owner.update(taskOrderUpdateQuery).exec();
|
||||||
|
|
||||||
// tasks with aliases need to be validated asynchronously
|
// 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()),
|
// If all tasks are valid (this is why it's not in the previous .map()),
|
||||||
// save everything, withough running validation again
|
// 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
|
* @param options.dueDate The date to use for computing the nextDue field for each returned task
|
||||||
* @return The tasks found
|
* @return The tasks found
|
||||||
*/
|
*/
|
||||||
export async function getTasks (req, res, options = {}) {
|
async function getTasks (req, res, options = {}) {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
challenge,
|
challenge,
|
||||||
@@ -253,8 +209,17 @@ export async function getTasks (req, res, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Order tasks based on tasksOrder
|
// Order tasks based on tasksOrder
|
||||||
|
let order = [];
|
||||||
if (type && type !== 'completedTodos' && type !== '_allCompletedTodos') {
|
if (type && type !== 'completedTodos' && type !== '_allCompletedTodos') {
|
||||||
const order = owner.tasksOrder[type];
|
order = owner.tasksOrder[type];
|
||||||
|
} else if (!type) {
|
||||||
|
Object.values(owner.tasksOrder).forEach(taskOrder => {
|
||||||
|
order = order.concat(taskOrder);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
let orderedTasks = new Array(tasks.length);
|
let orderedTasks = new Array(tasks.length);
|
||||||
const unorderedTasks = []; // what we want to add later
|
const unorderedTasks = []; // what we want to add later
|
||||||
|
|
||||||
@@ -272,44 +237,44 @@ export async function getTasks (req, res, options = {}) {
|
|||||||
orderedTasks = _.compact(orderedTasks).concat(unorderedTasks);
|
orderedTasks = _.compact(orderedTasks).concat(unorderedTasks);
|
||||||
return orderedTasks;
|
return orderedTasks;
|
||||||
}
|
}
|
||||||
return tasks;
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Takes a Task document and return a plain object of attributes that can be synced to the user
|
async function getGroupFromTaskAndUser (task, user) {
|
||||||
export function syncableAttrs (task) {
|
if (task.group.id && !task.userId) {
|
||||||
const t = task.toObject(); // lodash doesn't seem to like _.omit on Document
|
const fields = requiredGroupFields.concat(' managers');
|
||||||
// only sync/compare important attrs
|
return Group.getGroup({ user, groupId: task.group.id, fields });
|
||||||
const omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt', 'createdAt', 'group', 'checklist', 'attribute'];
|
}
|
||||||
if (t.type !== 'reward') omitAttrs.push('value');
|
return null;
|
||||||
return _.omit(t, omitAttrs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getChallengeFromTask (task) {
|
||||||
* Moves a task to a specified position.
|
if (task.challenge.id && !task.userId) {
|
||||||
*
|
return Challenge.findOne({ _id: task.challenge.id }).exec();
|
||||||
* @param order The list of ordered tasks
|
}
|
||||||
* @param taskId The Task._id of the task to move
|
return null;
|
||||||
* @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);
|
function verifyTaskModification (task, user, group, challenge, res) {
|
||||||
if (to === -1) {
|
if (!task) {
|
||||||
order.push(taskId);
|
throw new NotFound(res.t('taskNotFound'));
|
||||||
} else {
|
} else if (task.group.id && !task.userId) {
|
||||||
order.splice(to, 0, taskId);
|
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 };
|
return { id: data.task._id, delta: data.delta, _tmp: data._tmp };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
createTasks,
|
||||||
|
getTasks,
|
||||||
|
scoreTask,
|
||||||
|
canNotEditTasks,
|
||||||
|
getGroupFromTaskAndUser,
|
||||||
|
getChallengeFromTask,
|
||||||
|
verifyTaskModification,
|
||||||
|
};
|
||||||
94
website/server/libs/tasks/utils.js
Normal file
94
website/server/libs/tasks/utils.js
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,7 @@ import { removeFromArray } from '../libs/collectionManipulators';
|
|||||||
import shared from '../../common';
|
import shared from '../../common';
|
||||||
import { sendTxn as txnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle
|
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 { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle
|
||||||
import { // eslint-disable-line import/no-cycle
|
import { syncableAttrs, setNextDue } from '../libs/tasks/utils';
|
||||||
syncableAttrs,
|
|
||||||
setNextDue,
|
|
||||||
} from '../libs/taskManager';
|
|
||||||
|
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle
|
||||||
import { // eslint-disable-line import/no-cycle
|
import { // eslint-disable-line import/no-cycle
|
||||||
syncableAttrs,
|
syncableAttrs,
|
||||||
} from '../libs/taskManager';
|
} from '../libs/tasks/utils';
|
||||||
import {
|
import {
|
||||||
schema as SubscriptionPlanSchema,
|
schema as SubscriptionPlanSchema,
|
||||||
} from './subscriptionPlan';
|
} from './subscriptionPlan';
|
||||||
|
|||||||
Reference in New Issue
Block a user