Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ workflows:
context : org-global
filters:
branches:
only: ['develop', 'migration-setup']
only: ['develop', 'migration-setup', 'pm-1168']
- deployProd:
context : org-global
filters:
Expand Down
54 changes: 53 additions & 1 deletion docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,37 @@ paths:
description: "Internal Server Error"
schema:
$ref: "#/definitions/ErrorModel"
"/projects/copilots/opportunity/{copilotOpportunityId}/assign":
post:
tags:
- assign project copilot opportunity
operationId: assignCopilotOpportunity
security:
- Bearer: []
description: "Assign a copilot opportunity with copilot."
parameters:
- $ref: "#/parameters/copilotOpportunityIdParam"
- in: body
name: body
schema:
$ref: "#/definitions/AssignCopilotOpportunity"
responses:
"200":
description: "The response after assigning an copilot opportunity"
schema:
$ref: "#/definitions/CopilotOpportunityAssignResponse"
"401":
description: "Unauthorized"
schema:
$ref: "#/definitions/ErrorModel"
"403":
description: "Forbidden - User does not have permission"
schema:
$ref: "#/definitions/ErrorModel"
"500":
description: "Internal Server Error"
schema:
$ref: "#/definitions/ErrorModel"
"/projects/{projectId}/attachments":
get:
tags:
Expand Down Expand Up @@ -6081,6 +6112,13 @@ definitions:
notes:
description: notes regarding the application
type: string
status:
description: status of the application
type: string
enum:
- pending
- accepted
example: pending
opportunityId:
description: copilot request id
type: integer
Expand Down Expand Up @@ -6111,6 +6149,13 @@ definitions:
format: int64
description: READ-ONLY. User that deleted this task
readOnly: true
CopilotOpportunityAssignResponse:
type: object
properties:
id:
description: unique identifier
type: integer
format: int64
Project:
type: object
properties:
Expand Down Expand Up @@ -6321,12 +6366,19 @@ definitions:
- manager
- copilot
ApplyCopilotOpportunity:
title: Apply copilot CopilotOpportunity
title: Apply Copilot Opportunity
type: object
properties:
notes:
type: string
description: notes about applying copilot opportunity
AssignCopilotOpportunity:
title: Assign Copilot Opportunity
type: object
properties:
applicationId:
type: string
description: The ID of the application to be accepted for the copilot opportunity.
NewProjectAttachment:
title: Project attachment request
type: object
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('copilot_applications', 'status', {
type: Sequelize.STRING(16),
allowNull: true,
});

await queryInterface.sequelize.query(
`UPDATE copilot_applications SET status = 'pending' WHERE status IS NULL`
);

await queryInterface.changeColumn('copilot_applications', 'status', {
type: Sequelize.STRING(16),
allowNull: false,
});
},

down: async (queryInterface) => {
await queryInterface.removeColumn('copilot_applications', 'status');
},
};
5 changes: 5 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export const COPILOT_REQUEST_STATUS = {
FULFILLED: 'fulfiled',
};

export const COPILOT_APPLICATION_STATUS = {
PENDING: 'pending',
ACCEPTED: 'accepted',
};

export const COPILOT_OPPORTUNITY_STATUS = {
ACTIVE: 'active',
COMPLETED: 'completed',
Expand Down
9 changes: 9 additions & 0 deletions src/models/copilotApplication.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import _ from 'lodash';
import { COPILOT_APPLICATION_STATUS } from '../constants';

module.exports = function defineCopilotOpportunity(sequelize, DataTypes) {
const CopilotApplication = sequelize.define('CopilotApplication', {
Expand All @@ -17,6 +18,14 @@ module.exports = function defineCopilotOpportunity(sequelize, DataTypes) {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING(16),
defaultValue: 'pending',
validate: {
isIn: [_.values(COPILOT_APPLICATION_STATUS)],
},
allowNull: false,
},
userId: { type: DataTypes.BIGINT, allowNull: false },
deletedAt: { type: DataTypes.DATE, allowNull: true },
createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
Expand Down
12 changes: 12 additions & 0 deletions src/permissions/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,18 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
],
scopes: SCOPES_PROJECTS_WRITE,
},
ASSIGN_COPILOT_OPPORTUNITY: {
meta: {
title: 'Assign copilot to opportunity',
group: 'Assign Copilot',
description: 'Who can assign for copilot opportunity.',
},
topcoderRoles: [
USER_ROLE.PROJECT_MANAGER,
USER_ROLE.TOPCODER_ADMIN,
],
scopes: SCOPES_PROJECTS_WRITE,
},

LIST_COPILOT_OPPORTUNITY: {
meta: {
Expand Down
97 changes: 97 additions & 0 deletions src/routes/copilotOpportunity/assign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import _ from 'lodash';
import validate from 'express-validation';
import Joi from 'joi';

import models from '../../models';
import util from '../../util';
import { PERMISSION } from '../../permissions/constants';
import { COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS } from '../../constants';

const assignCopilotOpportunityValidations = {
body: Joi.object().keys({
applicationId: Joi.string(),
}),
};

module.exports = [
validate(assignCopilotOpportunityValidations),
async (req, res, next) => {
const { applicationId } = req.body;
const copilotOpportunityId = _.parseInt(req.params.id);
if (!util.hasPermissionByReq(PERMISSION.ASSIGN_COPILOT_OPPORTUNITY, req)) {
const err = new Error('Unable to assign copilot opportunity');
_.assign(err, {
details: JSON.stringify({ message: 'You do not have permission to assign a copilot opportunity' }),
status: 403,
});
return next(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is next a promise? Returning a callback function won't revert the transaction, what do you think?

}

return models.sequelize.transaction(() => {
models.CopilotOpportunity.findOne({
where: {
id: copilotOpportunityId,
},
}).then(async (opportunity) => {
if (!opportunity) {
const err = new Error('No opportunity found');
err.status = 404;
return next(err);
}

if (opportunity.status !== COPILOT_OPPORTUNITY_STATUS.ACTIVE) {
const err = new Error('Opportunity is not active');
err.status = 400;
return next(err);
}

const application = models.CopilotApplication.findOne({
where: {
id: applicationId,
},
});

if (!application) {
const err = new Error('No such application available');
err.status = 400;
return next(err);
}

if (application.status === COPILOT_APPLICATION_STATUS.ACCEPTED) {
const err = new Error('Application already accepted');
err.status = 400;
return next(err);
}

const projectId = opportunity.projectId;
const userId = application.userId;

const activeMembers = await models.ProjectMember.getActiveProjectMembers(projectId);

const existingUser = activeMembers.find(item => item.userId === userId);

if (existingUser) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check if existing user is a copilot in the project not just that they are member of a project.

const err = new Error(`User is already part of the project as ${existingUser.role}`);
err.status = 400;
return next(err);
}


return opportunity.update({status: COPILOT_OPPORTUNITY_STATUS.COMPLETED})
})
.then(async () => {
await models.CopilotApplication.update({
status: COPILOT_APPLICATION_STATUS.ACCEPTED,
}, {
where: {
id: applicationId,
}
});

res.status(200).send({id: applicationId});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am missing the requirement "Mark the related to opportunity copilot request as FULFILLED".
Where is this implemented?

return Promise.resolve()
})
})
.catch(err => next(err));
},
];
4 changes: 4 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ router.route('/v5/projects/copilots/opportunity/:id(\\d+)/apply')
router.route('/v5/projects/copilots/opportunity/:id(\\d+)/applications')
.get(require('./copilotOpportunityApply/list'));

// Copilot opportunity assign
router.route('/v5/projects/copilots/opportunity/:id(\\d+)/assign')
.post(require('./copilotOpportunity/assign'));

// Project Estimation Items
router.route('/v5/projects/:projectId(\\d+)/estimations/:estimationId(\\d+)/items')
.get(require('./projectEstimationItems/list'));
Expand Down