diff --git a/scripts/team-cron.js b/scripts/team-cron.js
index e1723f129d..71b882746c 100644
--- a/scripts/team-cron.js
+++ b/scripts/team-cron.js
@@ -17,7 +17,6 @@ async function updateTeamTasks (team) {
) {
const tasks = await Tasks.Task.find({
'group.id': team._id,
- 'group.assignedUsers': [],
userId: { $exists: false },
$or: [
{ type: 'todo', completed: false },
@@ -51,9 +50,18 @@ async function updateTeamTasks (team) {
processChecklist = true;
daily.completed = false;
} else if (shouldDo(team.cron.lastProcessed, daily, teamLeader.preferences)) {
+ let assignments = 0;
+ let completions = 0;
+ for (const assignedUser in daily.group.assignedUsers) {
+ if (Object.prototype.hasOwnProperty.call(daily.group.assignedUsers, assignedUser)) {
+ assignments += 1;
+ if (assignedUser.completed) completions += 1;
+ assignedUser.completed = false;
+ }
+ }
processChecklist = true;
const delta = TASK_VALUE_CHANGE_FACTOR ** daily.value;
- daily.value -= delta;
+ daily.value -= ((completions / assignments) * delta);
if (daily.value < MIN_TASK_VALUE) daily.value = MIN_TASK_VALUE;
}
daily.isDue = shouldDo(new Date(), daily, teamLeader.preferences);
diff --git a/website/client/src/components/tasks/task.vue b/website/client/src/components/tasks/task.vue
index b3ac457a0e..dcddbe8334 100644
--- a/website/client/src/components/tasks/task.vue
+++ b/website/client/src/components/tasks/task.vue
@@ -58,8 +58,8 @@
class="task-control daily-todo-control"
:class="controlClass.inner"
tabindex="0"
- @click="score(task.completed ? 'down' : 'up' )"
- @keypress.enter="score(task.completed ? 'down' : 'up' )"
+ @click="score(showCheckIcon ? 'down' : 'up' )"
+ @keypress.enter="score(showCheckIcon ? 'down' : 'up' )"
>
<%- userName %>",
- "assignedToMembers": "Assigned to <%= userCount %> members",
- "assignedToYouAndMembers": "Assigned to you and <%= userCount %> members",
+ "assignedToMembers": "Assigned to <%= userCount %> users",
+ "assignedToYouAndMembers": "Assigned to you and <%= userCount %> users",
"youAreAssigned": "Assigned to you",
"taskIsUnassigned": "This task is unassigned",
"unassigned": "Unassigned",
diff --git a/website/common/script/ops/scoreTask.js b/website/common/script/ops/scoreTask.js
index da258567b7..fb2e3c9abb 100644
--- a/website/common/script/ops/scoreTask.js
+++ b/website/common/script/ops/scoreTask.js
@@ -1,3 +1,4 @@
+import find from 'lodash/find';
import timesLodash from 'lodash/times';
import reduce from 'lodash/reduce';
import moment from 'moment';
@@ -330,13 +331,37 @@ export default function scoreTask (options = {}, req = {}, analytics) {
delta += _changeTaskValue(user, task, direction, times, cron);
} else {
if (direction === 'up') {
- task.dateCompleted = new Date();
- task.completed = true;
- if (task.group) task.group.completedBy = user._id;
+ if (task.group.id) {
+ if (!task.group.assignedUsers) {
+ task.group.completedBy = {
+ userId: user._id,
+ date: new Date(),
+ };
+ task.completed = true;
+ } else {
+ task.group.assignedUsers[user._id].completed = true;
+ task.group.assignedUsers[user._id].completedDate = new Date();
+ if (!find(task.group.assignedUsers, assignedUser => !assignedUser.completed)) {
+ task.dateCompleted = new Date();
+ task.completed = true;
+ }
+ }
+ if (task.markModified) task.markModified('group');
+ } else {
+ task.dateCompleted = new Date();
+ task.completed = true;
+ }
} else if (direction === 'down') {
task.completed = false;
task.dateCompleted = undefined;
- if (task.group && task.group.completedBy) task.group.completedBy = undefined;
+ if (task.group.id) {
+ if (task.group.completedBy) task.group.completedBy = {};
+ if (task.group.assignedUsers && task.group.assignedUsers[user._id]) {
+ task.group.assignedUsers[user._id].completed = false;
+ task.group.assignedUsers[user._id].completedDate = undefined;
+ }
+ if (task.markModified) task.markModified('group');
+ }
}
delta += _changeTaskValue(user, task, direction, times, cron);
diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js
index 176c764341..7719c35881 100644
--- a/website/server/controllers/api-v3/tasks.js
+++ b/website/server/controllers/api-v3/tasks.js
@@ -688,7 +688,7 @@ api.updateTask = {
setNextDue(task, user);
const savedTask = await task.save();
- if (group && task.group.id && task.group.assignedUsers.length > 0) {
+ if (group && task.group.id && task.group.assignedUsers) {
const updateCheckListItems = _.remove(sanitizedObj.checklist, checklist => {
const indexOld = _.findIndex(oldCheckList, check => check.id === checklist.id);
if (indexOld !== -1) return checklist.text !== oldCheckList[indexOld].text;
@@ -702,7 +702,7 @@ api.updateTask = {
if (challenge) {
challenge.updateTask(savedTask);
- } else if (group && task.group.id && task.group.assignedUsers.length > 0) {
+ } else if (group && task.group.id && task.group.assignedUsers) {
await group.updateTask(savedTask);
} else {
taskActivityWebhook.send(user, {
diff --git a/website/server/libs/tasks/index.js b/website/server/libs/tasks/index.js
index d122f57677..ec4c51f8f6 100644
--- a/website/server/libs/tasks/index.js
+++ b/website/server/libs/tasks/index.js
@@ -305,7 +305,7 @@ async function handleTeamTask (task, delta, direction) {
if (teamTask) {
const groupDelta = teamTask.group.assignedUsers
- ? delta / teamTask.group.assignedUsers.length
+ ? delta / _.keys(teamTask.group.assignedUsers).length
: delta;
await teamTask.scoreChallengeTask(groupDelta, direction);
if (task.type === 'daily' || task.type === 'todo') {
@@ -328,14 +328,19 @@ async function handleTeamTask (task, delta, direction) {
*/
async function scoreTask (user, task, direction, req, res) {
if (task.type === 'daily' || task.type === 'todo') {
- if (task.completed && direction === 'up') {
+ if (task.group.id && task.group.assignedUsers) {
+ if (task.group.assignedUsers[user._id].completed && direction === 'up') {
+ throw new NotAuthorized(res.t('sessionOutdated'));
+ } else if (!task.group.assignedUsers[user._id].completed && direction === 'down') {
+ throw new NotAuthorized(res.t('sessionOutdated'));
+ }
+ } else if (task.completed && direction === 'up') {
throw new NotAuthorized(res.t('sessionOutdated'));
} else if (!task.completed && direction === 'down') {
throw new NotAuthorized(res.t('sessionOutdated'));
}
}
- let localTask;
let rollbackUser;
let group;
@@ -347,46 +352,36 @@ async function scoreTask (user, task, direction, req, res) {
});
}
if (
- group && task.group.id && !task.userId
- && direction === 'down'
- && ['todo', 'daily'].includes(task.type)
- && task.completed
- && task.group.completedBy !== user._id
+ group && task.group.id && !task.userId // Task is on team board
+ && ['todo', 'daily'].includes(task.type) // Task is a To Do or Daily
+ && direction === 'down' // Task is being "unchecked"
) {
- if (group.leader !== user._id && !group.managers[user._id]) {
+ const userIsManagement = group.leader === user._id || Boolean(group.managers[user._id]);
+ if (!userIsManagement
+ && !(task.group.completedBy && task.group.completedBy.userId === user._id)
+ && !(task.group.assignedUsers && task.group.assignedUsers[user._id])
+ ) {
throw new BadRequest('Cannot uncheck task you did not complete if not a manager.');
}
- rollbackUser = await User.findOne({ _id: task.group.completedBy });
- task.group.completedBy = undefined;
- } else if (task.group.id && !task.userId && task.group.assignedUsers.length > 0) {
- // Task is being scored from team board, and a user copy should exist
- if (!task.group.assignedUsers.includes(user._id)) {
- throw new BadRequest('Task has not been assigned to this user.');
- }
-
- localTask = await Tasks.Task.findOne(
- { userId: user._id, 'group.taskId': task._id },
- ).exec();
- if (!localTask) throw new NotFound('Task not found.');
+ rollbackUser = await User.findOne({ _id: task.group.completedBy.userId });
+ task.group.completedBy = {};
}
- const targetTask = localTask || task;
-
- const wasCompleted = targetTask.completed;
+ const wasCompleted = task.completed;
const firstTask = !user.achievements.completedTask;
let delta;
if (rollbackUser) {
delta = shared.ops.scoreTask({
- task: targetTask,
+ task,
user: rollbackUser,
direction,
}, req, res.analytics);
rollbackUser.addNotification('GROUP_TASK_NEEDS_WORK', {
- message: res.t('taskNeedsWork', { taskText: targetTask.text, managerName: user.profile.name }, rollbackUser.preferences.language),
+ message: res.t('taskNeedsWork', { taskText: task.text, managerName: user.profile.name }, rollbackUser.preferences.language),
task: {
- id: targetTask._id,
- text: targetTask.text,
+ id: task._id,
+ text: task.text,
},
group: {
id: group._id,
@@ -399,51 +394,70 @@ async function scoreTask (user, task, direction, req, res) {
});
await rollbackUser.save();
} else {
- delta = shared.ops.scoreTask({ task: targetTask, user, direction }, req, res.analytics);
+ delta = shared.ops.scoreTask({ task, user, direction }, req, res.analytics);
}
// Drop system (don't run on the client,
// as it would only be discarded since ops are sent to the API, not the results)
- if (direction === 'up' && !firstTask) shared.fns.randomDrop(user, { task: targetTask, delta }, req, res.analytics);
+ if (direction === 'up' && !firstTask) shared.fns.randomDrop(user, { task, delta }, req, res.analytics);
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
// TODO move to common code?
let pullTask;
let pushTask;
- if (targetTask.type === 'todo') {
+ if (task.type === 'todo') {
if (!wasCompleted && task.completed) {
// @TODO: mongoose's push and pull should be atomic and help with
// our concurrency issues. If not, we need to use this update $pull and $push
- pullTask = targetTask._id;
+ pullTask = task._id;
} else if (
wasCompleted
- && !targetTask.completed
+ && !task.completed
&& user.tasksOrder.todos.indexOf(task._id) === -1
) {
- pushTask = targetTask._id;
+ pushTask = task._id;
}
}
- if (targetTask.completed && targetTask.group.id && !targetTask.userId) {
- targetTask.group.completedBy = user._id;
+ if (task.completed && task.group.id
+ && !task.userId && !task.group.assignedUsers) {
+ task.group.completedBy = {
+ userId: user._id,
+ date: new Date(),
+ };
}
setNextDue(task, user);
- if (localTask) {
- localTask.completed = targetTask.completed;
- localTask.value = Number(targetTask.value) + Number(delta);
- await localTask.save();
- }
-
taskScoredWebhook.send(user, {
- task: targetTask,
+ task,
direction,
delta,
user,
});
+ if (group) {
+ let role;
+ if (group.leader === user._id) {
+ role = 'leader';
+ } else if (group.managers[user._id]) {
+ role = 'manager';
+ } else {
+ role = 'member';
+ }
+ res.analytics.track('team task scored', {
+ uuid: user._id,
+ hitType: 'event',
+ category: 'behavior',
+ taskType: task.type,
+ direction,
+ headers: req.headers,
+ groupID: group._id,
+ role,
+ });
+ }
+
return {
- task: targetTask,
+ task,
delta,
direction,
pullTask,
diff --git a/website/server/models/group.js b/website/server/models/group.js
index 88adac0bae..c28050215c 100644
--- a/website/server/models/group.js
+++ b/website/server/models/group.js
@@ -1466,9 +1466,7 @@ schema.methods.updateTask = async function updateTask (taskToSync, options = {})
updateCmd.$set[key] = syncableAttributes[key];
}
- updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required;
updateCmd.$set['group.assignedUsers'] = taskToSync.group.assignedUsers;
- updateCmd.$set['group.sharedCompletion'] = taskToSync.group.sharedCompletion;
updateCmd.$set['group.managerNotes'] = taskToSync.group.managerNotes;
const taskSchema = Tasks[taskToSync.type];
@@ -1516,6 +1514,7 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, users, assig
if (!taskToSync.group.assignedUsers[user._id]) {
taskToSync.group.assignedUsers[user._id] = assignmentData;
}
+ taskToSync.markModified('group.assignedUsers');
// Sync tags
const userTags = user.tags;
@@ -1556,7 +1555,6 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, users, assig
if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
}
matchingTask.group.assignedUsers = taskToSync.group.assignedUsers;
- matchingTask.group.sharedCompletion = taskToSync.group.sharedCompletion;
matchingTask.group.managerNotes = taskToSync.group.managerNotes;
// sync checklist
@@ -1589,8 +1587,9 @@ schema.methods.unlinkTask = async function groupUnlinkTask (
userId: user._id,
};
- const assignedUserIndex = unlinkingTask.group.assignedUsers.indexOf(user._id);
- unlinkingTask.group.assignedUsers.splice(assignedUserIndex, 1);
+ delete unlinkingTask.group.assignedUsers[user._id];
+ unlinkingTask.markModified('group.assignedUsers');
+ const promises = [unlinkingTask.save()];
if (keep === 'keep-all') {
await Tasks.Task.update(findQuery, {
@@ -1608,16 +1607,14 @@ schema.methods.unlinkTask = async function groupUnlinkTask (
user.markModified('tasksOrder');
}
- const promises = [unlinkingTask.save()];
if (task) {
promises.push(task.remove());
}
// When multiple tasks are being unlinked at the same time,
// save the user once outside of this function
if (saveUser) promises.push(user.save());
-
- await Promise.all(promises);
}
+ await Promise.all(promises);
};
schema.methods.removeTask = async function groupRemoveTask (task) {
diff --git a/website/server/models/task.js b/website/server/models/task.js
index 42b376e0e3..7e0913f5d1 100644
--- a/website/server/models/task.js
+++ b/website/server/models/task.js
@@ -133,16 +133,14 @@ export const TaskSchema = new Schema({
// key is assigned UUID, with
// { assignedDate: Date,
// assigningUsername: '@username',
- // completed: Boolean }
+ // completed: Boolean,
+ // completedDate: Date }
},
taskId: { $type: String, ref: 'Task', validate: [v => validator.isUUID(v), 'Invalid uuid for group task.'] },
- sharedCompletion: {
- $type: String, default: 'singleCompletion', // legacy data
- },
managerNotes: { $type: String },
completedBy: {
- $type: Schema.Types.Mixed,
- default: () => ({}), // { 'UUID': Date }
+ userId: { $type: String, ref: 'User', validate: [v => validator.isUUID(v), 'Invalid uuid for task completing user.'] },
+ date: { $type: Date },
},
},