diff --git a/test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js b/test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js index 346760bd32..0364869af9 100644 --- a/test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js +++ b/test/api/v3/integration/tasks/groups/DELETE-group_tasks_id.test.js @@ -63,6 +63,38 @@ describe('Groups DELETE /tasks/:id', () => { }); }); + it('removes deleted taskʾs approval pending notifications from managers', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + await user.put(`/tasks/${task._id}/`, { + requiresApproval: true, + }); + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + + await user.sync(); + await member2.sync(); + expect(user.notifications.length).to.equal(2); + expect(user.notifications[1].type).to.equal('GROUP_TASK_APPROVAL'); + expect(member2.notifications.length).to.equal(2); + expect(member2.notifications[1].type).to.equal('GROUP_TASK_APPROVAL'); + + await member2.del(`/tasks/${task._id}`); + + await user.sync(); + await member2.sync(); + + expect(user.notifications.length).to.equal(1); + expect(member2.notifications.length).to.equal(1); + }); + it('unlinks assigned user', async () => { await user.del(`/tasks/${task._id}`); 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 c4a65c72fa..a483eaa7fa 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 @@ -53,18 +53,29 @@ describe('POST /tasks/:id/approve/:userId', () => { it('approves an assigned user', async () => { await user.post(`/tasks/${task._id}/assign/${member._id}`); - await user.post(`/tasks/${task._id}/approve/${member._id}`); let memberTasks = await member.get('/tasks/user'); let syncedTask = find(memberTasks, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + + await user.post(`/tasks/${task._id}/approve/${member._id}`); + await member.sync(); - expect(member.notifications.length).to.equal(2); - expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVED'); - expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text})); - expect(member.notifications[1].type).to.equal('SCORED_TASK'); + expect(member.notifications.length).to.equal(3); + expect(member.notifications[1].type).to.equal('GROUP_TASK_APPROVED'); expect(member.notifications[1].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text})); + expect(member.notifications[2].type).to.equal('SCORED_TASK'); + expect(member.notifications[2].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text})); + + memberTasks = await member.get('/tasks/user'); + syncedTask = find(memberTasks, findAssignedTask); expect(syncedTask.group.approval.approved).to.be.true; expect(syncedTask.group.approval.approvingUser).to.equal(user._id); @@ -77,18 +88,28 @@ describe('POST /tasks/:id/approve/:userId', () => { }); await member2.post(`/tasks/${task._id}/assign/${member._id}`); - await member2.post(`/tasks/${task._id}/approve/${member._id}`); let memberTasks = await member.get('/tasks/user'); let syncedTask = find(memberTasks, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + + await member2.post(`/tasks/${task._id}/approve/${member._id}`); await member.sync(); - expect(member.notifications.length).to.equal(2); - expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVED'); - expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text})); - expect(member.notifications[1].type).to.equal('SCORED_TASK'); + expect(member.notifications.length).to.equal(3); + expect(member.notifications[1].type).to.equal('GROUP_TASK_APPROVED'); expect(member.notifications[1].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text})); + expect(member.notifications[2].type).to.equal('SCORED_TASK'); + expect(member.notifications[2].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text})); + + memberTasks = await member.get('/tasks/user'); + syncedTask = find(memberTasks, findAssignedTask); expect(syncedTask.group.approval.approved).to.be.true; expect(syncedTask.group.approval.approvingUser).to.equal(member2._id); @@ -132,6 +153,16 @@ describe('POST /tasks/:id/approve/:userId', () => { }); await member2.post(`/tasks/${task._id}/assign/${member._id}`); + + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + await member2.post(`/tasks/${task._id}/approve/${member._id}`); await expect(user.post(`/tasks/${task._id}/approve/${member._id}`)) .to.eventually.be.rejected.and.to.eql({ @@ -141,6 +172,17 @@ describe('POST /tasks/:id/approve/:userId', () => { }); }); + it('prevents approving a task if it is not waiting for approval', async () => { + await user.post(`/tasks/${task._id}/assign/${member._id}`); + + await expect(user.post(`/tasks/${task._id}/approve/${member._id}`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalWasNotRequested'), + }); + }); + it('completes master task when single-completion task is approved', async () => { let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, { text: 'shared completion todo', @@ -151,6 +193,16 @@ describe('POST /tasks/:id/approve/:userId', () => { 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, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`); @@ -172,6 +224,16 @@ describe('POST /tasks/:id/approve/:userId', () => { 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, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); let member2Tasks = await member2.get('/tasks/user'); @@ -193,6 +255,16 @@ describe('POST /tasks/:id/approve/:userId', () => { 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, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); let groupTasks = await user.get(`/tasks/group/${guild._id}`); @@ -214,6 +286,25 @@ describe('POST /tasks/:id/approve/:userId', () => { 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, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + + let member2Tasks = await member2.get('/tasks/user'); + let member2SyncedTask = find(member2Tasks, findAssignedTask); + await expect(member2.post(`/tasks/${member2SyncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`); await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member2._id}`); diff --git a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_needs-work_userId.test.js b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_needs-work_userId.test.js index 0be0beb720..a474e97732 100644 --- a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_needs-work_userId.test.js +++ b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_needs-work_userId.test.js @@ -51,7 +51,7 @@ describe('POST /tasks/:id/needs-work/:userId', () => { }); }); - it('marks as task as needing more work', async () => { + it('marks a task as needing more work', async () => { const initialNotifications = member.notifications.length; await user.post(`/tasks/${task._id}/assign/${member._id}`); @@ -77,7 +77,7 @@ describe('POST /tasks/:id/needs-work/:userId', () => { expect(syncedTask.group.approval.requestedDate).to.equal(undefined); // Check that the notification is correct - expect(member.notifications.length).to.equal(initialNotifications + 1); + expect(member.notifications.length).to.equal(initialNotifications + 2); const notification = member.notifications[member.notifications.length - 1]; expect(notification.type).to.equal('GROUP_TASK_NEEDS_WORK'); @@ -131,7 +131,7 @@ describe('POST /tasks/:id/needs-work/:userId', () => { expect(syncedTask.group.approval.requested).to.equal(false); expect(syncedTask.group.approval.requestedDate).to.equal(undefined); - expect(member.notifications.length).to.equal(initialNotifications + 1); + expect(member.notifications.length).to.equal(initialNotifications + 2); const notification = member.notifications[member.notifications.length - 1]; expect(notification.type).to.equal('GROUP_TASK_NEEDS_WORK'); @@ -167,6 +167,17 @@ describe('POST /tasks/:id/needs-work/:userId', () => { }); await member2.post(`/tasks/${task._id}/assign/${member._id}`); + + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + await member2.post(`/tasks/${task._id}/approve/${member._id}`); await expect(user.post(`/tasks/${task._id}/needs-work/${member._id}`)) .to.eventually.be.rejected.and.to.eql({ 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 41f6c2ae2b..43380b64dc 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 @@ -129,6 +129,13 @@ describe('POST /tasks/:id/score/:direction', () => { let memberTasks = await member.get('/tasks/user'); let syncedTask = find(memberTasks, findAssignedTask); + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + await user.post(`/tasks/${task._id}/approve/${member._id}`); await member.post(`/tasks/${syncedTask._id}/score/up`); diff --git a/test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js b/test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js index 852f94bc86..77af1aba29 100644 --- a/test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js +++ b/test/api/v3/integration/tasks/groups/POST-tasks_group_id_assign_user_id.test.js @@ -113,6 +113,17 @@ describe('POST /tasks/:taskId/assign/:memberId', () => { expect(syncedTask).to.exist; }); + it('sends a notification to assigned user', async () => { + await user.post(`/tasks/${task._id}/assign/${member._id}`); + await member.sync(); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + + expect(member.notifications.length).to.equal(1); + expect(member.notifications[0].type).to.equal('GROUP_TASK_ASSIGNED'); + expect(member.notifications[0].taskId).to.equal(groupTask._id); + }); + it('assigns a task to multiple users', async () => { await user.post(`/tasks/${task._id}/assign/${member._id}`); await user.post(`/tasks/${task._id}/assign/${member2._id}`); diff --git a/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js b/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js index 4c82c2e895..a3fbd3c8bb 100644 --- a/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js +++ b/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js @@ -86,6 +86,13 @@ describe('POST /tasks/:taskId/unassign/:memberId', () => { expect(syncedTask).to.not.exist; }); + it('removes task assignment notification from unassigned user', async () => { + await user.post(`/tasks/${task._id}/unassign/${member._id}`); + + await member.sync(); + expect(member.notifications.length).to.equal(0); + }); + it('unassigns a user and only that user from a task', async () => { await user.post(`/tasks/${task._id}/assign/${member2._id}`); diff --git a/website/client/components/group-plans/taskInformation.vue b/website/client/components/group-plans/taskInformation.vue index b6312e0764..c1835d3bb0 100644 --- a/website/client/components/group-plans/taskInformation.vue +++ b/website/client/components/group-plans/taskInformation.vue @@ -16,54 +16,7 @@ h1 {{ $t('groupTasksTitle') }} // @TODO: Abstract to component! .col-12.col-md-4 - .input-group - input.form-control.input-search(type="text", :placeholder="$t('search')", v-model="searchText") - .filter-panel(v-if="isFilterPanelOpen") - .tags-category(v-for="tagsType in tagsByType", v-if="tagsType.tags.length > 0", :key="tagsType.key") - .tags-header.col-12 - strong(v-once) {{ $t(tagsType.key) }} - a.d-block(v-if="tagsType.key === 'tags' && !editingTags", @click="editTags()") {{ $t('editTags2') }} - .tags-list.container.col-12 - .row(:class="{'no-gutters': !editingTags}") - template(v-if="editingTags && tagsType.key === 'tags'") - .col-12.col-md-6(v-for="(tag, tagIndex) in tagsSnap") - .inline-edit-input-group.tag-edit-item.input-group - input.tag-edit-input.inline-edit-input.form-control(type="text", :value="tag.name") - .input-group-append(@click="removeTag(tagIndex)") - .svg-icon.destroy-icon(v-html="icons.destroy") - .col-12.col-md-6 - input.new-tag-item.edit-tag-item.inline-edit-input.form-control(type="text", :placeholder="$t('newTag')", @keydown.enter="addTag($event)", v-model="newTag") - template(v-else) - .col-12.col-md-6(v-for="(tag, tagIndex) in tagsType.tags") - .custom-control.custom-checkbox - input.custom-control-input( - type="checkbox", - :checked="isTagSelected(tag)", - @change="toggleTag(tag)", - :id="`tag-${tagsType.key}-${tagIndex}`", - ) - label.custom-control-label(:for="`tag-${tagsType.key}-${tagIndex}`") {{ tag.name }} - - .filter-panel-footer.clearfix - template(v-if="editingTags === true") - .text-center - a.mr-3.btn-filters-primary(@click="saveTags()", v-once) {{ $t('saveEdits') }} - a.btn-filters-secondary(@click="cancelTagsEditing()", v-once) {{ $t('cancel') }} - template(v-else) - .float-left - a.btn-filters-danger(@click="resetFilters()", v-once) {{ $t('resetFilters') }} - .float-right - a.mr-3.btn-filters-primary(@click="applyFilters()", v-once) {{ $t('applyFilters') }} - a.btn-filters-secondary(@click="closeFilterPanel()", v-once) {{ $t('cancel') }} - span.input-group-append - button.btn.btn-secondary.filter-button( - type="button", - @click="toggleFilterPanel()", - :class="{'filter-button-open': selectedTags.length > 0}", - ) - .d-flex.align-items-center - span(v-once) {{ $t('filter') }} - .svg-icon.filter-icon(v-html="icons.filter") + input.form-control.input-search(type="text", :placeholder="$t('search')", v-model="searchText") .create-task-area.d-flex(v-if='canCreateTasks') transition(name="slide-tasks-btns") .d-flex(v-if="openCreateBtn") @@ -99,10 +52,6 @@ @import '~client/assets/scss/colors.scss'; @import '~client/assets/scss/create-task.scss'; - .user-tasks-page { - padding-top: 31px; - } - .tasks-navigation { margin-bottom: 40px; } @@ -114,133 +63,6 @@ margin-right: 8px; padding-top: 6px; } - - .dropdown-icon-item .svg-icon { - width: 16px; - } - - button.btn.btn-secondary.filter-button { - box-shadow: none; - border-radius: 2px; - border: 1px solid $gray-400 !important; - - &:hover, &:active, &:focus, &.open { - box-shadow: none; - border-color: $purple-500 !important; - color: $gray-50 !important; - } - - &.filter-button-open { - color: $purple-200 !important; - - .filter-icon { - color: $purple-200 !important; - } - } - - .filter-icon { - height: 10px; - width: 12px; - color: $gray-50; - margin-left: 15px; - } - } - - .filter-panel { - position: absolute; - padding-left: 24px; - padding-right: 24px; - max-width: 40vw; - width: 100%; - z-index: 9999; - background: $white; - border-radius: 2px; - box-shadow: 0 2px 2px 0 rgba($black, 0.16), 0 1px 4px 0 rgba($black, 0.12); - top: 44px; - left: 20vw; - font-size: 14px; - line-height: 1.43; - text-overflow: ellipsis; - - .tags-category { - border-bottom: 1px solid $gray-600; - padding-bottom: 24px; - padding-top: 24px; - } - - .tags-header { - flex-basis: 96px; - flex-shrink: 0; - - a { - font-size: 12px; - line-height: 1.33; - color: $blue-10; - margin-top: 4px; - - &:focus, &:hover, &:active { - text-decoration: underline; - } - } - } - - .tag-edit-input { - border-bottom: 1px solid $gray-500 !important; - - &:focus, &:focus ~ .input-group-append { - border-color: $purple-500 !important; - } - } - - .new-tag-item { - width: 100%; - background-repeat: no-repeat; - background-position: center left 10px; - border-bottom: 1px solid $gray-500 !important; - background-size: 10px 10px; - padding-left: 40px; - background-image: url(~client/assets/svg/for-css/positive.svg); - } - - .tag-edit-item .input-group-append { - border-bottom: 1px solid $gray-500 !important; - - &:focus { - border-color: $purple-500; - } - } - - .custom-control-label { - margin-left: 10px; - } - - .filter-panel-footer { - padding-top: 16px; - padding-bottom: 16px; - - a { - &:focus, &:hover, &:active { - text-decoration: underline; - } - } - - .btn-filters-danger { - color: $red-50; - } - - .btn-filters-primary { - color: $blue-10; - } - - .btn-filters-secondary { - color: $gray-300; - } - } - } - - .button-label { - display: inline-block; - } diff --git a/website/client/components/header/notificationsDropdown.vue b/website/client/components/header/notificationsDropdown.vue index 3132f9e9b9..48f7bba6e7 100644 --- a/website/client/components/header/notificationsDropdown.vue +++ b/website/client/components/header/notificationsDropdown.vue @@ -87,6 +87,7 @@ import CHALLENGE_INVITATION from './notifications/challengeInvitation'; import QUEST_INVITATION from './notifications/questInvitation'; import GROUP_TASK_APPROVAL from './notifications/groupTaskApproval'; import GROUP_TASK_APPROVED from './notifications/groupTaskApproved'; +import GROUP_TASK_ASSIGNED from './notifications/groupTaskAssigned'; import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints'; import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems'; import CARD_RECEIVED from './notifications/cardReceived'; @@ -102,7 +103,7 @@ export default { // One component for each type NEW_STUFF, GROUP_TASK_NEEDS_WORK, GUILD_INVITATION, PARTY_INVITATION, CHALLENGE_INVITATION, - QUEST_INVITATION, GROUP_TASK_APPROVAL, GROUP_TASK_APPROVED, + QUEST_INVITATION, GROUP_TASK_APPROVAL, GROUP_TASK_APPROVED, GROUP_TASK_ASSIGNED, UNALLOCATED_STATS_POINTS, NEW_MYSTERY_ITEMS, CARD_RECEIVED, NEW_INBOX_MESSAGE, NEW_CHAT_MESSAGE, WorldBoss: WORLD_BOSS, @@ -118,7 +119,7 @@ export default { openStatus: undefined, actionableNotifications: [ 'GUILD_INVITATION', 'PARTY_INVITATION', 'CHALLENGE_INVITATION', - 'QUEST_INVITATION', 'GROUP_TASK_NEEDS_WORK', + 'QUEST_INVITATION', 'GROUP_TASK_NEEDS_WORK', 'GROUP_TASK_APPROVAL', ], // A list of notifications handled by this component, // listed in the order they should appear in the notifications panel. @@ -126,7 +127,7 @@ export default { handledNotifications: [ 'NEW_STUFF', 'GROUP_TASK_NEEDS_WORK', 'GUILD_INVITATION', 'PARTY_INVITATION', 'CHALLENGE_INVITATION', - 'QUEST_INVITATION', 'GROUP_TASK_APPROVAL', 'GROUP_TASK_APPROVED', + 'QUEST_INVITATION', 'GROUP_TASK_ASSIGNED', 'GROUP_TASK_APPROVAL', 'GROUP_TASK_APPROVED', 'NEW_MYSTERY_ITEMS', 'CARD_RECEIVED', 'NEW_INBOX_MESSAGE', 'NEW_CHAT_MESSAGE', 'UNALLOCATED_STATS_POINTS', 'VERIFY_USERNAME', diff --git a/website/client/components/tasks/taskModal.vue b/website/client/components/tasks/taskModal.vue index 4e9c0aca40..7ef40e3b6d 100644 --- a/website/client/components/tasks/taskModal.vue +++ b/website/client/components/tasks/taskModal.vue @@ -159,6 +159,15 @@ | {{ $t(frequency) }} .option.group-options(v-if='groupId') + .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) }} .form-group.row label.col-12(v-once) {{ $t('assignedTo') }} .col-12.mt-2 @@ -179,23 +188,12 @@ .row button.btn.btn-primary(@click.stop.prevent="showAssignedSelect = !showAssignedSelect") {{$t('close')}} - - .option.group-options(v-if='groupId') .form-group label(v-once) {{ $t('approvalRequired') }} toggle-switch.d-inline-block( :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") @@ -711,7 +709,7 @@ export default { calendar: calendarIcon, }), requiresApproval: false, // We can't set task.group fields so we use this field to toggle - sharedCompletion: 'recurringCompletion', + sharedCompletion: 'singleCompletion', members: [], memberNamesById: {}, assignedMembers: [], @@ -842,7 +840,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'; + if (this.task.group) this.sharedCompletion = this.task.group.sharedCompletion || 'singleCompletion'; } // @TODO: This whole component is mutating a prop and that causes issues. We need to not copy the prop similar to group modals @@ -926,7 +924,6 @@ export default { // 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; @@ -954,6 +951,7 @@ export default { }); }); Promise.all(promises); + this.task.group.assignedUsers = this.assignedMembers; this.$emit('taskCreated', this.task); } else { this.createTask(this.task); diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 034cb51761..08f7866cbc 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -285,7 +285,8 @@ "claim": "Claim", "removeClaim": "Remove Claim", "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription", - "yourTaskHasBeenApproved": "Your task <%= taskText %> has been approved.", + "youHaveBeenAssignedTask": "<%= managerName %> has assigned you the task <%= taskText %>.", + "yourTaskHasBeenApproved": "Your task <%= taskText %> has been approved.", "taskNeedsWork": "<%= managerName %> marked <%= taskText %> as needing additional work.", "userHasRequestedTaskApproval": "<%= user %> requests approval for <%= taskName %>", "approve": "Approve", diff --git a/website/common/locales/en/tasks.json b/website/common/locales/en/tasks.json index cdf0c07044..361be74664 100644 --- a/website/common/locales/en/tasks.json +++ b/website/common/locales/en/tasks.json @@ -173,7 +173,7 @@ "habitCounterDown": "Negative Counter (Resets <%= frequency %>)", "taskRequiresApproval": "This task must be approved before you can complete it. Approval has already been requested", "taskApprovalHasBeenRequested": "Approval has been requested", - "taskApprovalWasNotRequested": "Only a task waiting for approval can be marked as needing more work", + "taskApprovalWasNotRequested": "Approval has not been requested for this task.", "approvals": "Approvals", "approvalRequired": "Needs Approval", "repeatZero": "Daily is never due", diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js index 69f050ad3d..93ce3cb1eb 100644 --- a/website/server/controllers/api-v3/tasks/groups.js +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -206,6 +206,14 @@ api.assignTask = { let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text}); const newMessage = group.sendChat(message); promises.push(newMessage.save()); + } else { + const taskText = task.text; + const managerName = user.profile.name; + + assignedUser.addNotification('GROUP_TASK_ASSIGNED', { + message: res.t('youHaveBeenAssignedTask', {managerName, taskText}), + taskId: task._id, + }); } promises.push(group.syncTask(task, assignedUser)); @@ -261,6 +269,15 @@ api.unassignTask = { await group.unlinkTask(task, assignedUser); + let notificationIndex = assignedUser.notifications.findIndex(function findNotification (notification) { + return notification && notification.data && notification.type === 'GROUP_TASK_ASSIGNED' && notification.data.taskId === task._id; + }); + + if (notificationIndex !== -1) { + assignedUser.notifications.splice(notificationIndex, 1); + await assignedUser.save(); + } + res.respond(200, task); }, }; @@ -308,6 +325,9 @@ api.approveTask = { if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); if (task.group.approval.approved === true) throw new NotAuthorized(res.t('canOnlyApproveTaskOnce')); + if (!task.group.approval.requested) { + throw new NotAuthorized(res.t('taskApprovalWasNotRequested')); + } task.group.approval.dateApproved = new Date(); task.group.approval.approvingUser = user._id; diff --git a/website/server/models/group.js b/website/server/models/group.js index 53f66e9a3a..fee789a878 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -1341,7 +1341,7 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) { matchingTask.group.id = taskToSync.group.id; matchingTask.userId = user._id; matchingTask.group.taskId = taskToSync._id; - user.tasksOrder[`${taskToSync.type}s`].push(matchingTask._id); + user.tasksOrder[`${taskToSync.type}s`].unshift(matchingTask._id); } else { _.merge(matchingTask, syncableAttrs(taskToSync)); // Make sure the task is in user.tasksOrder @@ -1419,9 +1419,29 @@ schema.methods.removeTask = async function groupRemoveTask (task) { $set: {'group.broken': 'TASK_DELETED'}, }, {multi: true}).exec(); + // Get Managers + const managerIds = Object.keys(group.managers); + managerIds.push(group.leader); + const managers = await User.find({_id: managerIds}, 'notifications').exec(); // Use this method so we can get access to notifications + + // Remove old notifications + let removalPromises = []; + managers.forEach((manager) => { + let notificationIndex = manager.notifications.findIndex(function findNotification (notification) { + return notification && notification.data && notification.data.groupTaskId === task._id && notification.type === 'GROUP_TASK_APPROVAL'; + }); + + if (notificationIndex !== -1) { + manager.notifications.splice(notificationIndex, 1); + removalPromises.push(manager.save()); + } + }); + removeFromArray(group.tasksOrder[`${task.type}s`], task._id); group.markModified('tasksOrder'); - return await group.save(); + removalPromises.push(group.save()); + + return await Promise.all(removalPromises); }; // Returns true if the user has reached the spam message limit diff --git a/website/server/models/task.js b/website/server/models/task.js index 1858cd9623..db7a05e867 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -113,7 +113,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}, + sharedCompletion: {$type: String, enum: _.values(SHARED_COMPLETION), default: SHARED_COMPLETION.single}, }, reminders: [{ diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js index acb280b16a..79075f7d7f 100644 --- a/website/server/models/userNotification.js +++ b/website/server/models/userNotification.js @@ -15,6 +15,7 @@ const NOTIFICATION_TYPES = [ 'CRON', 'GROUP_TASK_APPROVAL', 'GROUP_TASK_APPROVED', + 'GROUP_TASK_ASSIGNED', 'GROUP_TASK_NEEDS_WORK', 'LOGIN_INCENTIVE', 'GROUP_INVITE_ACCEPTED',