diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js index df2b9bb680..fafc55d7cb 100644 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -43,9 +43,9 @@ describe('GET /export/history.csv', () => { expect(splitRes[0]).to.equal('Task Name,Task ID,Task Type,Date,Value'); expect(splitRes[1]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[0].value}`); expect(splitRes[2]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[1].value}`); - expect(splitRes[3]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[0].value}`); - expect(splitRes[4]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[1].value}`); - expect(splitRes[5]).to.equal(`daily 1,${tasks[1]._id},daily,${moment(tasks[1].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[1].history[0].value}`); + expect(splitRes[3]).to.equal(`daily 1,${tasks[1]._id},daily,${moment(tasks[1].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[1].history[0].value}`); + expect(splitRes[4]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[0].value}`); + expect(splitRes[5]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[1].value}`); expect(splitRes[6]).to.equal(''); }); }); 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 new file mode 100644 index 0000000000..cf5935e443 --- /dev/null +++ b/test/api/v3/integration/tasks/groups/GET-approvals_group_id.test.js @@ -0,0 +1,53 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('GET /approvals/group/:groupId', () => { + let user, guild, member, task, syncedTask; + + function findAssignedTask (memberTask) { + return memberTask.group.id === guild._id; + } + + beforeEach(async () => { + let {group, members, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + }, + members: 1, + }); + + guild = group; + user = groupLeader; + member = members[0]; + + task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test todo', + type: 'todo', + requiresApproval: true, + }); + + await user.post(`/tasks/${task._id}/assign/${member._id}`); + + let memberTasks = await member.get('/tasks/user'); + syncedTask = find(memberTasks, findAssignedTask); + await member.post(`/tasks/${syncedTask._id}/score/up`); + }); + + it('errors when user is not the group leader', async () => { + await expect(member.get(`/approvals/group/${guild._id}`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderCanEditTasks'), + }); + }); + + it('gets a list of task that need approval', async () => { + let approvals = await user.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 new file mode 100644 index 0000000000..0fa9807e07 --- /dev/null +++ b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_approve_userId.test.js @@ -0,0 +1,70 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('POST /tasks/:id/approve/:userId', () => { + let user, guild, member, task; + + function findAssignedTask (memberTask) { + return memberTask.group.id === guild._id; + } + + beforeEach(async () => { + let {group, members, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + }, + members: 1, + }); + + guild = group; + user = groupLeader; + member = members[0]; + + task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test todo', + type: 'todo', + requiresApproval: true, + }); + }); + + it('errors when user is not assigned', async () => { + await expect(user.post(`/tasks/${task._id}/approve/${member._id}`)) + .to.eventually.be.rejected.and.to.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('errors when user is not the group leader', async () => { + await user.post(`/tasks/${task._id}/assign/${member._id}`); + await expect(member.post(`/tasks/${task._id}/approve/${member._id}`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderCanEditTasks'), + }); + }); + + 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 member.sync(); + + expect(member.notifications.length).to.equal(1); + expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVAL'); + expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved')); + + expect(syncedTask.group.approval.approved).to.be.true; + 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 + }); +}); 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 new file mode 100644 index 0000000000..8a42ae9f75 --- /dev/null +++ b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js @@ -0,0 +1,83 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('POST /tasks/:id/score/:direction', () => { + let user, guild, member, task; + + function findAssignedTask (memberTask) { + return memberTask.group.id === guild._id; + } + + beforeEach(async () => { + let {group, members, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + }, + members: 1, + }); + + guild = group; + user = groupLeader; + member = members[0]; + + task = await user.post(`/tasks/group/${guild._id}`, { + text: 'test todo', + type: 'todo', + requiresApproval: true, + }); + + await user.post(`/tasks/${task._id}/assign/${member._id}`); + }); + + it('prevents user from scoring a task that needs to be approved', async () => { + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + let response = await member.post(`/tasks/${syncedTask._id}/score/up`); + let updatedTask = await member.get(`/tasks/${syncedTask._id}`); + + await user.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, + })); + + expect(response.message).to.equal(t('taskApprovalHasBeenRequested')); + expect(updatedTask.group.approval.requested).to.equal(true); + expect(updatedTask.group.approval.requestedDate).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type + }); + + it('errors when approval has already been requested', async () => { + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + await member.post(`/tasks/${syncedTask._id}/score/up`); + + await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('taskRequiresApproval'), + }); + }); + + it('allows a user to score an apporoved task', async () => { + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + await user.post(`/tasks/${task._id}/approve/${member._id}`); + + await member.post(`/tasks/${syncedTask._id}/score/up`); + let updatedTask = await member.get(`/tasks/${syncedTask._id}`); + + expect(updatedTask.completed).to.equal(true); + expect(updatedTask.dateCompleted).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type + }); +}); diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 310722ae95..d37de9587a 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -216,5 +216,7 @@ "assignTask": "Assign Task", "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.

You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.

This box will close automatically when a decision is made.", "claim": "Claim", - "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription" + "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription", + "yourTaskHasBeenApproved": "Your task has been approved", + "userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>" } diff --git a/website/common/locales/en/tasks.json b/website/common/locales/en/tasks.json index 8ae133571f..be0578c984 100644 --- a/website/common/locales/en/tasks.json +++ b/website/common/locales/en/tasks.json @@ -134,5 +134,7 @@ "strengthExample": "Relating to exercise and activity", "intelligenceExample": "Relating to academic or mentally challenging pursuits", "perceptionExample": "Relating to work or financial tasks", - "constitutionExample": "Relating to health, wellness, and social interaction" + "constitutionExample": "Relating to health, wellness, and social interaction", + "taskRequiresApproval": "This task must be approved before you can complete it. Approval has already been requested", + "taskApprovalHasBeenRequested": "Approval has been requested" } diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 3eb9b8c134..4e4ea6acf9 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -7,6 +7,7 @@ import { removeFromArray } from '../../libs/collectionManipulators'; import * as Tasks from '../../models/task'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, @@ -315,6 +316,28 @@ api.scoreTask = { if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.group.approval.required && !task.group.approval.approved) { + if (task.group.approval.requested) { + throw new NotAuthorized(res.t('taskRequiresApproval')); + } + + 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); // Use this method so we can get access to notifications + groupLeader.addNotification('GROUP_TASK_APPROVAL', { + message: res.t('userHasRequestedTaskApproval', { + user: user.profile.name, + taskName: task.text, + }), + }); + + await Bluebird.all([groupLeader.save(), task.save()]); + + return res.respond(200, {message: res.t('taskApprovalHasBeenRequested'), task}); + } + let wasCompleted = task.completed; let [delta] = common.ops.scoreTask({task, user, direction}, req); diff --git a/website/server/controllers/api-v3/tasks/groups.js b/website/server/controllers/api-v3/tasks/groups.js index 92ac10a843..db522e86b1 100644 --- a/website/server/controllers/api-v3/tasks/groups.js +++ b/website/server/controllers/api-v3/tasks/groups.js @@ -1,5 +1,6 @@ import { authWithHeaders } from '../../../middlewares/auth'; import ensureDevelpmentMode from '../../../middlewares/ensureDevelpmentMode'; +import Bluebird from 'bluebird'; import * as Tasks from '../../../models/task'; import { model as Group } from '../../../models/group'; import { model as User } from '../../../models/user'; @@ -178,4 +179,97 @@ api.unassignTask = { }, }; +/** + * @api {post} /api/v3/tasks/:taskId/approve/:userId Approve a user's task + * @apiDescription Approves a user assigned to a group task + * @apiVersion 3.0.0 + * @apiName ApproveTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The id of the task that is the original group task + * @apiParam {UUID} userId The id of the user that will be approved + * + * @apiSuccess task The approved task + */ +api.approveTask = { + method: 'POST', + url: '/tasks/:taskId/approve/:userId', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID(); + + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; + + let user = res.locals.user; + let assignedUserId = req.params.userId; + let assignedUser = await User.findById(assignedUserId); + + let taskId = req.params.taskId; + let task = await Tasks.Task.findOne({ + 'group.taskId': taskId, + userId: assignedUserId, + }); + + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } + + let 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')); + + task.group.approval.dateApproved = new Date(); + task.group.approval.approvingUser = user._id; + task.group.approval.approved = true; + + assignedUser.addNotification('GROUP_TASK_APPROVAL', {message: res.t('yourTaskHasBeenApproved')}); + + await Bluebird.all([assignedUser.save(), task.save()]); + + res.respond(200, task); + }, +}; + +/** + * @api {get} /api/v3/approvals/group/:groupId Get a group's approvals + * @apiVersion 3.0.0 + * @apiName GetGroupApprovals + * @apiGroup Task + * @apiIgnore + * + * @apiParam {UUID} groupId The id of the group from which to retrieve the approvals + * + * @apiSuccess {Array} data An array of tasks + */ +api.getGroupApprovals = { + method: 'GET', + url: '/approvals/group/:groupId', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let groupId = req.params.groupId; + + let group = await Group.getGroup({user, groupId, fields: requiredGroupFields}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); + + let approvals = await Tasks.Task.find({ + 'group.id': groupId, + 'group.approval.approved': false, + 'group.approval.requested': true, + }, 'userId group').exec(); + + res.respond(200, approvals); + }, +}; + module.exports = api; diff --git a/website/server/libs/taskManager.js b/website/server/libs/taskManager.js index 1ba5ef587c..0fbd9f2bfc 100644 --- a/website/server/libs/taskManager.js +++ b/website/server/libs/taskManager.js @@ -31,6 +31,7 @@ async function _validateTaskAlias (tasks, res) { * @param options.user The user that these tasks belong to * @param options.challenge The challenge that these tasks belong to * @param options.group The group that these tasks belong to + * @param options.requiresApproval A boolean stating if the task will require approval * @return The created tasks */ export async function createTasks (req, res, options = {}) { @@ -55,6 +56,9 @@ export async function createTasks (req, res, options = {}) { newTask.challenge.id = challenge.id; } else if (group) { newTask.group.id = group._id; + if (taskData.requiresApproval) { + newTask.group.approval.required = true; + } } else { newTask.userId = user._id; } diff --git a/website/server/models/group.js b/website/server/models/group.js index 51501caac3..4f31f976a4 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -947,6 +947,8 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) { if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); } + matchingTask.group.approval.required = taskToSync.group.approval.required; + if (!matchingTask.notes) matchingTask.notes = taskToSync.notes; // don't override the notes, but provide it if not provided if (matchingTask.tags.indexOf(group._id) === -1) matchingTask.tags.push(group._id); // add tag if missing diff --git a/website/server/models/task.js b/website/server/models/task.js index efff2ff61c..8de85da31e 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -69,6 +69,14 @@ export let TaskSchema = new Schema({ broken: {type: String, enum: ['GROUP_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED']}, assignedUsers: [{type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}], taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, + approval: { + required: {type: Boolean, default: false}, + approved: {type: Boolean, default: false}, + dateApproved: {type: Date}, + approvingUser: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, + requested: {type: Boolean, default: false}, + requestedDate: {type: Date}, + }, }, reminders: [{ diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js index 9d72831a13..0b338b2e57 100644 --- a/website/server/models/userNotification.js +++ b/website/server/models/userNotification.js @@ -12,6 +12,7 @@ const NOTIFICATION_TYPES = [ 'REBIRTH_ACHIEVEMENT', 'NEW_CONTRIBUTOR_LEVEL', 'CRON', + 'GROUP_TASK_APPROVAL', ]; const Schema = mongoose.Schema;