Skip to content

feat(PM-577): apply for copilot opportunity #798

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 23 commits into from
Apr 30, 2025
Merged
Show file tree
Hide file tree
Changes from 21 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-577']
- deployProd:
context : org-global
filters:
Expand Down
53 changes: 49 additions & 4 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ info:
You can also set a custom page size up to 100 with the `perPage` parameter.

Pagination response data is included in http headers. By Default, the response header contains links with `next`, `last`, `first`, `prev` resource links.
host: "localhost:3000"
host: "api.topcoder-dev.com"
basePath: /v5
schemes:
- http
- https
produces:
- application/json
consumes:
Expand Down Expand Up @@ -411,7 +411,7 @@ paths:
"/projects/copilots/opportunities":
get:
tags:
- projects copilot opportunities
- projects copilot opportunity
operationId: getAllCopilotOpportunities
security:
- Bearer: []
Expand Down Expand Up @@ -444,7 +444,7 @@ paths:
description: "Internal Server Error"
schema:
$ref: "#/definitions/ErrorModel"
"/projects/copilots/opportunities/{copilotOpportunityId}":
"/projects/copilots/opportunity/{copilotOpportunityId}":
get:
tags:
- projects copilot opportunity
Expand All @@ -471,6 +471,37 @@ paths:
description: "Internal Server Error"
schema:
$ref: "#/definitions/ErrorModel"
"/projects/copilots/opportunity/{copilotOpportunityId}/apply":
post:
tags:
- projects copilot opportunity
operationId: applyCopilotOpportunity
security:
- Bearer: []
description: "Retrieve a specific copilot opportunity."
parameters:
- $ref: "#/parameters/copilotOpportunityIdParam"
- in: body
name: body
schema:
$ref: "#/definitions/ApplyCopilotOpportunity"
responses:
"200":
description: "The copilot opportunity"
schema:
$ref: "#/definitions/CopilotOpportunity"
"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 @@ -5448,6 +5479,13 @@ parameters:
required: true
type: integer
format: int64
copilotOpportunityIdParam:
name: copilotOpportunityId
in: path
description: copilot opportunity identifier
required: true
type: integer
format: int64
phaseIdParam:
name: phaseId
in: path
Expand Down Expand Up @@ -6184,6 +6222,13 @@ definitions:
- customer
- manager
- copilot
ApplyCopilotOpportunity:
title: Apply copilot CopilotOpportunity
type: object
properties:
notes:
type: string
description: notes about applying copilot opportunity
NewProjectAttachment:
title: Project attachment request
type: object
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict';

module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('copilot_applications', {
id: {
type: Sequelize.BIGINT,
allowNull: false,
primaryKey: true,
autoIncrement: true,
},
userId: {
type: Sequelize.BIGINT,
allowNull: false,
},
opportunityId: {
type: Sequelize.BIGINT,
allowNull: false,
references: {
model: 'copilot_opportunities',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
notes: {
type: Sequelize.TEXT,
allowNull: true,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: true,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: true,
},
deletedBy: {
type: Sequelize.BIGINT,
allowNull: true,
},
createdBy: {
type: Sequelize.BIGINT,
allowNull: false,
},
updatedBy: {
type: Sequelize.BIGINT,
allowNull: false,
},
});
},

down: async (queryInterface) => {
await queryInterface.dropTable('copilot_applications');
}
};
42 changes: 42 additions & 0 deletions src/models/copilotApplication.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import _ from 'lodash';

module.exports = function defineCopilotOpportunity(sequelize, DataTypes) {
const CopilotApplication = sequelize.define('CopilotApplication', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
opportunityId: {
type: DataTypes.BIGINT,
allowNull: false,
references: {
model: 'copilot_opportunities',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
notes: {
type: DataTypes.TEXT,
allowNull: true
},
userId: { type: DataTypes.BIGINT, allowNull: false },
deletedAt: { type: DataTypes.DATE, allowNull: true },
createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
deletedBy: { type: DataTypes.INTEGER, allowNull: true },
createdBy: { type: DataTypes.INTEGER, allowNull: false },
updatedBy: { type: DataTypes.INTEGER, allowNull: false },
}, {
tableName: 'copilot_applications',
paranoid: true,
timestamps: true,
updatedAt: 'updatedAt',
createdAt: 'createdAt',
deletedAt: 'deletedAt',
indexes: [],
});

CopilotApplication.associate = (models) => {
CopilotApplication.belongsTo(models.CopilotOpportunity, { as: 'copilotOpportunity', foreignKey: 'opportunityId' });
};

return CopilotApplication;
};
1 change: 1 addition & 0 deletions src/models/copilotOpportunity.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ module.exports = function defineCopilotOpportunity(sequelize, DataTypes) {
CopilotOpportunity.associate = (models) => {
CopilotOpportunity.belongsTo(models.CopilotRequest, { as: 'copilotRequest', foreignKey: 'copilotRequestId' });
CopilotOpportunity.belongsTo(models.Project, { as: 'project', foreignKey: 'projectId' });
CopilotOpportunity.hasMany(models.CopilotApplication, { as: 'copilotApplications', foreignKey: 'opportunityId' });
};

return CopilotOpportunity;
Expand Down
12 changes: 12 additions & 0 deletions src/permissions/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,18 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
scopes: SCOPES_PROJECTS_WRITE,
},

APPLY_COPILOT_OPPORTUNITY: {
meta: {
title: 'Apply copilot opportunity',
group: 'Apply Copilot',
description: 'Who can apply for copilot opportunity.',
},
topcoderRoles: [
USER_ROLE.COPILOT,
],
scopes: SCOPES_PROJECTS_WRITE,
},

MANAGE_PROJECT_BILLING_ACCOUNT_ID: {
meta: {
title: 'Manage Project property "billingAccountId"',
Expand Down
83 changes: 83 additions & 0 deletions src/routes/copilotOpportunityApply/create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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_OPPORTUNITY_STATUS } from '../../constants';

const applyCopilotRequestValidations = {
body: Joi.object().keys({
notes: Joi.string(),
}),
};

module.exports = [
validate(applyCopilotRequestValidations),
async (req, res, next) => {
const { notes } = req.body;
const copilotOpportunityId = _.parseInt(req.params.id);
if (!util.hasPermissionByReq(PERMISSION.APPLY_COPILOT_OPPORTUNITY, req)) {
const err = new Error('Unable to apply for copilot opportunity');
_.assign(err, {
details: JSON.stringify({ message: 'You do not have permission to apply for copilot opportunity' }),
status: 403,
});
return next(err);
}
// default values
_.assign(data, {
userId: req.authUser.userId,
createdBy: req.authUser.userId,
updatedBy: req.authUser.userId,
opportunityId: copilotOpportunityId,
notes: notes ? req.sanitize(notes) : null,
});

console.log(data, 'debug data data');

return 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 existingApplication = await models.CopilotApplication.findOne({
where: {
opportunityId: opportunity.id,
userId: req.authUser.userId,
},
});

if (existingApplication) {
const err = new Error('User already applied for this opportunity');
err.status = 400;
return next(err);
}

return models.CopilotApplication.create(data)
.then((result) => {
res.status(201).json(result);
return Promise.resolve();
})
.catch((err) => {
util.handleError('Error creating copilot application', err, req, next);
return next(err);
});
}).catch((e) => {
util.handleError('Error applying for copilot opportunity', e, req, next);
});
},
];
4 changes: 4 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,10 @@ router.route('/v5/projects/copilots/opportunities')
router.route('/v5/projects/copilot/opportunity/:id(\\d+)')
.get(require('./copilotOpportunity/get'));

// Project copilot opportunity apply
router.route('/v5/projects/copilots/opportunity/:id(\\d+)/apply')
.post(require('./copilotOpportunityApply/create'));

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