diff --git a/.circleci/config.yml b/.circleci/config.yml index 1acd4a4c..bc40edcd 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-1356'] - deployProd: context : org-global filters: diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml new file mode 100644 index 00000000..333b5bab --- /dev/null +++ b/.github/workflows/code_reviewer.yml @@ -0,0 +1,22 @@ +name: AI PR Reviewer + +on: + pull_request: + types: + - opened + - synchronize +permissions: + pull-requests: write +jobs: + tc-ai-pr-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: TC AI PR Reviewer + uses: topcoder-platform/tc-ai-pr-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) + LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} + exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 5d7f5192..2d1d9475 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -30,7 +30,8 @@ "dbConfig": { "masterUrl": "DB_MASTER_URL", "maxPoolSize": "DB_MAX_POOL_SIZE", - "minPoolSize": "DB_MIN_POOL_SIZE" + "minPoolSize": "DB_MIN_POOL_SIZE", + "schema": "DB_SCHEMA_NAME" }, "kafkaConfig": { "groupId": "KAFKA_GROUP_ID", diff --git a/config/default.json b/config/default.json index 43353496..d65b6383 100644 --- a/config/default.json +++ b/config/default.json @@ -54,6 +54,7 @@ "inviteEmailSubject": "You are invited to Topcoder", "inviteEmailSectionTitle": "Project Invitation", "workManagerUrl": "https://challenges.topcoder-dev.com", + "copilotPortalUrl": "https://copilots.topcoder-dev.com", "accountsAppUrl": "https://accounts.topcoder-dev.com", "MAX_REVISION_NUMBER": 100, "UNIQUE_GMAIL_VALIDATION": false, diff --git a/config/development.json b/config/development.json index 5874ef2c..878bc669 100644 --- a/config/development.json +++ b/config/development.json @@ -3,6 +3,7 @@ "pubsubExchangeName": "dev.projects", "attachmentsS3Bucket": "topcoder-dev-media", "workManagerUrl": "https://challenges.topcoder-dev.com", + "copilotPortalUrl": "https://copilots.topcoder-dev.com", "fileServiceEndpoint": "https://api.topcoder-dev.com/v5/files", "memberServiceEndpoint": "https://api.topcoder-dev.com/v5/members", "identityServiceEndpoint": "https://api.topcoder-dev.com/v3/", diff --git a/config/production.json b/config/production.json index d784a55e..73399edf 100644 --- a/config/production.json +++ b/config/production.json @@ -1,6 +1,7 @@ { "authDomain": "topcoder.com", "workManagerUrl": "https://challenges.topcoder.com", + "copilotPortalUrl": "https://copilots.topcoder.com", "sfdcBillingAccountNameField": "Billing_Account_name__c", "sfdcBillingAccountMarkupField": "Mark_up__c", "sfdcBillingAccountActiveField": "Active__c" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index efe6e6d7..60fe8f75 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,126 @@ 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 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/copilots/opportunity/{copilotOpportunityId}/applications": + get: + tags: + - projects copilot opportunity applications + operationId: listCopilotOpportunity + security: + - Bearer: [] + description: "Retrieve the list copilot opportunity applications." + parameters: + - $ref: "#/parameters/copilotOpportunityIdParam" + - name: sort + required: false + description: > + sort projects by createdAt, updatedAt. Default + is createdAt asc + in: query + type: string + responses: + "200": + description: A list of projects + schema: + type: array + items: + $ref: "#/definitions/CopilotOpportunityApplication" + headers: + X-Next-Page: + type: integer + description: The index of the next page + X-Page: + type: integer + description: The index of the current page (starting at 1) + X-Per-Page: + type: integer + description: The number of items to list per page + X-Prev-Page: + type: integer + description: The index of the previous page + X-Total: + type: integer + description: The total number of items + X-Total-Pages: + type: integer + description: The total number of pages + Link: + type: string + description: Pagination link header. + "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/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: @@ -5448,6 +5568,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 @@ -5975,6 +6102,60 @@ definitions: format: int64 description: READ-ONLY. User that last updated this task readOnly: true + CopilotOpportunityApplication: + type: object + properties: + id: + description: unique identifier + type: integer + format: int64 + 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 + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + deletedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was deleted + readOnly: true + deletedBy: + type: integer + 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: @@ -6184,6 +6365,20 @@ definitions: - customer - manager - copilot + ApplyCopilotOpportunity: + 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 diff --git a/fix.txt b/fix.txt new file mode 100644 index 00000000..e56da10a --- /dev/null +++ b/fix.txt @@ -0,0 +1,143 @@ +commit 4b5eecc32d6cf2e5b8f4bc00d1b56b27447e88ba +Author: himaniraghav3 +Date: Thu Feb 27 14:33:45 2025 +0530 + + Add pagination to copilot opportunities response + +diff --git a/src/routes/copilotOpportunity/get.js b/src/routes/copilotOpportunity/get.js +index 9ceb6a57..a968af13 100644 +--- a/src/routes/copilotOpportunity/get.js ++++ b/src/routes/copilotOpportunity/get.js +@@ -1,9 +1,5 @@ +-import _ from 'lodash'; +- + import models from '../../models'; +-import { ADMIN_ROLES } from '../../constants'; + import util from '../../util'; +-import { PERMISSION } from '../../permissions/constants'; + + module.exports = [ + (req, res, next) => { +@@ -13,26 +9,26 @@ module.exports = [ + return util.handleError('Invalid opportunity ID', null, req, next, 400); + } + +- models.CopilotOpportunity.findOne({ +- where: { id }, +- include: [ +- { +- model: models.CopilotRequest, +- as: 'copilotRequest', +- }, +- { +- model: models.Project, +- as: 'project', +- attributes: ['name'], +- } +- ], ++ return models.CopilotOpportunity.findOne({ ++ where: { id }, ++ include: [ ++ { ++ model: models.CopilotRequest, ++ as: 'copilotRequest', ++ }, ++ { ++ model: models.Project, ++ as: 'project', ++ attributes: ['name'], ++ }, ++ ], + }) + .then((copilotOpportunity) => { +- const plainOpportunity = copilotOpportunity.get({ plain: true }); +- const formattedOpportunity = Object.assign({}, plainOpportunity, +- plainOpportunity.copilotRequest ? plainOpportunity.copilotRequest.data : {}, +- { copilotRequest: undefined } +- ); ++ const plainOpportunity = copilotOpportunity.get({ plain: true }); ++ const formattedOpportunity = Object.assign({}, plainOpportunity, ++ plainOpportunity.copilotRequest ? plainOpportunity.copilotRequest.data : {}, ++ { copilotRequest: undefined }, ++ ); + res.json(formattedOpportunity); + }) + .catch((err) => { +diff --git a/src/routes/copilotOpportunity/list.js b/src/routes/copilotOpportunity/list.js +index 1f1e003f..f8d45bde 100644 +--- a/src/routes/copilotOpportunity/list.js ++++ b/src/routes/copilotOpportunity/list.js +@@ -1,9 +1,7 @@ + import _ from 'lodash'; + + import models from '../../models'; +-import { ADMIN_ROLES } from '../../constants'; + import util from '../../util'; +-import { PERMISSION } from '../../permissions/constants'; + + module.exports = [ + (req, res, next) => { +@@ -17,29 +15,42 @@ module.exports = [ + } + const sortParams = sort.split(' '); + +- models.CopilotOpportunity.findAll({ +- include: [ +- { +- model: models.CopilotRequest, +- as: 'copilotRequest', +- }, +- { +- model: models.Project, +- as: 'project', +- attributes: ['name'], +- } +- ], +- order: [[sortParams[0], sortParams[1]]], ++ // Extract pagination parameters ++ const page = parseInt(req.query.page, 10) || 1; ++ const pageSize = parseInt(req.query.pageSize, 10) || 10; ++ const offset = (page - 1) * pageSize; ++ const limit = pageSize; ++ ++ return models.CopilotOpportunity.findAll({ ++ include: [ ++ { ++ model: models.CopilotRequest, ++ as: 'copilotRequest', ++ }, ++ { ++ model: models.Project, ++ as: 'project', ++ attributes: ['name'], ++ }, ++ ], ++ order: [[sortParams[0], sortParams[1]]], ++ limit, ++ offset, + }) +- .then(copilotOpportunities => { +- const formattedOpportunities = copilotOpportunities.map(opportunity => { +- const plainOpportunity = opportunity.get({ plain: true }); +- return Object.assign({}, plainOpportunity, +- plainOpportunity.copilotRequest ? plainOpportunity.copilotRequest.data : {}, +- { copilotRequest: undefined } +- ); ++ .then((copilotOpportunities) => { ++ const formattedOpportunities = copilotOpportunities.map((opportunity) => { ++ const plainOpportunity = opportunity.get({ plain: true }); ++ return Object.assign({}, plainOpportunity, ++ plainOpportunity.copilotRequest ? plainOpportunity.copilotRequest.data : {}, ++ { copilotRequest: undefined }, ++ ); ++ }); ++ return util.setPaginationHeaders(req, res, { ++ count: copilotOpportunities.count, ++ rows: formattedOpportunities, ++ page, ++ pageSize, + }); +- return res.json(formattedOpportunities); + }) + .catch((err) => { + util.handleError('Error fetching copilot opportunities', err, req, next); diff --git a/migrations/umzug/index.js b/migrations/umzug/index.js index 7a807c26..40401ea7 100644 --- a/migrations/umzug/index.js +++ b/migrations/umzug/index.js @@ -5,6 +5,7 @@ const { Umzug, SequelizeStorage } = require('umzug'); // Initialize Sequelize const sequelize = new Sequelize(config.get('dbConfig.masterUrl'), { dialect: 'postgres', + schema: config.get('dbConfig.schema'), }); // Initialize Umzug 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..27910a56 --- /dev/null +++ b/migrations/umzug/migrations/20250411182312-copilot_opportunity_apply.js @@ -0,0 +1,60 @@ + + +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/migrations/umzug/migrations/20250417160549-add_copilotOpportunityId_to_request.js b/migrations/umzug/migrations/20250417160549-add_copilotOpportunityId_to_request.js new file mode 100644 index 00000000..76c3a897 --- /dev/null +++ b/migrations/umzug/migrations/20250417160549-add_copilotOpportunityId_to_request.js @@ -0,0 +1,18 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('copilot_requests', 'copilotOpportunityId', { + type: Sequelize.BIGINT, + allowNull: true, + references: { + model: 'copilot_opportunities', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn('copilot_requests', 'copilotOpportunityId'); + }, +}; diff --git a/migrations/umzug/migrations/20250511123109-copilot_application_status.js b/migrations/umzug/migrations/20250511123109-copilot_application_status.js new file mode 100644 index 00000000..2c03606b --- /dev/null +++ b/migrations/umzug/migrations/20250511123109-copilot_application_status.js @@ -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'); + }, +}; diff --git a/migrations/umzug/migrations/20250521211708-copilot_invite_from_application.js b/migrations/umzug/migrations/20250521211708-copilot_invite_from_application.js new file mode 100644 index 00000000..487d324e --- /dev/null +++ b/migrations/umzug/migrations/20250521211708-copilot_invite_from_application.js @@ -0,0 +1,18 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('project_member_invites', 'applicationId', { + type: Sequelize.BIGINT, + allowNull: true, + references: { + model: 'copilot_applications', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn('project_member_invites', 'applicationId'); + }, +}; diff --git a/src/constants.js b/src/constants.js index 77bd3b20..bfc93281 100644 --- a/src/constants.js +++ b/src/constants.js @@ -15,7 +15,14 @@ export const COPILOT_REQUEST_STATUS = { REJECTED: 'rejected', SEEKING: 'seeking', CANCELED: 'canceled', - FULFILLED: 'fulfiled', + FULFILLED: 'fulfilled', +}; + +export const COPILOT_APPLICATION_STATUS = { + PENDING: 'pending', + INVITED: 'invited', + ACCEPTED: 'accepted', + CANCELED: 'canceled', }; export const COPILOT_OPPORTUNITY_STATUS = { @@ -90,6 +97,7 @@ export const USER_ROLE = { PROJECT_MANAGER: 'Project Manager', TOPCODER_USER: 'Topcoder User', TG_ADMIN: 'tgadmin', + TC_COPILOT: 'copilot', }; export const ADMIN_ROLES = [USER_ROLE.CONNECT_ADMIN, USER_ROLE.TOPCODER_ADMIN, USER_ROLE.TG_ADMIN]; @@ -294,8 +302,16 @@ export const CONNECT_NOTIFICATION_EVENT = { TOPIC_UPDATED: 'connect.notification.project.topic.updated', POST_CREATED: 'connect.notification.project.post.created', POST_UPDATED: 'connect.notification.project.post.edited', + + // External action email + EXTERNAL_ACTION_EMAIL: 'external.action.email', }; +export const TEMPLATE_IDS = { + APPLY_COPILOT: 'd-d7c1f48628654798a05c8e09e52db14f', + CREATE_REQUEST: 'd-3efdc91da580479d810c7acd50a4c17f', + PROJECT_MEMBER_INVITED: 'd-b47a25b103604bc28fc0ce77e77fb681', +} export const REGEX = { URL: /^(http(s?):\/\/)?(www\.)?[a-zA-Z0-9\.\-\_]+(\.[a-zA-Z]{2,15})+(\:[0-9]{2,5})?(\/[a-zA-Z0-9\_\-\s\.\/\?\%\#\&\=;]*)?$/, // eslint-disable-line }; @@ -353,6 +369,11 @@ export const INVITE_STATUS = { CANCELED: 'canceled', }; +export const INVITE_SOURCE = { + WORK_MANAGER: "work_manager", + COPILOT_PORTAL: "copilot_portal", +}; + export const SCOPE_CHANGE_REQ_STATUS = { PENDING: 'pending', APPROVED: 'approved', diff --git a/src/events/busApi.js b/src/events/busApi.js index ccea766a..c3e0b58d 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -992,11 +992,12 @@ module.exports = (app, logger) => { /* Send event for Notification Service */ - const projectId = _.parseInt(req.params.projectId); + const projectId = resource.projectId; const userId = resource.userId; const email = resource.email; const status = resource.status; const role = resource.role; + const source = resource.source; models.Project.findOne({ where: { id: projectId }, @@ -1011,6 +1012,7 @@ module.exports = (app, logger) => { role, initiatorUserId: req.authUser.userId, isSSO: util.isSSO(project), + source, }, logger); } else { // send event to bus api @@ -1021,6 +1023,7 @@ module.exports = (app, logger) => { role, initiatorUserId: req.authUser.userId, isSSO: util.isSSO(project), + source, }); createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_CREATED, { projectId, @@ -1029,6 +1032,7 @@ module.exports = (app, logger) => { role, initiatorUserId: req.authUser.userId, isSSO: util.isSSO(project), + source, }, logger); } }).catch(err => logger.error(err)); // eslint-disable-line no-unused-vars diff --git a/src/models/copilotApplication.js b/src/models/copilotApplication.js new file mode 100644 index 00000000..9a90881f --- /dev/null +++ b/src/models/copilotApplication.js @@ -0,0 +1,51 @@ +import _ from 'lodash'; +import { COPILOT_APPLICATION_STATUS } from '../constants'; + +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, + }, + 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 }, + 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/models/index.js b/src/models/index.js index 0acc3b27..036e30ae 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -29,6 +29,7 @@ const operatorsAliases = { const sequelize = new Sequelize(config.get('dbConfig.masterUrl'), { operatorsAliases, + schema: config.get('dbConfig.schema'), logging: false, dialectOptions: { ssl: false, diff --git a/src/models/projectMember.js b/src/models/projectMember.js index d1ed93fb..bf213a3d 100644 --- a/src/models/projectMember.js +++ b/src/models/projectMember.js @@ -44,11 +44,12 @@ module.exports = function defineProjectMember(sequelize, DataTypes) { }) .then(res => _.without(_.map(res, 'projectId'), null)); - ProjectMember.getActiveProjectMembers = projectId => ProjectMember.findAll({ + ProjectMember.getActiveProjectMembers = (projectId, t) => ProjectMember.findAll({ where: { deletedAt: { $eq: null }, projectId, }, + transaction: t, raw: true, }); diff --git a/src/models/projectMemberInvite.js b/src/models/projectMemberInvite.js index d19db4cf..29bd895c 100644 --- a/src/models/projectMemberInvite.js +++ b/src/models/projectMemberInvite.js @@ -13,6 +13,16 @@ module.exports = function defineProjectMemberInvite(sequelize, DataTypes) { isEmail: true, }, }, + applicationId: { + type: DataTypes.BIGINT, + allowNull: true, + references: { + model: 'copilot_applications', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, role: { type: DataTypes.STRING, allowNull: false, @@ -55,6 +65,14 @@ module.exports = function defineProjectMemberInvite(sequelize, DataTypes) { raw: true, }); + ProjectMemberInvite.getPendingInvitesForApplication = applicationId => ProjectMemberInvite.findAll({ + where: { + applicationId, + status: INVITE_STATUS.PENDING, + }, + raw: true, + }); + ProjectMemberInvite.getPendingAndReguestedInvitesForProject = projectId => ProjectMemberInvite.findAll({ where: { projectId, diff --git a/src/permissions/constants.js b/src/permissions/constants.js index 17585d30..c32e16d4 100644 --- a/src/permissions/constants.js +++ b/src/permissions/constants.js @@ -265,6 +265,45 @@ 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.TC_COPILOT, + ], + 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: { + title: 'Apply copilot opportunity', + group: 'Apply Copilot', + description: 'Who can apply for copilot opportunity.', + }, + topcoderRoles: [ + USER_ROLE.TOPCODER_ADMIN, + ], + projectRoles: [ + USER_ROLE.PROJECT_MANAGER, + ], + scopes: SCOPES_PROJECTS_WRITE, + }, + MANAGE_PROJECT_BILLING_ACCOUNT_ID: { meta: { title: 'Manage Project property "billingAccountId"', @@ -599,6 +638,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export topcoderRoles: [ ...TOPCODER_ROLES_ADMINS, USER_ROLE.COPILOT_MANAGER, + USER_ROLE.PROJECT_MANAGER, ], scopes: SCOPES_PROJECT_INVITES_WRITE, }, diff --git a/src/permissions/copilotApplications.view.js b/src/permissions/copilotApplications.view.js new file mode 100644 index 00000000..b333cc3c --- /dev/null +++ b/src/permissions/copilotApplications.view.js @@ -0,0 +1,45 @@ + +import _ from 'lodash'; +import util from '../util'; +import models from '../models'; + +/** + * Topcoder admin and Project managers who are part of the project can view the copilot applications in it + * Also, users who had an application will have access to view it. + * @param {Object} freq the express request instance + * @return {Promise} Returns a promise + */ +module.exports = freq => new Promise((resolve, reject) => { + const opportunityId = _.parseInt(freq.params.id); + const currentUserId = freq.authUser.userId; + return models.CopilotOpportunity.findOne({ + where: { + id: opportunityId, + }, + }) + .then((opportunity) => { + const req = freq; + req.context = req.context || {}; + req.context.currentOpportunity = opportunity; + const isProjectManager = util.hasProjectManagerRole(req); + + return models.CopilotApplication.findOne({ + where: { + opportunityId: opportunityId, + userId: currentUserId, + }, + }).then((copilotApplication) => { + // check if auth user has access to this project + const hasAccess = util.hasAdminRole(req) || isProjectManager || !!copilotApplication; + return Promise.resolve(hasAccess); + }) + }) + .then((hasAccess) => { + if (!hasAccess) { + const errorMessage = 'You do not have permissions to perform this action'; + // user is not an admin nor is a registered project member + return reject(new Error(errorMessage)); + } + return resolve(true); + }); +}); diff --git a/src/permissions/index.js b/src/permissions/index.js index edb9cb6f..01cb7799 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -9,6 +9,7 @@ const copilotAndAbove = require('./copilotAndAbove'); const workManagementPermissions = require('./workManagementForTemplate'); const projectSettingEdit = require('./projectSetting.edit'); const customerPaymentConfirm = require('./customerPayment.confirm'); +const viewCopilotApplications = require('./copilotApplications.view'); const generalPermission = require('./generalPermission'); const { PERMISSION } = require('./constants'); @@ -199,4 +200,7 @@ module.exports = () => { Authorizer.setPolicy('customerPayment.view', generalPermission(PERMISSION.VIEW_CUSTOMER_PAYMENT)); Authorizer.setPolicy('customerPayment.edit', generalPermission(PERMISSION.UPDATE_CUSTOMER_PAYMENT)); Authorizer.setPolicy('customerPayment.confirm', customerPaymentConfirm); + + // Copilot opportunity + Authorizer.setPolicy('copilotApplications.view', viewCopilotApplications); }; diff --git a/src/routes/copilotOpportunity/assign.js b/src/routes/copilotOpportunity/assign.js new file mode 100644 index 00000000..5a3d0b3f --- /dev/null +++ b/src/routes/copilotOpportunity/assign.js @@ -0,0 +1,124 @@ +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, COPILOT_REQUEST_STATUS, EVENT, INVITE_STATUS, PROJECT_MEMBER_ROLE, RESOURCES } 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); + } + + return models.sequelize.transaction(async (t) => { + const opportunity = await models.CopilotOpportunity.findOne({ + where: { id: copilotOpportunityId }, + transaction: t, + }); + + if (!opportunity) { + const err = new Error('No opportunity found'); + err.status = 404; + throw err; + } + + if (opportunity.status !== COPILOT_OPPORTUNITY_STATUS.ACTIVE) { + const err = new Error('Opportunity is not active'); + err.status = 400; + throw err; + } + + const application = await models.CopilotApplication.findOne({ + where: { id: applicationId, opportunityId: copilotOpportunityId }, + transaction: t, + }); + + if (!application) { + const err = new Error('No such application available'); + err.status = 400; + throw err; + } + + if (application.status === COPILOT_APPLICATION_STATUS.ACCEPTED) { + const err = new Error('Application already accepted'); + err.status = 400; + throw err; + } + + const projectId = opportunity.projectId; + const userId = application.userId; + const activeMembers = await models.ProjectMember.getActiveProjectMembers(projectId, t); + + const existingUser = activeMembers.find(item => item.userId === userId); + if (existingUser && existingUser.role === 'copilot') { + const err = new Error(`User is already a copilot of this project`); + err.status = 400; + throw err; + } + + const existingInvite = await models.ProjectMemberInvite.findAll({ + where: { + userId, + projectId, + role: PROJECT_MEMBER_ROLE.COPILOT, + status: INVITE_STATUS.PENDING, + }, + transaction: t, + }); + + if (existingInvite && existingInvite.length) { + const err = new Error(`User already has an pending invite to the project`); + err.status = 400; + throw err; + } + + const invite = await models.ProjectMemberInvite.create({ + status: INVITE_STATUS.PENDING, + role: PROJECT_MEMBER_ROLE.COPILOT, + userId, + projectId, + applicationId: application.id, + createdBy: req.authUser.userId, + createdAt: new Date(), + updatedBy: req.authUser.userId, + updatedAt: new Date(), + }, { + transaction: t, + }) + + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, + RESOURCES.PROJECT_MEMBER_INVITE, + Object.assign({}, invite.toJSON(), { + source: 'copilot_portal', + }), + ); + + await application.update({ + status: COPILOT_APPLICATION_STATUS.INVITED, + }, { + transaction: t, + }); + + res.status(200).send({ id: applicationId }); + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/copilotOpportunityApply/create.js b/src/routes/copilotOpportunityApply/create.js new file mode 100644 index 00000000..4bfbea9f --- /dev/null +++ b/src/routes/copilotOpportunityApply/create.js @@ -0,0 +1,119 @@ +import _ from 'lodash'; +import validate from 'express-validation'; +import Joi from 'joi'; +import config from 'config'; + +import models from '../../models'; +import util from '../../util'; +import { PERMISSION } from '../../permissions/constants'; +import { CONNECT_NOTIFICATION_EVENT, COPILOT_OPPORTUNITY_STATUS, TEMPLATE_IDS, USER_ROLE } from '../../constants'; +import { createEvent } from '../../services/busApi'; + +const applyCopilotRequestValidations = { + body: Joi.object().keys({ + notes: Joi.string().optional(), + }), +}; + +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) { + res.status(200).json(existingApplication); + return Promise.resolve(); + } + + return models.CopilotApplication.create(data) + .then(async (result) => { + const pmRole = await util.getRolesByRoleName(USER_ROLE.PROJECT_MANAGER, req.log, req.id); + const { subjects = [] } = await util.getRoleInfo(pmRole[0], req.log, req.id); + + const creator = await util.getMemberDetailsByUserIds([opportunity.createdBy], req.log, req.id); + + const listOfSubjects = subjects; + if (creator && creator[0] && creator[0].email) { + const isCreatorPartofSubjects = subjects.find(item => { + if (!item.email) { + return false; + } + + return item.email.toLowerCase() === creator[0].email.toLowerCase(); + }); + if (!isCreatorPartofSubjects) { + listOfSubjects.push({ + email: creator[0].email, + handle: creator[0].handle, + }); + } + } + + const emailEventType = CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL; + const copilotPortalUrl = config.get('copilotPortalUrl'); + listOfSubjects.forEach((subject) => { + createEvent(emailEventType, { + data: { + user_name: subject.handle, + opportunity_details_url: `${copilotPortalUrl}/opportunity/${opportunity.id}#applications`, + work_manager_url: config.get('workManagerUrl'), + }, + sendgrid_template_id: TEMPLATE_IDS.APPLY_COPILOT, + recipients: [subject.email], + version: 'v3', + }, req.log); + }); + + 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/copilotOpportunityApply/list.js b/src/routes/copilotOpportunityApply/list.js new file mode 100644 index 00000000..69aea8fe --- /dev/null +++ b/src/routes/copilotOpportunityApply/list.js @@ -0,0 +1,49 @@ +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; + +import models from '../../models'; +import { ADMIN_ROLES } from '../../constants'; +import util from '../../util'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('copilotApplications.view'), + (req, res, next) => { + const canAccessAllApplications = util.hasRoles(req, ADMIN_ROLES) || util.hasProjectManagerRole(req); + const userId = req.authUser.userId; + const opportunityId = _.parseInt(req.params.id); + + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'createdAt desc'; + if (sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = ['createdAt asc', 'createdAt desc']; + if (_.indexOf(sortableProps, sort) < 0) { + return util.handleError('Invalid sort criteria', null, req, next); + } + const sortParams = sort.split(' '); + + // Admin can see all requests and the PM can only see requests created by them + const whereCondition = _.assign({ + opportunityId, + }, + canAccessAllApplications ? {} : { createdBy: userId }, + ); + + return models.CopilotApplication.findAll({ + where: whereCondition, + include: [ + { + model: models.CopilotOpportunity, + as: 'copilotOpportunity', + }, + ], + order: [[sortParams[0], sortParams[1]]], + }) + .then(copilotApplications => res.json(copilotApplications)) + .catch((err) => { + util.handleError('Error fetching copilot applications', err, req, next); + }); + }, +]; diff --git a/src/routes/copilotRequest/approveRequest.js b/src/routes/copilotRequest/approveRequest.js index 12fd6a8b..c1371671 100644 --- a/src/routes/copilotRequest/approveRequest.js +++ b/src/routes/copilotRequest/approveRequest.js @@ -35,7 +35,7 @@ module.exports = [ updatedBy: req.authUser.userId, }); - return approveRequest(data) + return approveRequest(req, data) .then(_newCopilotOpportunity => res.status(201).json(_newCopilotOpportunity)) .catch((err) => { if (err.message) { diff --git a/src/routes/copilotRequest/approveRequest.service.js b/src/routes/copilotRequest/approveRequest.service.js index b5164092..fc0663d5 100644 --- a/src/routes/copilotRequest/approveRequest.service.js +++ b/src/routes/copilotRequest/approveRequest.service.js @@ -1,7 +1,10 @@ import _ from 'lodash'; +import config from 'config'; import models from '../../models'; -import { COPILOT_REQUEST_STATUS } from '../../constants'; +import { CONNECT_NOTIFICATION_EVENT, COPILOT_REQUEST_STATUS, TEMPLATE_IDS, USER_ROLE } from '../../constants'; +import util from '../../util'; +import { createEvent } from '../../services/busApi'; const resolveTransaction = (transaction, callback) => { if (transaction) { @@ -11,7 +14,7 @@ const resolveTransaction = (transaction, callback) => { return models.sequelize.transaction(callback); }; -module.exports = (data, existingTransaction) => { +module.exports = (req, data, existingTransaction) => { const { projectId, copilotRequestId } = data; return resolveTransaction(existingTransaction, transaction => @@ -52,6 +55,29 @@ module.exports = (data, existingTransaction) => { return models.CopilotOpportunity .create(data, { transaction }); })) + .then(async (opportunity) => { + const roles = await util.getRolesByRoleName(USER_ROLE.TC_COPILOT, req.log, req.id); + const { subjects = [] } = await util.getRoleInfo(roles[0], req.log, req.id); + const emailEventType = CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL; + const copilotPortalUrl = config.get('copilotPortalUrl'); + req.log.info("Sending emails to all copilots about new opportunity"); + subjects.forEach(subject => { + createEvent(emailEventType, { + data: { + user_name: subject.handle, + opportunity_details_url: `${copilotPortalUrl}/opportunity/${opportunity.id}`, + work_manager_url: config.get('workManagerUrl'), + }, + sendgrid_template_id: TEMPLATE_IDS.CREATE_REQUEST, + recipients: [subject.email], + version: 'v3', + }, req.log); + }); + + req.log.info("Finished sending emails to copilots"); + + return opportunity; + }) .catch((err) => { transaction.rollback(); return Promise.reject(err); diff --git a/src/routes/copilotRequest/create.js b/src/routes/copilotRequest/create.js index 95fa45f2..2b05f524 100644 --- a/src/routes/copilotRequest/create.js +++ b/src/routes/copilotRequest/create.js @@ -98,7 +98,7 @@ module.exports = [ updatedBy: req.authUser.userId, type: copilotRequest.data.projectType, }); - return approveRequest(approveData, transaction).then(() => copilotRequest); + return approveRequest(req, approveData, transaction).then(() => copilotRequest); }).then(copilotRequest => res.status(201).json(copilotRequest)) .catch((err) => { try { diff --git a/src/routes/copilotRequest/list.js b/src/routes/copilotRequest/list.js index 66f36606..a36a3d7b 100644 --- a/src/routes/copilotRequest/list.js +++ b/src/routes/copilotRequest/list.js @@ -1,7 +1,6 @@ import _ from 'lodash'; import models from '../../models'; -import { ADMIN_ROLES } from '../../constants'; import util from '../../util'; import { PERMISSION } from '../../permissions/constants'; @@ -16,9 +15,6 @@ module.exports = [ return next(err); } - const isAdmin = util.hasRoles(req, ADMIN_ROLES); - - const userId = req.authUser.userId; const projectId = _.parseInt(req.params.projectId); let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'createdAt desc'; @@ -31,11 +27,7 @@ module.exports = [ } const sortParams = sort.split(' '); - // Admin can see all requests and the PM can only see requests created by them - const whereCondition = _.assign({}, - isAdmin ? {} : { createdBy: userId }, - projectId ? { projectId } : {}, - ); + const whereCondition = projectId ? { projectId } : {}; return models.CopilotRequest.findAll({ where: whereCondition, diff --git a/src/routes/index.js b/src/routes/index.js index b0022ad8..54df9280 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -25,8 +25,8 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { // List of public routes const publicRoutes = [ - `/${apiVersion}/projects/copilots/opportunities`, - `/${apiVersion}/projects/copilot/opportunities/:id(\\d+)`, + new RegExp(`^/${apiVersion}/projects/copilots/opportunities$`), + new RegExp(`^/${apiVersion}/projects/copilot/opportunity/\\d+$`), ]; // All project service endpoints need authentication @@ -35,7 +35,7 @@ const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; router.all( RegExp(`\\/${apiVersion}\\/(copilots|projects|timelines|orgConfig|customer-payments)(?!\\/health).*`), (req, res, next) => { - if (publicRoutes.some(route => req.path.match(new RegExp(`^${route}$`)))) { + if (publicRoutes.some(routeRegex => routeRegex.test(req.path))) { return next(); } // JWT authentication @@ -404,6 +404,16 @@ 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')); +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')); diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index ed32e2c3..99f4334a 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -14,6 +14,7 @@ import { RESOURCES, MAX_PARALLEL_REQUEST_QTY, CONNECT_NOTIFICATION_EVENT, + TEMPLATE_IDS, } from '../../constants'; import { createEvent } from '../../services/busApi'; import { PERMISSION, PROJECT_TO_TOPCODER_ROLES_MATRIX } from '../../permissions/constants'; @@ -199,9 +200,9 @@ const buildCreateInvitePromises = (req, inviteEmails, inviteUserIds, invites, da }; const sendInviteEmail = (req, projectId, invite) => { - req.log.debug(`Sending invite email: ${JSON.stringify(req.body)}, ${projectId}, ${JSON.stringify(invite)}`) + req.log.debug(`Sending invite email: ${JSON.stringify(req.body)}, ${projectId}, ${JSON.stringify(invite)}`); req.log.debug(req.authUser); - const emailEventType = CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_EMAIL_INVITE_CREATED; + const emailEventType = CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL; const promises = [ models.Project.findOne({ where: { id: projectId }, @@ -237,13 +238,9 @@ const sendInviteEmail = (req, projectId, invite) => { ], }], }, + sendgrid_template_id: TEMPLATE_IDS.PROJECT_MEMBER_INVITED, recipients: [invite.email], version: 'v3', - from: { - name: config.get('EMAIL_INVITE_FROM_NAME'), - email: config.get('EMAIL_INVITE_FROM_EMAIL'), - }, - categories: [`${process.env.NODE_ENV}:${emailEventType}`.toLowerCase()], }, req.log); }).catch((error) => { req.log.error(error); @@ -295,13 +292,12 @@ module.exports = [ // whom we are inviting, because Member Service has a loose search logic and may return // users with handles whom we didn't search for .then((foundUsers) => { - if(invite.handles) { + if (invite.handles) { const lowerCaseHandles = invite.handles.map(handle => handle.toLowerCase()); return foundUsers.filter(foundUser => _.includes(lowerCaseHandles, foundUser.handleLower)); } - else { - return [] - } + + return []; }) .then((inviteUsers) => { const members = req.context.currentProjectMembers; @@ -412,9 +408,12 @@ module.exports = [ req, EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, RESOURCES.PROJECT_MEMBER_INVITE, - v.toJSON()); + Object.assign({}, v.toJSON(), { + source: 'work_manager', + }), + ); - req.log.debug(`V: ${JSON.stringify(v)}`) + req.log.debug(`V: ${JSON.stringify(v)}`); // send email invite (async) if (v.email && !v.userId && v.status === INVITE_STATUS.PENDING) { sendInviteEmail(req, projectId, v); @@ -443,7 +442,7 @@ module.exports = [ } }); }).catch((err) => { - console.log(err) + console.log(err); if (failed.length) { res.status(403).json(_.assign({}, { success: [] }, { failed })); } else next(err); diff --git a/src/routes/projectMemberInvites/delete.js b/src/routes/projectMemberInvites/delete.js index d8c8be91..6eab2167 100644 --- a/src/routes/projectMemberInvites/delete.js +++ b/src/routes/projectMemberInvites/delete.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { PROJECT_MEMBER_ROLE, INVITE_STATUS, EVENT, RESOURCES } from '../../constants'; +import { PROJECT_MEMBER_ROLE, INVITE_STATUS, EVENT, RESOURCES, COPILOT_APPLICATION_STATUS } from '../../constants'; import { PERMISSION } from '../../permissions/constants'; /** @@ -74,7 +74,7 @@ module.exports = [ .update({ status: INVITE_STATUS.CANCELED, }) - .then((updatedInvite) => { + .then(async (updatedInvite) => { // emit the event util.sendResourceToKafkaBus( req, @@ -82,6 +82,23 @@ module.exports = [ RESOURCES.PROJECT_MEMBER_INVITE, updatedInvite.toJSON()); + // update the application if the invite + // originated from copilot opportunity + if (invite.applicationId) { + const allPendingInvitesForApplication = await models.ProjectMemberInvite.getPendingInvitesForApplication(invite.applicationId); + // If only the current invite is the open one's + // then the application status has to be moved to pending status + if (allPendingInvitesForApplication.length === 0) { + await models.CopilotApplication.update({ + status: COPILOT_APPLICATION_STATUS.PENDING, + }, { + where: { + id: invite.applicationId, + }, + }); + } + } + res.status(204).end(); }); }) diff --git a/src/routes/projectMemberInvites/update.js b/src/routes/projectMemberInvites/update.js index ccb7e657..9f21b1c8 100644 --- a/src/routes/projectMemberInvites/update.js +++ b/src/routes/projectMemberInvites/update.js @@ -1,12 +1,14 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; +import { Op } from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { INVITE_STATUS, EVENT, RESOURCES } from '../../constants'; +import { INVITE_STATUS, EVENT, RESOURCES, COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS, INVITE_SOURCE } from '../../constants'; import { PERMISSION } from '../../permissions/constants'; + /** * API to update invite member to project. * @@ -19,6 +21,8 @@ const updateMemberValidations = { status: Joi.any() .valid(_.values(INVITE_STATUS)) .required(), + source: Joi.string() + .default(INVITE_SOURCE.WORK_MANAGER), }) .required(), }; @@ -29,6 +33,7 @@ module.exports = [ permissions('projectMemberInvite.edit'), (req, res, next) => { const newStatus = req.body.status; + const source = req.body.source; if (newStatus === INVITE_STATUS.CANCELED) { const err = new Error('Cannot change invite status to “canceled”. Please, delete the invite instead.'); err.status = 400; @@ -81,7 +86,7 @@ module.exports = [ .update({ status: newStatus, }) - .then((updatedInvite) => { + .then(async (updatedInvite) => { // emit the event util.sendResourceToKafkaBus( req, @@ -94,7 +99,7 @@ module.exports = [ if (updatedInvite.status === INVITE_STATUS.ACCEPTED || updatedInvite.status === INVITE_STATUS.REQUEST_APPROVED) { return models.ProjectMember.getActiveProjectMembers(projectId) - .then((members) => { + .then(async (members) => { req.context = req.context || {}; req.context.currentProjectMembers = members; let userId = updatedInvite.userId; @@ -117,11 +122,171 @@ module.exports = [ createdBy: req.authUser.userId, updatedBy: req.authUser.userId, }; - return util - .addUserToProject(req, member) - .then(() => res.json(util.postProcessInvites('$.email', updatedInvite, req))) - .catch(err => next(err)); + const t = await models.sequelize.transaction(); + try { + await util.addUserToProject(req, member, t); + if (invite.applicationId) { + let nextApplicationStatus = COPILOT_APPLICATION_STATUS.CANCELED; + let nextOpportunityStatus = COPILOT_OPPORTUNITY_STATUS.CANCELED; + let nextOpportunityRequestStatus = COPILOT_REQUEST_STATUS.CANCELED; + if (source === 'copilot_portal') { + nextApplicationStatus = COPILOT_APPLICATION_STATUS.ACCEPTED; + nextOpportunityStatus = COPILOT_OPPORTUNITY_STATUS.COMPLETED; + nextOpportunityRequestStatus = COPILOT_REQUEST_STATUS.FULFILLED; + } + + const application = await models.CopilotApplication.findOne({ + where: { + id: invite.applicationId, + }, + transaction: t, + }); + + await application.update({ status: nextApplicationStatus }, { + transaction: t + }); + + const opportunity = await models.CopilotOpportunity.findOne({ + where: { + id: application.opportunityId, + }, + transaction: t, + }); + + await opportunity.update({ + status: nextOpportunityStatus, + }, { + transaction: t, + }); + + const request = await models.CopilotRequest.findOne({ + where: { + id: opportunity.copilotRequestId, + }, + transaction: t, + }); + + await request.update({ + status: nextOpportunityRequestStatus, + }, { + transaction: t, + }); + } else if (source === INVITE_SOURCE.WORK_MANAGER) { + const allCopilotRequestsByProjectId = await models.CopilotRequest.findAll({ + where: { + projectId: invite.projectId, + }, + transaction: t, + }); + + const requestIds = allCopilotRequestsByProjectId.map(item => item.id); + + await models.CopilotRequest.update({ + status: COPILOT_REQUEST_STATUS.CANCELED, + }, { + where: { + id: { + [Op.in]: requestIds, + } + }, + transaction: t, + }); + + const allCopilotOpportunityByRequestIds = await models.CopilotOpportunity.findAll({ + where: { + copilotRequestId: { + [Op.in]: requestIds, + }, + }, + transaction: t, + }); + + await models.CopilotOpportunity.update({ + status: COPILOT_OPPORTUNITY_STATUS.CANCELED, + }, { + where: { + id: { + [Op.in]: allCopilotOpportunityByRequestIds.map(item => item.id), + }, + }, + transaction: t, + }); + + const copilotApplications = await models.CopilotApplication.findAll({ + where: { + opportunityId: { + [Op.in]: allCopilotOpportunityByRequestIds.map(item => item.id), + }, + }, + transaction: t, + }); + + await models.CopilotApplication.update({ + status: COPILOT_APPLICATION_STATUS.CANCELED, + }, { + where: { + id: { + [Op.in]: copilotApplications.map(item => item.id), + }, + }, + transaction: t, + }); + + const invitesToBeUpdated = await models.ProjectMemberInvite.findAll({ + where: { + applicationId: { + [Op.in]: copilotApplications.map(item => item.id), + } + }, + transaction: t, + }); + + // Cancel the existing invites which are opened via + // applications + await models.ProjectMemberInvite.update({ + status: INVITE_STATUS.CANCELED, + }, { + where: { + applicationId: { + [Op.in]: copilotApplications.map(item => item.id), + } + }, + transaction: t, + }); + + invitesToBeUpdated.forEach((inviteToBeUpdated) => { + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_UPDATED, + RESOURCES.PROJECT_MEMBER_INVITE, + inviteToBeUpdated.toJSON()); + }) + } + + await t.commit(); + return res.json(util.postProcessInvites('$.email', updatedInvite, req)); + } catch (e) { + await t.rollback(); + return next(e); + } }); + } else if (updatedInvite.status === INVITE_STATUS.REFUSED) { + // update the application if the invite + // originated from copilot opportunity + if (updatedInvite.applicationId) { + const allPendingInvitesForApplication = await models.ProjectMemberInvite.getPendingInvitesForApplication(invite.applicationId); + // If only the current invite is the open one's + // then the application status has to be moved to pending status + if (allPendingInvitesForApplication.length === 0) { + await models.CopilotApplication.update({ + status: COPILOT_APPLICATION_STATUS.PENDING, + }, { + where: { + id: updatedInvite.applicationId, + }, + }); + } + } } return res.json(util.postProcessInvites('$.email', updatedInvite, req)); }); diff --git a/src/util.js b/src/util.js index 711b76a8..60c71d57 100644 --- a/src/util.js +++ b/src/util.js @@ -225,6 +225,23 @@ const projectServiceUtils = { return _.intersection(roles, ADMIN_ROLES.map(r => r.toLowerCase())).length > 0; }, + /** + * Helper funtion to verify if user has project manager role + * @param {object} req Request object that should contain authUser + * @return {boolean} true/false + */ + hasProjectManagerRole: (req) => { + const isMachineToken = _.get(req, 'authUser.isMachine', false); + const tokenScopes = _.get(req, 'authUser.scopes', []); + if (isMachineToken) { + if (_.indexOf(tokenScopes, M2M_SCOPES.CONNECT_PROJECT_ADMIN) >= 0) return true; + return false; + } + let roles = _.get(req, 'authUser.roles', []); + roles = roles.map(s => s.toLowerCase()); + return roles.includes(USER_ROLE.PROJECT_MANAGER.toLowerCase()); + }, + /** * Parses query fields and groups them per table * @param {array} queryFields list of query fields @@ -798,6 +815,54 @@ const projectServiceUtils = { } }, + getRoleInfo: Promise.coroutine(function* (roleId, logger, requestId) { // eslint-disable-line func-names + try { + const token = yield this.getM2MToken(); + const httpClient = this.getHttpClient({ id: requestId, log: logger }); + httpClient.defaults.timeout = 6000; + logger.debug(`${config.identityServiceEndpoint}roles/${roleId}`, "fetching role info"); + return httpClient.get(`${config.identityServiceEndpoint}roles/${roleId}`, { + params: { + fields: `subjects`, + }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }).then((res) => { + logger.debug(`Role info by ${roleId}: ${JSON.stringify(res.data.result.content)}`); + return _.get(res, 'data.result.content', []); + }); + } catch (err) { + logger.debug(err, "error on getting role info"); + return Promise.reject(err); + } + }), + + getRolesByRoleName: Promise.coroutine(function* (roleName, logger, requestId) { // eslint-disable-line func-names + try { + const token = yield this.getM2MToken(); + const httpClient = this.getHttpClient({ id: requestId, log: logger }); + httpClient.defaults.timeout = 6000; + return httpClient.get(`${config.identityServiceEndpoint}roles`, { + params: { + filter: `roleName=${roleName}`, + }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }).then((res) => { + logger.debug(`Roles by ${roleName}: ${JSON.stringify(res.data.result.content)}`); + return _.get(res, 'data.result.content', []) + .filter(item => item.roleName === roleName) + .map(r => r.id); + }); + } catch (err) { + return Promise.reject(err); + } + }), + /** * Retrieve member details from userIds */