diff --git a/.circleci/config.yml b/.circleci/config.yml index 1acd4a4c..3dd6a457 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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: diff --git a/docs/swagger.yaml b/docs/swagger.yaml index efe6e6d7..e38fbe25 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: @@ -411,7 +411,7 @@ paths: "/projects/copilots/opportunities": get: tags: - - projects copilot opportunities + - projects copilot opportunity operationId: getAllCopilotOpportunities security: - Bearer: [] @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/migrations/umzug/migrations/20250411182312-copilot_opportunity_apply.js b/migrations/umzug/migrations/20250411182312-copilot_opportunity_apply.js new file mode 100644 index 00000000..7d29919e --- /dev/null +++ b/migrations/umzug/migrations/20250411182312-copilot_opportunity_apply.js @@ -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'); + } +}; diff --git a/src/models/copilotApplication.js b/src/models/copilotApplication.js new file mode 100644 index 00000000..c472da60 --- /dev/null +++ b/src/models/copilotApplication.js @@ -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; +}; diff --git a/src/models/copilotOpportunity.js b/src/models/copilotOpportunity.js index 7ce395c3..446cab81 100644 --- a/src/models/copilotOpportunity.js +++ b/src/models/copilotOpportunity.js @@ -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; diff --git a/src/permissions/constants.js b/src/permissions/constants.js index b12c299f..4395021e 100644 --- a/src/permissions/constants.js +++ b/src/permissions/constants.js @@ -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"', diff --git a/src/routes/copilotOpportunityApply/create.js b/src/routes/copilotOpportunityApply/create.js new file mode 100644 index 00000000..dcf2cbe5 --- /dev/null +++ b/src/routes/copilotOpportunityApply/create.js @@ -0,0 +1,81 @@ +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); + } + + const data = { + userId: req.authUser.userId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + opportunityId: copilotOpportunityId, + notes: notes ? req.sanitize(notes) : null, + }; + + 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); + }); + }, +]; diff --git a/src/routes/index.js b/src/routes/index.js index f171dac3..137c3759 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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'));