diff --git a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_approve_userId.test.js b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_approve_userId.test.js index 9972d6c33f..c4a65c72fa 100644 --- a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_approve_userId.test.js +++ b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_approve_userId.test.js @@ -140,4 +140,89 @@ describe('POST /tasks/:id/approve/:userId', () => { message: t('canOnlyApproveTaskOnce'), }); }); + + it('completes master task when single-completion task is approved', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: true, + sharedCompletion: 'singleCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); + + let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`); + + let masterTask = find(groupTasks, (groupTask) => { + return groupTask._id === sharedCompletionTask._id; + }); + + expect(masterTask.completed).to.equal(true); + }); + + it('deletes other assigned user tasks when single-completion task is approved', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: true, + sharedCompletion: 'singleCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); + + let member2Tasks = await member2.get('/tasks/user'); + + let syncedTask2 = find(member2Tasks, (memberTask) => { + return memberTask.group.taskId === sharedCompletionTask._id; + }); + + expect(syncedTask2).to.equal(undefined); + }); + + it('does not complete master task when not all user tasks are approved if all assigned must complete', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: true, + sharedCompletion: 'allAssignedCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); + + let groupTasks = await user.get(`/tasks/group/${guild._id}`); + + let masterTask = find(groupTasks, (groupTask) => { + return groupTask._id === sharedCompletionTask._id; + }); + + expect(masterTask.completed).to.equal(false); + }); + + it('completes master task when all user tasks are approved if all assigned must complete', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: true, + sharedCompletion: 'allAssignedCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member2._id}`); + + let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`); + + let masterTask = find(groupTasks, (groupTask) => { + return groupTask._id === sharedCompletionTask._id; + }); + + expect(masterTask.completed).to.equal(true); + }); }); diff --git a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js index d50841d3bc..41f6c2ae2b 100644 --- a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js @@ -125,7 +125,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); }); - it('allows a user to score an apporoved task', async () => { + it('allows a user to score an approved task', async () => { let memberTasks = await member.get('/tasks/user'); let syncedTask = find(memberTasks, findAssignedTask); @@ -137,4 +137,112 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedTask.completed).to.equal(true); expect(updatedTask.dateCompleted).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type }); + + it('completes master task when single-completion task is completed', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: false, + sharedCompletion: 'singleCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + let memberTasks = await member.get('/tasks/user'); + + let syncedTask = find(memberTasks, (memberTask) => { + return memberTask.group.taskId === sharedCompletionTask._id; + }); + + await member.post(`/tasks/${syncedTask._id}/score/up`); + + let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`); + let masterTask = find(groupTasks, (groupTask) => { + return groupTask._id === sharedCompletionTask._id; + }); + + expect(masterTask.completed).to.equal(true); + }); + + it('deletes other assigned user tasks when single-completion task is completed', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: false, + sharedCompletion: 'singleCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`); + let memberTasks = await member.get('/tasks/user'); + + let syncedTask = find(memberTasks, (memberTask) => { + return memberTask.group.taskId === sharedCompletionTask._id; + }); + + await member.post(`/tasks/${syncedTask._id}/score/up`); + + let member2Tasks = await member2.get('/tasks/user'); + + let syncedTask2 = find(member2Tasks, (memberTask) => { + return memberTask.group.taskId === sharedCompletionTask._id; + }); + + expect(syncedTask2).to.equal(undefined); + }); + + it('does not complete master task when not all user tasks are completed if all assigned must complete', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: false, + sharedCompletion: 'allAssignedCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`); + let memberTasks = await member.get('/tasks/user'); + + let syncedTask = find(memberTasks, (memberTask) => { + return memberTask.group.taskId === sharedCompletionTask._id; + }); + + await member.post(`/tasks/${syncedTask._id}/score/up`); + + let groupTasks = await user.get(`/tasks/group/${guild._id}`); + let masterTask = find(groupTasks, (groupTask) => { + return groupTask._id === sharedCompletionTask._id; + }); + + expect(masterTask.completed).to.equal(false); + }); + + it('completes master task when all user tasks are completed if all assigned must complete', async () => { + let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { + text: 'shared completion todo', + type: 'todo', + requiresApproval: false, + sharedCompletion: 'allAssignedCompletion', + }); + + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`); + await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`); + let memberTasks = await member.get('/tasks/user'); + let member2Tasks = await member2.get('/tasks/user'); + let syncedTask = find(memberTasks, (memberTask) => { + return memberTask.group.taskId === sharedCompletionTask._id; + }); + let syncedTask2 = find(member2Tasks, (memberTask) => { + return memberTask.group.taskId === sharedCompletionTask._id; + }); + + await member.post(`/tasks/${syncedTask._id}/score/up`); + await member2.post(`/tasks/${syncedTask2._id}/score/up`); + + let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`); + let masterTask = find(groupTasks, (groupTask) => { + return groupTask._id === sharedCompletionTask._id; + }); + + expect(masterTask.completed).to.equal(true); + }); }); diff --git a/website/client/components/group-plans/taskInformation.vue b/website/client/components/group-plans/taskInformation.vue index 610ee98929..01aeb6c3e8 100644 --- a/website/client/components/group-plans/taskInformation.vue +++ b/website/client/components/group-plans/taskInformation.vue @@ -80,6 +80,7 @@ :key="column", :taskListOverride='tasksByType[column]', v-on:editTask="editTask", + v-on:loadGroupCompletedTodos="loadGroupCompletedTodos", :group='group', :searchText="searchText") @@ -384,6 +385,20 @@ export default { this.$root.$emit('bv::show::modal', 'task-modal'); }); }, + async loadGroupCompletedTodos () { + const completedTodos = await this.$store.dispatch('tasks:getCompletedGroupTasks', { + groupId: this.searchId, + }); + + completedTodos.forEach((task) => { + const existingTaskIndex = findIndex(this.tasksByType.todo, (todo) => { + return todo._id === task._id; + }); + if (existingTaskIndex === -1) { + this.tasksByType.todo.push(task); + } + }); + }, createTask (type) { this.taskFormPurpose = 'create'; this.creatingTask = taskDefaults({type, text: ''}); diff --git a/website/client/components/tasks/column.vue b/website/client/components/tasks/column.vue index eef611ca7a..ba69531d99 100644 --- a/website/client/components/tasks/column.vue +++ b/website/client/components/tasks/column.vue @@ -362,7 +362,7 @@ export default { type: this.type, filterType: this.activeFilter.label, }) : - this.taskListOverride; + this.filterByCompleted(this.taskListOverride, this.activeFilter.label); let taggedList = this.filterByTagList(filteredTaskList, this.selectedTags); let searchedList = this.filterBySearchText(taggedList, this.searchText); @@ -556,7 +556,11 @@ export default { activateFilter (type, filter = '') { // Needs a separate API call as this data may not reside in store if (type === 'todo' && filter === 'complete2') { - this.loadCompletedTodos(); + if (this.group && this.group._id) { + this.$emit('loadGroupCompletedTodos'); + } else { + this.loadCompletedTodos(); + } } // the only time activateFilter is called with filter==='' is when the component is first created @@ -594,6 +598,13 @@ export default { } }); }, + filterByCompleted (taskList, filter) { + if (!taskList) return []; + return taskList.filter(task => { + if (filter === 'complete2') return task.completed; + return !task.completed; + }); + }, filterByTagList (taskList, tagList = []) { let filteredTaskList = taskList; // filter requested tasks by tags diff --git a/website/client/components/tasks/taskModal.vue b/website/client/components/tasks/taskModal.vue index c11cec9fe9..aa09db6fa0 100644 --- a/website/client/components/tasks/taskModal.vue +++ b/website/client/components/tasks/taskModal.vue @@ -185,6 +185,15 @@ :checked="requiresApproval", @change="updateRequiresApproval" ) + .form-group(v-if="task.type === 'todo'") + label(v-once) {{ $t('sharedCompletion') }} + b-dropdown.inline-dropdown(:text="$t(sharedCompletion)") + b-dropdown-item( + v-for="completionOption in ['recurringCompletion', 'singleCompletion', 'allAssignedCompletion']", + :key="completionOption", + @click="sharedCompletion = completionOption", + :class="{active: sharedCompletion === completionOption}" + ) {{ $t(completionOption) }} .advanced-settings(v-if="task.type !== 'reward'") .advanced-settings-toggle.d-flex.justify-content-between.align-items-center(@click = "showAdvancedOptions = !showAdvancedOptions") @@ -691,6 +700,7 @@ export default { calendar: calendarIcon, }), requiresApproval: false, // We can't set task.group fields so we use this field to toggle + sharedCompletion: 'recurringCompletion', members: [], memberNamesById: {}, assignedMembers: [], @@ -811,6 +821,7 @@ export default { }); this.assignedMembers = []; if (this.task.group && this.task.group.assignedUsers) this.assignedMembers = this.task.group.assignedUsers; + if (this.task.group) this.sharedCompletion = this.task.group.sharedCompletion || 'recurringCompletion'; } // @TODO: This whole component is mutating a prop and that causes issues. We need to not copy the prop similar to group modals @@ -892,10 +903,13 @@ export default { async submit () { if (this.newChecklistItem) this.addChecklistItem(); + // TODO Fix up permissions on task.group so we don't have to keep doing these hacks if (this.groupId) { this.task.group.assignedUsers = this.assignedMembers; this.task.requiresApproval = this.requiresApproval; this.task.group.approval.required = this.requiresApproval; + this.task.sharedCompletion = this.sharedCompletion; + this.task.group.sharedCompletion = this.sharedCompletion; } if (this.purpose === 'create') { diff --git a/website/client/store/actions/tasks.js b/website/client/store/actions/tasks.js index baa3788460..d6d702d8af 100644 --- a/website/client/store/actions/tasks.js +++ b/website/client/store/actions/tasks.js @@ -176,6 +176,11 @@ export async function getGroupTasks (store, payload) { return response.data.data; } +export async function getCompletedGroupTasks (store, payload) { + let response = await axios.get(`/api/v4/tasks/group/${payload.groupId}?type=completedTodos`); + return response.data.data; +} + export async function createGroupTasks (store, payload) { let response = await axios.post(`/api/v4/tasks/group/${payload.groupId}`, payload.tasks); return response.data.data; diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 59d1ced5a2..ba06f49700 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -475,5 +475,9 @@ "howToRequireApprovalDesc2": "Group Leaders and Managers can approve completed Tasks directly from the Task Board or from the Notifications panel.", "whatIsGroupManager": "What is a Group Manager?", "whatIsGroupManagerDesc": "A Group Manager is a user role that do not have access to the group's billing details, but can create, assign, and approve shared Tasks for the Group's members. Promote Group Managers from the Group’s member list.", - "goToTaskBoard": "Go to Task Board" + "goToTaskBoard": "Go to Task Board", + "sharedCompletion": "Shared Completion", + "recurringCompletion": "None - Group task does not complete", + "singleCompletion": "Single - Completes when any assigned user finishes", + "allAssignedCompletion": "All - Completes when all assigned users finish" } diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index e309eac1c0..9951b859e8 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -5,6 +5,7 @@ import { } from '../../libs/webhook'; import { removeFromArray } from '../../libs/collectionManipulators'; import * as Tasks from '../../models/task'; +import { handleSharedCompletion } from '../../libs/groupTasks'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; @@ -490,6 +491,9 @@ api.updateTask = { if (sanitizedObj.requiresApproval) { task.group.approval.required = true; } + if (sanitizedObj.sharedCompletion) { + task.group.sharedCompletion = sanitizedObj.sharedCompletion; + } setNextDue(task, user); let savedTask = await task.save(); @@ -653,6 +657,12 @@ api.scoreTask = { user.save(), task.save(), ]; + + if (task.group && task.group.taskId) { + await handleSharedCompletion(task); + } + + // Save results and handle request if (taskOrderPromise) promises.push(taskOrderPromise); let results = await Promise.all(promises); diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js index bd68fe6d2d..11f45a2259 100644 --- a/website/server/controllers/api-v3/tasks/groups.js +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -12,10 +12,13 @@ import { getTasks, moveTask, } from '../../../libs/taskManager'; +import { handleSharedCompletion } from '../../../libs/groupTasks'; import apiError from '../../../libs/apiError'; let requiredGroupFields = '_id leader tasksOrder name'; +// @TODO: abstract to task lib let types = Tasks.tasksTypes.map(type => `${type}s`); +types.push('completedTodos', '_allCompletedTodos'); // _allCompletedTodos is currently in BETA and is likely to be removed in future function canNotEditTasks (group, user, assignedUserId) { let isNotGroupLeader = group.leader !== user._id; @@ -338,7 +341,7 @@ api.approveTask = { } // Remove old notifications - let managerPromises = []; + let approvalPromises = []; managers.forEach((manager) => { let notificationIndex = manager.notifications.findIndex(function findNotification (notification) { return notification && notification.data && notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL'; @@ -346,7 +349,7 @@ api.approveTask = { if (notificationIndex !== -1) { manager.notifications.splice(notificationIndex, 1); - managerPromises.push(manager.save()); + approvalPromises.push(manager.save()); } }); @@ -362,9 +365,11 @@ api.approveTask = { direction, }); - managerPromises.push(task.save()); - managerPromises.push(assignedUser.save()); - await Promise.all(managerPromises); + await handleSharedCompletion(task); + + approvalPromises.push(task.save()); + approvalPromises.push(assignedUser.save()); + await Promise.all(approvalPromises); res.respond(200, task); }, diff --git a/website/server/libs/groupTasks.js b/website/server/libs/groupTasks.js new file mode 100644 index 0000000000..30f6e328ec --- /dev/null +++ b/website/server/libs/groupTasks.js @@ -0,0 +1,61 @@ +import * as Tasks from '../models/task'; + +const SHARED_COMPLETION = { + default: 'recurringCompletion', + single: 'singleCompletion', + every: 'allAssignedCompletion', +}; + +async function _completeMasterTask (masterTask) { + masterTask.completed = true; + await masterTask.save(); +} + +async function _deleteUnfinishedTasks (groupMemberTask) { + await Tasks.Task.deleteMany({ + 'group.taskId': groupMemberTask.group.taskId, + $and: [ + {userId: {$exists: true}}, + {userId: {$ne: groupMemberTask.userId}}, + ], + }).exec(); +} + +async function _evaluateAllAssignedCompletion (masterTask) { + let completions; + if (masterTask.group.approval && masterTask.group.approval.required) { + completions = await Tasks.Task.count({ + 'group.taskId': masterTask._id, + 'group.approval.approved': true, + }).exec(); + completions++; + } else { + completions = await Tasks.Task.count({ + 'group.taskId': masterTask._id, + completed: true, + }).exec(); + } + if (completions >= masterTask.group.assignedUsers.length) { + await _completeMasterTask(masterTask); + } +} + +async function handleSharedCompletion (groupMemberTask) { + let masterTask = await Tasks.Task.findOne({ + _id: groupMemberTask.group.taskId, + }).exec(); + + if (!masterTask || !masterTask.group || masterTask.type !== 'todo') return; + + if (masterTask.group.sharedCompletion === SHARED_COMPLETION.single) { + await _deleteUnfinishedTasks(groupMemberTask); + await _completeMasterTask(masterTask); + } else if (masterTask.group.sharedCompletion === SHARED_COMPLETION.every) { + await _evaluateAllAssignedCompletion(masterTask); + } +} + +export { + SHARED_COMPLETION, + handleSharedCompletion, +}; diff --git a/website/server/libs/taskManager.js b/website/server/libs/taskManager.js index c65b1cdc52..6cc246f531 100644 --- a/website/server/libs/taskManager.js +++ b/website/server/libs/taskManager.js @@ -3,6 +3,9 @@ import * as Tasks from '../models/task'; import { BadRequest, } from './errors'; +import { + SHARED_COMPLETION, +} from './groupTasks'; import _ from 'lodash'; import shared from '../../common'; @@ -96,6 +99,7 @@ export async function createTasks (req, res, options = {}) { if (taskData.requiresApproval) { newTask.group.approval.required = true; } + newTask.group.sharedCompletion = taskData.sharedCompletion || SHARED_COMPLETION.default; } else { newTask.userId = user._id; } @@ -183,11 +187,12 @@ export async function getTasks (req, res, options = {}) { limit = 0; // no limit } - query = { - userId: user._id, - type: 'todo', - completed: true, - }; + query.type = 'todo'; + query.completed = true; + + if (owner._id === user._id) { + query.userId = user._id; + } sort = { dateCompleted: -1, diff --git a/website/server/models/group.js b/website/server/models/group.js index 790e06c607..f1612af3e8 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -1319,6 +1319,7 @@ schema.methods.updateTask = async function updateTask (taskToSync, options = {}) updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required; updateCmd.$set['group.assignedUsers'] = taskToSync.group.assignedUsers; + updateCmd.$set['group.sharedCompletion'] = taskToSync.group.sharedCompletion; let taskSchema = Tasks[taskToSync.type]; @@ -1414,6 +1415,7 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) { matchingTask.group.approval.required = taskToSync.group.approval.required; matchingTask.group.assignedUsers = taskToSync.group.assignedUsers; + matchingTask.group.sharedCompletion = taskToSync.group.sharedCompletion; // sync checklist if (taskToSync.checklist) { diff --git a/website/server/models/task.js b/website/server/models/task.js index 2b3d8e1c8f..52b1a0450b 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -6,6 +6,7 @@ import baseModel from '../libs/baseModel'; import { InternalServerError } from '../libs/errors'; import _ from 'lodash'; import { preenHistory } from '../libs/preening'; +import { SHARED_COMPLETION } from '../libs/groupTasks'; const Schema = mongoose.Schema; @@ -111,6 +112,7 @@ export let TaskSchema = new Schema({ requested: {type: Boolean, default: false}, requestedDate: {type: Date}, }, + sharedCompletion: {type: String, enum: _.values(SHARED_COMPLETION), default: SHARED_COMPLETION.default}, }, reminders: [{