Group managers (#8591)

* Added abiltiy to add group managers

* Added ability to remove managers

* Added ability for managers to add group tasks

* Allower managers to assign tasks

* Allowed managers to unassign tasks

* Allow managers to delete group tasks

* Allowed managers to approve

* Added initial ui

* Added approval view for managers

* Allowed managers to edit

* Fixed lint issues

* Added spacing to buttons

* Removed leader from selection of group managers

* Code review updates

* Ensured approvals are only done once

* Added ability for parties to add managers

* Add notifications to all managers when approval is requests

* Removed tasks need approval notifications from all managers when task is approve

* Fixed linting issues

* Hid add managers UI from groups that are not subscribed

* Removed let from front end

* Fixed issues with post task url params

* Fixed string locales

* Removed extra limited strings

* Added cannotedit tasks function

* Added limit fields and notification check by taskId

* Localized string and other minor issues

* Added manager and leader indicator

* Added group notifications refresh on sync

* Added close button for group notifications

* Removed group approval notifications when manager is removed

* Moved leader/manager indicators to after hp

* Added manager fields to groups

* Spelling and syntax fixes
This commit is contained in:
Keith Holliday
2017-04-25 08:28:56 -06:00
committed by GitHub
parent 369702884a
commit e2f4b0e3dc
25 changed files with 708 additions and 55 deletions

View File

@@ -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;
});
});

View File

@@ -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;
});
});
});

View File

@@ -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}`);

View File

@@ -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);
});
});

View File

@@ -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'),
});
});
});

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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;
});
});

View File

@@ -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;
});
});

View File

@@ -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);
});
});

View File

@@ -215,3 +215,6 @@ group-members-autocomplete
background #ff6633
color #fff
cursor pointer
.add-manager-button, .remove-manager-button
margin-left: 1rem;

View File

@@ -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;
};
/*

View File

@@ -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;
};
}]);

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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; });

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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();

View File

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

View File

@@ -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 = {}) {

View File

@@ -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();

View File

@@ -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 ? '&#9679 ' + 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

View File

@@ -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")

View File

@@ -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