diff --git a/test/api/v3/integration/groups/POST-group_remove_manager.test.js b/test/api/v3/integration/groups/POST-group_remove_manager.test.js new file mode 100644 index 0000000000..1a1c412a90 --- /dev/null +++ b/test/api/v3/integration/groups/POST-group_remove_manager.test.js @@ -0,0 +1,93 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { find } from 'lodash'; + +describe('POST /group/:groupId/remove-manager', () => { + let leader, nonLeader, groupToUpdate; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; + let nonManager; + + function findAssignedTask (memberTask) { + return memberTask.group.id === groupToUpdate._id; + } + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: groupName, + type: groupType, + privacy: 'public', + }, + members: 1, + }); + + groupToUpdate = group; + leader = groupLeader; + nonLeader = members[0]; + nonManager = members[0]; + }); + + it('returns an error when a non group leader tries to add member', async () => { + await expect(nonLeader.post(`/groups/${groupToUpdate._id}/remove-manager`, { + managerId: nonLeader._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupOnlyLeaderCanUpdate'), + }); + }); + + it('returns an error when manager does not exist', async () => { + await expect(leader.post(`/groups/${groupToUpdate._id}/remove-manager`, { + managerId: nonManager._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userIsNotManager'), + }); + }); + + it('allows a leader to remove managers', async () => { + await leader.post(`/groups/${groupToUpdate._id}/add-manager`, { + managerId: nonLeader._id, + }); + + let updatedGroup = await leader.post(`/groups/${groupToUpdate._id}/remove-manager`, { + managerId: nonLeader._id, + }); + + expect(updatedGroup.managers[nonLeader._id]).to.not.exist; + }); + + it('removes group approval notifications from a manager that is removed', async () => { + await leader.post(`/groups/${groupToUpdate._id}/add-manager`, { + managerId: nonLeader._id, + }); + let task = await leader.post(`/tasks/group/${groupToUpdate._id}`, { + text: 'test todo', + type: 'todo', + requiresApproval: true, + }); + await nonLeader.post(`/tasks/${task._id}/assign/${leader._id}`); + let memberTasks = await leader.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + await expect(leader.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskApprovalHasBeenRequested'), + }); + + let updatedGroup = await leader.post(`/groups/${groupToUpdate._id}/remove-manager`, { + managerId: nonLeader._id, + }); + + await nonLeader.sync(); + + expect(nonLeader.notifications.length).to.equal(0); + expect(updatedGroup.managers[nonLeader._id]).to.not.exist; + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_manager.test.js b/test/api/v3/integration/groups/POST-groups_manager.test.js new file mode 100644 index 0000000000..b6b90f7ceb --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_manager.test.js @@ -0,0 +1,85 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /group/:groupId/add-manager', () => { + let leader, nonLeader, groupToUpdate; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; + let nonMember; + + context('Guilds', () => { + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: groupName, + type: groupType, + privacy: 'public', + }, + members: 1, + }); + + groupToUpdate = group; + leader = groupLeader; + nonLeader = members[0]; + nonMember = await generateUser(); + }); + + it('returns an error when a non group leader tries to add member', async () => { + await expect(nonLeader.post(`/groups/${groupToUpdate._id}/add-manager`, { + managerId: nonLeader._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupOnlyLeaderCanUpdate'), + }); + }); + + it('returns an error when trying to promote a non member', async () => { + await expect(leader.post(`/groups/${groupToUpdate._id}/add-manager`, { + managerId: nonMember._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userMustBeMember'), + }); + }); + + it('allows a leader to add managers', async () => { + let updatedGroup = await leader.post(`/groups/${groupToUpdate._id}/add-manager`, { + managerId: nonLeader._id, + }); + + expect(updatedGroup.managers[nonLeader._id]).to.be.true; + }); + }); + + context('Party', () => { + let party, partyLeader, partyNonLeader; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: groupName, + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + party = group; + partyLeader = groupLeader; + partyNonLeader = members[0]; + }); + + it('allows leader of party to add managers', async () => { + let updatedGroup = await partyLeader.post(`/groups/${party._id}/add-manager`, { + managerId: partyNonLeader._id, + }); + + expect(updatedGroup.managers[partyNonLeader._id]).to.be.true; + }); + }); +}); 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 40b5f5bded..346760bd32 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 @@ -4,7 +4,7 @@ import { } from '../../../../../helpers/api-integration/v3'; import { find } from 'lodash'; -describe('DELETE /tasks/:id', () => { +describe('Groups DELETE /tasks/:id', () => { let user, guild, member, member2, task; function findAssignedTask (memberTask) { @@ -48,6 +48,21 @@ describe('DELETE /tasks/:id', () => { }); }); + it('allows a manager to delete a group task', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + + await member2.del(`/tasks/${task._id}`); + + await expect(user.get(`/tasks/${task._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + it('unlinks assigned user', async () => { await user.del(`/tasks/${task._id}`); diff --git a/test/api/v3/integration/tasks/groups/GET-approvals_group_id.test.js b/test/api/v3/integration/tasks/groups/GET-approvals_group_id.test.js index eeeec85b37..edef9c6502 100644 --- a/test/api/v3/integration/tasks/groups/GET-approvals_group_id.test.js +++ b/test/api/v3/integration/tasks/groups/GET-approvals_group_id.test.js @@ -55,4 +55,13 @@ describe('GET /approvals/group/:groupId', () => { let approvals = await user.get(`/approvals/group/${guild._id}`); expect(approvals[0]._id).to.equal(syncedTask._id); }); + + it('allows managers to get a list of task that need approval', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member._id, + }); + + let approvals = await member.get(`/approvals/group/${guild._id}`); + expect(approvals[0]._id).to.equal(syncedTask._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 bdeb33be31..0805255502 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 @@ -5,7 +5,7 @@ import { import { find } from 'lodash'; describe('POST /tasks/:id/approve/:userId', () => { - let user, guild, member, task; + let user, guild, member, member2, task; function findAssignedTask (memberTask) { return memberTask.group.id === guild._id; @@ -17,12 +17,13 @@ describe('POST /tasks/:id/approve/:userId', () => { name: 'Test Guild', type: 'guild', }, - members: 1, + members: 2, }); guild = group; user = groupLeader; member = members[0]; + member2 = members[1]; task = await user.post(`/tasks/group/${guild._id}`, { text: 'test todo', @@ -69,4 +70,74 @@ describe('POST /tasks/:id/approve/:userId', () => { expect(syncedTask.group.approval.approvingUser).to.equal(user._id); expect(syncedTask.group.approval.dateApproved).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type }); + + it('allows a manager to approve an assigned user', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + + 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 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[1].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text})); + + expect(syncedTask.group.approval.approved).to.be.true; + expect(syncedTask.group.approval.approvingUser).to.equal(member2._id); + expect(syncedTask.group.approval.dateApproved).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type + }); + + it('removes approval pending notifications from managers', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + + 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 user.sync(); + await member2.sync(); + expect(user.notifications.length).to.equal(1); + expect(user.notifications[0].type).to.equal('GROUP_TASK_APPROVAL'); + expect(member2.notifications.length).to.equal(1); + expect(member2.notifications[0].type).to.equal('GROUP_TASK_APPROVAL'); + + await member2.post(`/tasks/${task._id}/approve/${member._id}`); + + await user.sync(); + await member2.sync(); + + expect(user.notifications.length).to.equal(0); + expect(member2.notifications.length).to.equal(0); + }); + + it('prevents double approval on a task', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + + await member2.post(`/tasks/${task._id}/assign/${member._id}`); + 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({ + code: 401, + error: 'NotAuthorized', + message: t('canOnlyApproveTaskOnce'), + }); + }); }); 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 8ac329b1b9..6d24f9c0a4 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 @@ -5,7 +5,7 @@ import { import { find } from 'lodash'; describe('POST /tasks/:id/score/:direction', () => { - let user, guild, member, task; + let user, guild, member, member2, task; function findAssignedTask (memberTask) { return memberTask.group.id === guild._id; @@ -17,12 +17,13 @@ describe('POST /tasks/:id/score/:direction', () => { name: 'Test Guild', type: 'guild', }, - members: 1, + members: 2, }); guild = group; user = groupLeader; member = members[0]; + member2 = members[1]; task = await user.post(`/tasks/group/${guild._id}`, { text: 'test todo', @@ -56,6 +57,7 @@ describe('POST /tasks/:id/score/:direction', () => { expect(user.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', { user: member.auth.local.username, taskName: updatedTask.text, + taskId: updatedTask._id, }, 'cs')); // This test only works if we have the notification translated expect(user.notifications[0].data.groupId).to.equal(guild._id); @@ -63,6 +65,42 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedTask.group.approval.requestedDate).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type }); + it('sends notifications to all managers', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: 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 updatedTask = await member.get(`/tasks/${syncedTask._id}`); + await user.sync(); + await member2.sync(); + + expect(user.notifications.length).to.equal(1); + expect(user.notifications[0].type).to.equal('GROUP_TASK_APPROVAL'); + expect(user.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', { + user: member.auth.local.username, + taskName: updatedTask.text, + taskId: updatedTask._id, + })); + expect(user.notifications[0].data.groupId).to.equal(guild._id); + + expect(member2.notifications.length).to.equal(1); + expect(member2.notifications[0].type).to.equal('GROUP_TASK_APPROVAL'); + expect(member2.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', { + user: member.auth.local.username, + taskName: updatedTask.text, + taskId: updatedTask._id, + })); + expect(member2.notifications[0].data.groupId).to.equal(guild._id); + }); + it('errors when approval has already been requested', async () => { let memberTasks = await member.get('/tasks/user'); let syncedTask = find(memberTasks, findAssignedTask); diff --git a/test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js b/test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js index 382448cbf2..5b38eec50f 100644 --- a/test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js +++ b/test/api/v3/integration/tasks/groups/POST-tasks_group_id.test.js @@ -1,16 +1,29 @@ import { generateUser, - generateGroup, + createAndPopulateGroup, translate as t, } from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/group/:groupid', () => { - let user, guild; + let user, guild, manager; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; beforeEach(async () => { user = await generateUser({balance: 1}); - guild = await generateGroup(user, {type: 'guild'}); + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: groupName, + type: groupType, + privacy: 'private', + }, + members: 1, + }); + + guild = group; + user = groupLeader; + manager = members[0]; }); it('returns error when group is not found', async () => { @@ -116,4 +129,27 @@ describe('POST /tasks/group/:groupid', () => { expect(task.everyX).to.eql(5); expect(new Date(task.startDate)).to.eql(now); }); + + it('allows a manager to add a group task', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: manager._id, + }); + + let task = await manager.post(`/tasks/group/${guild._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + let groupTask = await manager.get(`/tasks/group/${guild._id}`); + + expect(groupTask[0].group.id).to.equal(guild._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + }); }); 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 26a58fa668..22029de3e8 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 @@ -6,7 +6,7 @@ import { import { v4 as generateUUID } from 'uuid'; import { find } from 'lodash'; -describe('POST /tasks/:taskId', () => { +describe('POST /tasks/:taskId/assign/:memberId', () => { let user, guild, member, member2, task; function findAssignedTask (memberTask) { @@ -130,4 +130,19 @@ describe('POST /tasks/:taskId', () => { expect(member1SyncedTask).to.exist; expect(member2SyncedTask).to.exist; }); + + it('allows a manager to assign tasks', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + + await member2.post(`/tasks/${task._id}/assign/${member._id}`); + + let groupTask = await member2.get(`/tasks/group/${guild._id}`); + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(groupTask[0].group.assignedUsers).to.contain(member._id); + expect(syncedTask).to.exist; + }); }); 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 99009a8a8c..50bcd46b1f 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 @@ -114,4 +114,19 @@ describe('POST /tasks/:taskId/unassign/:memberId', () => { expect(groupTask[0].group.assignedUsers).to.contain(member2._id); expect(member2SyncedTask).to.exist; }); + + it('allows a manager to unassign a user from a task', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + + await member2.post(`/tasks/${task._id}/unassign/${member._id}`); + + let groupTask = await member2.get(`/tasks/group/${guild._id}`); + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(groupTask[0].group.assignedUsers).to.not.contain(member._id); + expect(syncedTask).to.not.exist; + }); }); diff --git a/test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js b/test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js index de67b4d3bd..3ce0524866 100644 --- a/test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js +++ b/test/api/v3/integration/tasks/groups/PUT-group_task_id.test.js @@ -89,4 +89,25 @@ describe('PUT /tasks/:id', () => { expect(member2SyncedTask.up).to.eql(false); expect(member2SyncedTask.down).to.eql(false); }); + + it('updates the linked tasks', async () => { + await user.post(`/groups/${guild._id}/add-manager`, { + managerId: member2._id, + }); + + await member2.put(`/tasks/${task._id}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + }); + + + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(syncedTask.text).to.eql('some new text'); + expect(syncedTask.up).to.eql(false); + expect(syncedTask.down).to.eql(false); + }); }); diff --git a/website/client-old/css/groups.styl b/website/client-old/css/groups.styl index 20cee8e5e5..dfafd96d35 100644 --- a/website/client-old/css/groups.styl +++ b/website/client-old/css/groups.styl @@ -215,3 +215,6 @@ group-members-autocomplete background #ff6633 color #fff cursor pointer + +.add-manager-button, .remove-manager-button + margin-left: 1rem; diff --git a/website/client-old/js/components/groupTasks/groupTasksController.js b/website/client-old/js/components/groupTasks/groupTasksController.js index c8e03ab5f5..5af2864f49 100644 --- a/website/client-old/js/components/groupTasks/groupTasksController.js +++ b/website/client-old/js/components/groupTasks/groupTasksController.js @@ -160,8 +160,10 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', '$ro $scope.checkGroupAccess = function (group) { if (!group || !group.leader) return true; - if (User.user._id !== group.leader._id) return false; - return true; + var userId = User.user._id; + var leader = group.leader._id === userId; + var isManager = Boolean(group.managers[userId]); + return leader || isManager; }; /* diff --git a/website/client-old/js/controllers/groupsCtrl.js b/website/client-old/js/controllers/groupsCtrl.js index 26870b09bc..fa3cc0acb9 100644 --- a/website/client-old/js/controllers/groupsCtrl.js +++ b/website/client-old/js/controllers/groupsCtrl.js @@ -128,5 +128,37 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' .then(function (response) { $rootScope.openModal('private-message', {controller: 'MemberModalCtrl'}); }); + }; + + $scope.memberProfileName = function (memberId) { + var member = _.find($scope.groupCopy.members, function (member) { return member._id === memberId; }); + return member.profile.name; + }; + + $scope.addManager = function () { + Groups.Group.addManager($scope.groupCopy._id, $scope.groupCopy._newManager) + .then(function (response) { + $scope.groupCopy._newManager = ''; + $scope.groupCopy.managers = response.data.data.managers; + }); + }; + + $scope.removeManager = function (memberId) { + Groups.Group.removeManager($scope.groupCopy._id, memberId) + .then(function (response) { + $scope.groupCopy._newManager = ''; + $scope.groupCopy.managers = response.data.data.managers; + }); + }; + + $scope.isManager = function (memberId, group) { + return Boolean(group.managers[memberId]); } + + $scope.userCanApprove = function (userId, group) { + if (!group) return false; + var leader = group.leader._id === userId; + var userIsManager = !!group.managers[userId]; + return leader || userIsManager; + }; }]); diff --git a/website/client-old/js/controllers/menuCtrl.js b/website/client-old/js/controllers/menuCtrl.js index cd639a7187..5df3c43942 100644 --- a/website/client-old/js/controllers/menuCtrl.js +++ b/website/client-old/js/controllers/menuCtrl.js @@ -116,10 +116,10 @@ angular.module('habitrpg') return selectNotificationValue(false, false, false, false, false, true, false, false); }; - $scope.viewGroupApprovalNotification = function (notification, $index) { + $scope.viewGroupApprovalNotification = function (notification, $index, navigate) { User.readNotification(notification.id); User.user.groupNotifications.splice($index, 1); - $state.go("options.social.guilds.detail", {gid: notification.data.groupId}); + if (navigate) $state.go("options.social.guilds.detail", {gid: notification.data.groupId}); }; $scope.groupApprovalNotificationIcon = function (notification) { diff --git a/website/client-old/js/controllers/notificationCtrl.js b/website/client-old/js/controllers/notificationCtrl.js index 831b8f69e4..d379864412 100644 --- a/website/client-old/js/controllers/notificationCtrl.js +++ b/website/client-old/js/controllers/notificationCtrl.js @@ -89,14 +89,20 @@ habitrpg.controller('NotificationCtrl', var notificationsToRead = []; var scoreTaskNotification; + User.user.groupNotifications = []; // Flush group notifictions + after.forEach(function (notification) { if (lastShownNotifications.indexOf(notification.id) !== -1) { return; } - lastShownNotifications.push(notification.id); - if (lastShownNotifications.length > 10) { - lastShownNotifications.splice(0, 9); + // Some notifications are not marked read here, so we need to fix this system + // to handle notifications differently + if (['GROUP_TASK_APPROVED', 'GROUP_TASK_APPROVAL'].indexOf(notification.type) === -1) { + lastShownNotifications.push(notification.id); + if (lastShownNotifications.length > 10) { + lastShownNotifications.splice(0, 9); + } } var markAsRead = true; diff --git a/website/client-old/js/services/groupServices.js b/website/client-old/js/services/groupServices.js index 88883f5900..d3ccfb0d2e 100644 --- a/website/client-old/js/services/groupServices.js +++ b/website/client-old/js/services/groupServices.js @@ -108,6 +108,26 @@ angular.module('habitrpg') }); }; + Group.addManager = function(gid, memberId) { + return $http({ + method: "POST", + url: groupApiURLPrefix + '/' + gid + '/add-manager/', + data: { + managerId: memberId, + }, + }); + }; + + Group.removeManager = function(gid, memberId) { + return $http({ + method: "POST", + url: groupApiURLPrefix + '/' + gid + '/remove-manager/', + data: { + managerId: memberId, + }, + }); + }; + $rootScope.$on('syncPartyRequest', function (event, options) { if (options.type === 'user_update') { var index = _.findIndex(data.party.members, function(user) { return user._id === options.user._id; }); @@ -221,7 +241,7 @@ angular.module('habitrpg') function inviteOrStartParty (group) { Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Invite Friends'}); - + var sendInviteText = window.env.t('sendInvitations'); if (group.type !== 'party' && group.type !== 'guild') { $location.path("/options/groups/party"); diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index b2a095c277..b20c9ff9b0 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -272,5 +272,13 @@ "confirmCancelGroupPlan": "Are you sure you want to cancel the group plan and remove its benefits from all members, including their free subscriptions?", "canceledGroupPlan": "Canceled Group Plan", "groupPlanCanceled": "Group Plan will become inactive on", - "purchasedGroupPlanPlanExtraMonths": "You have <%= months %> months of extra group plan credit." + "purchasedGroupPlanPlanExtraMonths": "You have <%= months %> months of extra group plan credit.", + "addManagers": "Add Managers", + "addManager": "Add Manager", + "removeManager": "Remove", + "userMustBeMember": "User must be a member", + "userIsNotManager": "User is not manager", + "canOnlyApproveTaskOnce": "This task has already been approved.", + "leaderMarker": " - Leader", + "managerMarker": " - Manager" } diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 6641d07bec..855175e74b 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -1095,4 +1095,108 @@ api.inviteToGroup = { }, }; +/** + * @api {post} /api/v3/groups/:groupId/add-manager Add a manager to a group + * @apiName AddGroupManager + * @apiGroup Group + * + * @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * + * @apiParamExample {String} party: + * /api/v3/groups/party/add-manager + * + * @apiBody (Body) {UUID} managerId The user _id of the member to promote to manager + * + * @apiSuccess {Object} data An empty object + * + * @apiError (400) {NotAuthorized} managerId req.body.managerId is required + * @apiUse groupIdRequired + */ +api.addGroupManager = { + method: 'POST', + url: '/groups/:groupId/add-manager', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + let managerId = req.body.managerId; + + req.checkParams('groupId', apiMessages('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party' + req.checkBody('managerId', apiMessages('managerIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let newManager = await User.findById(managerId, 'guilds party').exec(); + let groupFields = basicGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: groupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); + + let isMember = group.isMember(newManager); + if (!isMember) throw new NotAuthorized(res.t('userMustBeMember')); + + group.managers[managerId] = true; + group.markModified('managers'); + await group.save(); + + res.respond(200, group); + }, +}; + +/** + * @api {post} /api/v3/groups/:groupId/remove-manager Remove a manager from a group + * @apiName RemoveGroupManager + * @apiGroup Group + * + * @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * + * @apiParamExample {String} party: + * /api/v3/groups/party/add-manager + * + * @apiBody (Body) {UUID} managerId The user _id of the member to remove + * + * @apiSuccess {Object} group The group + * + * @apiError (400) {NotAuthorized} managerId req.body.managerId is required + * @apiUse groupIdRequired + */ +api.removeGroupManager = { + method: 'POST', + url: '/groups/:groupId/remove-manager', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + let managerId = req.body.managerId; + + req.checkParams('groupId', apiMessages('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party' + req.checkBody('managerId', apiMessages('managerIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let groupFields = basicGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: groupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); + + if (!group.managers[managerId]) throw new NotAuthorized(res.t('userIsNotManager')); + + delete group.managers[managerId]; + group.markModified('managers'); + await group.save(); + + let manager = await User.findById(managerId, 'notifications').exec(); + let newNotifications = manager.notifications.filter((notification) => { + return notification.type !== 'GROUP_TASK_APPROVAL'; + }); + manager.notifications = newNotifications; + manager.markModified('notifications'); + await manager.save(); + + res.respond(200, group); + }, +}; + module.exports = api; diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 4e710ac827..ff7d90505a 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -25,6 +25,13 @@ import logger from '../../libs/logger'; const MAX_SCORE_NOTES_LENGTH = 256; +function canNotEditTasks (group, user, assignedUserId) { + let isNotGroupLeader = group.leader !== user._id; + let isManager = Boolean(group.managers[user._id]); + let userIsAssigningToSelf = Boolean(assignedUserId && user._id === assignedUserId); + return isNotGroupLeader && !isManager && !userIsAssigningToSelf; +} + /** * @apiDefine TaskNotFound * @apiError (404) {NotFound} TaskNotFound The specified task could not be found. @@ -413,9 +420,10 @@ api.updateTask = { throw new NotFound(res.t('taskNotFound')); } else if (task.group.id && !task.userId) { // @TODO: Abstract this access snippet - group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + group = await Group.getGroup({user, groupId: task.group.id, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); @@ -530,18 +538,29 @@ api.scoreTask = { task.group.approval.requested = true; task.group.approval.requestedDate = new Date(); - let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); - let groupLeader = await User.findById(group.leader).exec(); // Use this method so we can get access to notifications + let fields = requiredGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId: task.group.id, fields}); - groupLeader.addNotification('GROUP_TASK_APPROVAL', { - message: res.t('userHasRequestedTaskApproval', { - user: user.profile.name, - taskName: task.text, - }, groupLeader.preferences.language), - groupId: group._id, + // @TODO: we can use the User.pushNotification function because we need to ensure notifications are translated + let managerIds = Object.keys(group.managers); + managerIds.push(group.leader); + let managers = await User.find({_id: managerIds}, 'notifications preferences').exec(); // Use this method so we can get access to notifications + + let managerPromises = []; + managers.forEach((manager) => { + manager.addNotification('GROUP_TASK_APPROVAL', { + message: res.t('userHasRequestedTaskApproval', { + user: user.profile.name, + taskName: task.text, + }, manager.preferences.language), + groupId: group._id, + taskId: task._id, + }); + managerPromises.push(manager.save()); }); - await Bluebird.all([groupLeader.save(), task.save()]); + managerPromises.push(task.save()); + await Bluebird.all(managerPromises); throw new NotAuthorized(res.t('taskApprovalHasBeenRequested')); } @@ -694,9 +713,9 @@ api.addChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (task.group.id && !task.userId) { - group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + let fields = requiredGroupFields.concat(' managers'); + group = await Group.getGroup({user, groupId: task.group.id, fields}); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); @@ -803,9 +822,10 @@ api.updateChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (task.group.id && !task.userId) { - group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + group = await Group.getGroup({user, groupId: task.group.id, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); @@ -867,9 +887,10 @@ api.removeChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (task.group.id && !task.userId) { - group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + group = await Group.getGroup({user, groupId: task.group.id, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); @@ -1185,9 +1206,10 @@ api.deleteTask = { throw new NotFound(res.t('taskNotFound')); } else if (task.group.id && !task.userId) { // @TODO: Abstract this access snippet - let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId: task.group.id, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); await group.removeTask(task); } else if (task.challenge.id && !task.userId) { // If the task belongs to a challenge make sure the user has rights challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js index 9ae8e57d36..f71df25c93 100644 --- a/website/server/controllers/api-v3/tasks/groups.js +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -1,3 +1,4 @@ +import findIndex from 'lodash/findIndex'; import { authWithHeaders } from '../../../middlewares/auth'; import Bluebird from 'bluebird'; import * as Tasks from '../../../models/task'; @@ -16,6 +17,14 @@ import { let requiredGroupFields = '_id leader tasksOrder name'; let types = Tasks.tasksTypes.map(type => `${type}s`); + +function canNotEditTasks (group, user, assignedUserId) { + let isNotGroupLeader = group.leader !== user._id; + let isManager = Boolean(group.managers[user._id]); + let userIsAssigningToSelf = Boolean(assignedUserId && user._id === assignedUserId); + return isNotGroupLeader && !isManager && !userIsAssigningToSelf; +} + let api = {}; /** @@ -40,10 +49,11 @@ api.createGroupTasks = { let user = res.locals.user; - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId: req.params.groupId, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); let tasks = await createTasks(req, res, {user, group}); @@ -171,11 +181,11 @@ api.assignTask = { throw new NotAuthorized(res.t('onlyGroupTasksCanBeAssigned')); } - let groupFields = `${requiredGroupFields} chat`; + let groupFields = `${requiredGroupFields} chat managers`; let group = await Group.getGroup({user, groupId: task.group.id, fields: groupFields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id && user._id !== assignedUserId) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user, assignedUserId)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); // User is claiming the task if (user._id === assignedUserId) { @@ -229,10 +239,11 @@ api.unassignTask = { throw new NotAuthorized(res.t('onlyGroupTasksCanBeAssigned')); } - let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId: task.group.id, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); await group.unlinkTask(task, assignedUser); @@ -277,10 +288,12 @@ api.approveTask = { throw new NotFound(res.t('taskNotFound')); } - let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId: task.group.id, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (task.group.approval.approved === true) throw new NotAuthorized(res.t('canOnlyApproveTaskOnce')); task.group.approval.dateApproved = new Date(); task.group.approval.approvingUser = user._id; @@ -296,7 +309,25 @@ api.approveTask = { scoreTask: task, }); - await Bluebird.all([assignedUser.save(), task.save()]); + let managerIds = Object.keys(group.managers); + managerIds.push(group.leader); + let managers = await User.find({_id: managerIds}, 'notifications').exec(); // Use this method so we can get access to notifications + + let managerPromises = []; + managers.forEach((manager) => { + let notificationIndex = findIndex(manager.notifications, function findNotification (notification) { + return notification.data.taskId === task._id; + }); + + if (notificationIndex !== -1) { + manager.notifications.splice(notificationIndex, 1); + managerPromises.push(manager.save()); + } + }); + + managerPromises.push(task.save()); + managerPromises.push(assignedUser.save()); + await Bluebird.all(managerPromises); res.respond(200, task); }, @@ -325,10 +356,11 @@ api.getGroupApprovals = { let user = res.locals.user; let groupId = req.params.groupId; - let group = await Group.getGroup({user, groupId, fields: requiredGroupFields}); + let fields = requiredGroupFields.concat(' managers'); + let group = await Group.getGroup({user, groupId, fields}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); let approvals = await Tasks.Task.find({ 'group.id': groupId, diff --git a/website/server/libs/apiMessages.js b/website/server/libs/apiMessages.js index 7770c87dba..77f2215263 100644 --- a/website/server/libs/apiMessages.js +++ b/website/server/libs/apiMessages.js @@ -8,6 +8,8 @@ const messages = { guildsOnlyPaginate: 'Only public guilds support pagination.', guildsPaginateBooleanString: 'req.query.paginate must be a boolean string.', guildsPageInteger: 'req.query.page must be an integer greater than or equal to 0.', + groupIdRequired: 'req.params.groupId must contain a groupId.', + managerIdRequired: 'req.body.managerId must contain a user ID.', }; export default function (msgKey, vars = {}) { @@ -18,4 +20,4 @@ export default function (msgKey, vars = {}) { // TODO cache the result of template() ? More memory usage, faster output return _.template(message)(clonedVars); -} \ No newline at end of file +} diff --git a/website/server/models/group.js b/website/server/models/group.js index 2675424d0e..36b2090b09 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -105,13 +105,16 @@ export let schema = new Schema({ return {}; }}, }, + managers: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, }, { strict: true, minimize: false, // So empty objects are returned }); schema.plugin(baseModel, { - noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount', 'tasksOrder', 'purchased'], + noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount', 'tasksOrder', 'purchased', 'managers'], private: ['purchased.plan'], toJSONTransform (plainObj, originalDoc) { if (plainObj.purchased) plainObj.purchased.active = originalDoc.isSubscribed(); diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade index fc2e82398b..db1929121d 100644 --- a/website/views/options/social/group.jade +++ b/website/views/options/social/group.jade @@ -14,7 +14,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter a(ng-click="groupPanel = 'chat'")=env.t('groupHomeTitle') li(ng-show='group.purchased.active') a(ng-click="groupPanel = 'tasks'")=env.t('groupTasksTitle') - li(ng-show='group.purchased.active && group.leader._id === user._id') + li(ng-show='group.purchased.active && userCanApprove(user._id, group)') a(ng-click="groupPanel = 'approvals'")=env.t('approvalsTitle') li a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && group.purchased.plan.customerId')=env.t('paymentDetails') @@ -65,6 +65,17 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter h4=env.t('assignLeader') select#group-leader-selection(ng-model='groupCopy._newLeader', ng-options='member.profile.name for member in group.members') + div(ng-if='group.purchased.active') + h4=env.t('addManagers') + .form-group + select#group-leader-selection(ng-model='groupCopy._newManager') + option(ng-repeat='member in group.members', ng-if='member._id !== group.leader.id', ng-value='member._id') {{member.profile.name}} + button.btn.btn-primary.add-manager-button(ng-click='addManager()')=env.t('addManager') + ul + li(ng-repeat='(managerId, value) in groupCopy.managers') + | {{memberProfileName(managerId)}} + button.btn.btn-warning.remove-manager-button(ng-click='removeManager(managerId)')=env.t('removeManager') + div(ng-show='!group._editing') img.img-rendering-auto.pull-right(ng-show='group.logo', ng-src='{{group.logo}}') markdown(text='group.description') @@ -105,6 +116,10 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter | {{member.profile.name}} span(ng-click='clickMember(member._id, true)' ng-if='group.type === "party"') | (#[strong {{member.stats.hp.toFixed(1)}}] #{env.t('hp')}) {{member.id === user.id ? ' ' + env.t('you') : ''}} + span(ng-if='group.leader._id === member.id') + | {{env.t('leaderMarker')}} + span(ng-show='isManager(member._id, group)') + | {{env.t('managerMarker')}} .pull-right(ng-if='group.type === "party"') span.text-success {{member.online ? '● ' + env.t('online') : ''}} tr(ng-if='::group.memberCount > group.members.length') @@ -178,6 +193,6 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter group-tasks(ng-show="groupPanel == 'tasks'") - group-approvals(ng-show="groupPanel == 'approvals'", ng-if="group.leader._id === user._id", group="group") + group-approvals(ng-show="groupPanel == 'approvals'", ng-if="userCanApprove(user._id, group)", group="group") +groupSubscription diff --git a/website/views/options/social/groups/group-tasks-actions.jade b/website/views/options/social/groups/group-tasks-actions.jade index 38d256abd4..9a30743386 100644 --- a/website/views/options/social/groups/group-tasks-actions.jade +++ b/website/views/options/social/groups/group-tasks-actions.jade @@ -1,5 +1,5 @@ script(type='text/ng-template', id='partials/groups.tasks.actions.html') - div(ng-if="group.leader._id === user._id", class="col-md-12") + div(ng-if="group.leader._id === user._id || group.managers[user._id]", class="col-md-12") strong=env.t('assignTask') group-members-autocomplete(ng-model="assignedMembers") diff --git a/website/views/shared/header/menu.jade b/website/views/shared/header/menu.jade index fcda892473..5b501c2684 100644 --- a/website/views/shared/header/menu.jade +++ b/website/views/shared/header/menu.jade @@ -223,10 +223,16 @@ nav.toolbar(ng-controller='MenuCtrl') a(ng-click='clearMessages(k)', popover=env.t('clear'),popover-placement='right',popover-trigger='mouseenter',popover-append-to-body='true') span.glyphicon.glyphicon-remove-circle li(ng-repeat='notification in user.groupNotifications') - a(ng-click='viewGroupApprovalNotification(notification, $index)', data-close-menu) + a(ng-click='viewGroupApprovalNotification(notification, $index, true)', data-close-menu) span(class="{{::groupApprovalNotificationIcon(notification)}}") span | {{notification.data.message}} + a(ng-click='viewGroupApprovalNotification(notification, $index)', + popover=env.t('clear'), + popover-placement='right', + popover-trigger='mouseenter', + popover-append-to-body='true') + span.glyphicon.glyphicon-remove-circle ul.toolbar-controls li.toolbar-controls-button