Skip to content

feat(PM-1168): Added API to assign an opportunity with an applicant #805

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 15, 2025
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
47 changes: 46 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 copilot opportunity application"
schema:
$ref: "#/definitions/CopilotOpportunityApplication"
"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 @@ -6321,12 +6359,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 () => {
const [, affected] = await models.CopilotApplication.update({
status: COPILOT_APPLICATION_STATUS.ACCEPTED,
}, {
where: {
id: applicationId,
}
});

res.status(200).send(affected[0]);
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