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 }, }, },