diff --git a/.circleci/config.yml b/.circleci/config.yml index ff93f090..b3f2600c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -149,7 +149,7 @@ workflows: context : org-global filters: branches: - only: ['develop', 'migration-setup', 'pm-1378'] + only: ['develop', 'migration-setup', 'pm-1398'] - deployProd: context : org-global filters: diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 2d1d9475..4a56c8a5 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -53,6 +53,7 @@ "AUTH0_PROXY_SERVER_URL" : "AUTH0_PROXY_SERVER_URL", "connectUrl": "CONNECT_URL", "workManagerUrl": "WORK_MANAGER_URL", + "copilotsSlackEmail": "COPILOTS_SLACK_EMAIL", "accountsAppUrl": "ACCOUNTS_APP_URL", "inviteEmailSubject": "INVITE_EMAIL_SUBJECT", "inviteEmailSectionTitle": "INVITE_EMAIL_SECTION_TITLE", diff --git a/config/default.json b/config/default.json index d65b6383..ebdea119 100644 --- a/config/default.json +++ b/config/default.json @@ -56,6 +56,7 @@ "workManagerUrl": "https://challenges.topcoder-dev.com", "copilotPortalUrl": "https://copilots.topcoder-dev.com", "accountsAppUrl": "https://accounts.topcoder-dev.com", + "copilotsSlackEmail": "aaaaplg3e4y6ccn3qniivvnxva@topcoder.slack.com", "MAX_REVISION_NUMBER": 100, "UNIQUE_GMAIL_VALIDATION": false, "pageSize": 20, diff --git a/config/production.json b/config/production.json index 73399edf..9715f1cd 100644 --- a/config/production.json +++ b/config/production.json @@ -4,5 +4,6 @@ "copilotPortalUrl": "https://copilots.topcoder.com", "sfdcBillingAccountNameField": "Billing_Account_name__c", "sfdcBillingAccountMarkupField": "Mark_up__c", - "sfdcBillingAccountActiveField": "Active__c" + "sfdcBillingAccountActiveField": "Active__c", + "copilotsSlackEmail": "mem-ops-copilot-aaaadbvbjvvek6ojnwpfdh5qq4@topcoder.slack.com" } diff --git a/src/constants.js b/src/constants.js index bfc93281..1b727459 100644 --- a/src/constants.js +++ b/src/constants.js @@ -311,6 +311,8 @@ export const TEMPLATE_IDS = { APPLY_COPILOT: 'd-d7c1f48628654798a05c8e09e52db14f', CREATE_REQUEST: 'd-3efdc91da580479d810c7acd50a4c17f', PROJECT_MEMBER_INVITED: 'd-b47a25b103604bc28fc0ce77e77fb681', + INFORM_PM_COPILOT_APPLICATION_ACCEPTED: 'd-b35d073e302b4279a1bd208fcfe96f58', + COPILOT_ALREADY_PART_OF_PROJECT: 'd-003d41cdc9de4bbc9e14538e8f2e0585', } 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 diff --git a/src/routes/copilotOpportunity/assign.js b/src/routes/copilotOpportunity/assign.js index 5a3d0b3f..6cab3b55 100644 --- a/src/routes/copilotOpportunity/assign.js +++ b/src/routes/copilotOpportunity/assign.js @@ -1,11 +1,15 @@ 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 { COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS, EVENT, INVITE_STATUS, PROJECT_MEMBER_ROLE, RESOURCES } from '../../constants'; +import { CONNECT_NOTIFICATION_EVENT, COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS, EVENT, INVITE_STATUS, PROJECT_MEMBER_ROLE, RESOURCES, TEMPLATE_IDS } from '../../constants'; +import { getCopilotTypeLabel } from '../../utils/copilot'; +import { createEvent } from '../../services/busApi'; +import moment from 'moment'; const assignCopilotOpportunityValidations = { body: Joi.object().keys({ @@ -45,11 +49,17 @@ module.exports = [ throw err; } + const copilotRequest = await models.CopilotRequest.findOne({ + where: { id: opportunity.copilotRequestId }, + transaction: t, + }); + 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; @@ -65,12 +75,101 @@ module.exports = [ 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 updateCopilotOpportunity = async () => { + const transaction = await models.sequelize.transaction(); + const memberDetails = await util.getMemberDetailsByUserIds([application.userId], req.log, req.id); + const member = memberDetails[0]; + req.log.debug(`Updating opportunity: ${JSON.stringify(opportunity)}`); + await opportunity.update({ + status: COPILOT_OPPORTUNITY_STATUS.COMPLETED, + }, { + transaction, + }); + req.log.debug(`Updating application: ${JSON.stringify(application)}`); + await application.update({ + status: COPILOT_APPLICATION_STATUS.ACCEPTED, + }, { + transaction, + }); + + req.log.debug(`Updating request: ${JSON.stringify(copilotRequest)}`); + await copilotRequest.update({ + status: COPILOT_REQUEST_STATUS.FULFILLED, + }, { + transaction, + }); + + req.log.debug(`Updating other applications: ${JSON.stringify(copilotRequest)}`); + await models.CopilotApplication.update({ + status: COPILOT_APPLICATION_STATUS.CANCELED, + }, { + where: { + opportunityId: opportunity.id, + id: { + $ne: application.id, + }, + } + }); + + req.log.debug(`All updations done`); + transaction.commit(); + + req.log.debug(`Sending email notification`); + const emailEventType = CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL; + const copilotPortalUrl = config.get('copilotPortalUrl'); + const requestData = copilotRequest.data; + createEvent(emailEventType, { + data: { + opportunity_details_url: `${copilotPortalUrl}/opportunity/${opportunity.id}`, + work_manager_url: config.get('workManagerUrl'), + opportunity_type: getCopilotTypeLabel(requestData.projectType), + opportunity_title: requestData.opportunityTitle, + start_date: moment.utc(requestData.startDate).format('DD-MM-YYYY'), + user_name: member ? member.handle : "", + }, + sendgrid_template_id: TEMPLATE_IDS.COPILOT_ALREADY_PART_OF_PROJECT, + recipients: [member.email], + version: 'v3', + }, req.log); + + req.log.debug(`Email sent`); + }; + + const existingMember = activeMembers.find(item => item.userId === userId); + if (existingMember) { + req.log.debug(`User already part of project: ${JSON.stringify(existingMember)}`); + if (['copilot', 'manager'].includes(existingMember.role)) { + req.log.debug(`User is a copilot or manager`); + await updateCopilotOpportunity(); + } else { + req.log.debug(`User has read/write role`); + await models.ProjectMember.update({ + role: 'copilot', + }, { + where: { + id: existingMember.id, + }, + }); + + const projectMember = await models.ProjectMember.findOne({ + where: { + id: existingMember.id, + }, + }); + + req.log.debug(`Updated project member: ${JSON.stringify(projectMember.get({plain: true}))}`); + + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_MEMBER_UPDATED, + RESOURCES.PROJECT_MEMBER, + projectMember.get({ plain: true }), + existingMember); + req.log.debug(`Member updated in kafka`); + await updateCopilotOpportunity(); + } + res.status(200).send({ id: applicationId }); + return; } const existingInvite = await models.ProjectMemberInvite.findAll({ diff --git a/src/routes/copilotOpportunity/delete.js b/src/routes/copilotOpportunity/delete.js index 3c6d9bfa..5336807f 100644 --- a/src/routes/copilotOpportunity/delete.js +++ b/src/routes/copilotOpportunity/delete.js @@ -1,10 +1,12 @@ import _ from 'lodash'; +import { Op } from 'sequelize'; import models from '../../models'; import util from '../../util'; -import { COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS } from '../../constants'; +import { COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS, EVENT, INVITE_STATUS, RESOURCES } from '../../constants'; import { PERMISSION } from '../../permissions/constants'; + module.exports = [ (req, res, next) => { if (!util.hasPermissionByReq(PERMISSION.CANCEL_COPILOT_OPPORTUNITY, req)) { @@ -54,6 +56,14 @@ module.exports = [ })); }); + const allInvites = await models.ProjectMemberInvite.findAll({ + where: { + applicationId: { + [Op.in]: applications.map(item => item.id), + }, + }, + }); + await Promise.all(promises); await copilotRequest.update({ @@ -68,6 +78,21 @@ module.exports = [ transaction, }); + // update all the existing invites which are + // associated to the copilot opportunity + // with cancel status + for (const invite of allInvites) { + await invite.update({ + status: INVITE_STATUS.CANCELED, + }); + await invite.reload(); + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_UPDATED, + RESOURCES.PROJECT_MEMBER_INVITE, + invite.toJSON()); + } + res.status(200).send({ id: opportunity.id }); }) diff --git a/src/routes/copilotOpportunity/list.js b/src/routes/copilotOpportunity/list.js index 772eba28..9a806290 100644 --- a/src/routes/copilotOpportunity/list.js +++ b/src/routes/copilotOpportunity/list.js @@ -21,6 +21,25 @@ module.exports = [ const pageSize = parseInt(req.query.pageSize, 10) || DEFAULT_PAGE_SIZE; const offset = (page - 1) * pageSize; const limit = pageSize; + const noGroupingByStatus = req.query.noGrouping === 'true'; + + const baseOrder = []; + + // If grouping is enabled (default), add custom ordering based on status + if (!noGroupingByStatus) { + baseOrder.push([ + models.Sequelize.literal(` + CASE + WHEN "CopilotOpportunity"."status" = 'active' THEN 0 + WHEN "CopilotOpportunity"."status" = 'cancelled' THEN 1 + WHEN "CopilotOpportunity"."status" = 'completed' THEN 2 + ELSE 3 + END + `), + 'ASC', + ]); + } + baseOrder.push([sortParams[0], sortParams[1]]); return models.CopilotOpportunity.findAll({ include: [ @@ -34,7 +53,7 @@ module.exports = [ attributes: ['name'], }, ], - order: [[sortParams[0], sortParams[1]]], + order: baseOrder, limit, offset, }) diff --git a/src/routes/copilotOpportunityApply/create.js b/src/routes/copilotOpportunityApply/create.js index 4bfbea9f..2364e034 100644 --- a/src/routes/copilotOpportunityApply/create.js +++ b/src/routes/copilotOpportunityApply/create.js @@ -8,10 +8,11 @@ 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'; +import { getCopilotTypeLabel } from '../../utils/copilot'; const applyCopilotRequestValidations = { body: Joi.object().keys({ - notes: Joi.string().optional(), + notes: Joi.string().required(), }), }; @@ -41,6 +42,12 @@ module.exports = [ where: { id: copilotOpportunityId, }, + include: [ + { + model: models.CopilotRequest, + as: 'copilotRequest', + }, + ], }).then(async (opportunity) => { if (!opportunity) { const err = new Error('No opportunity found'); @@ -92,12 +99,15 @@ module.exports = [ const emailEventType = CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL; const copilotPortalUrl = config.get('copilotPortalUrl'); + const requestData = opportunity.copilotRequest.data; listOfSubjects.forEach((subject) => { createEvent(emailEventType, { data: { user_name: subject.handle, opportunity_details_url: `${copilotPortalUrl}/opportunity/${opportunity.id}#applications`, work_manager_url: config.get('workManagerUrl'), + opportunity_type: getCopilotTypeLabel(requestData.projectType), + opportunity_title: requestData.opportunityTitle, }, sendgrid_template_id: TEMPLATE_IDS.APPLY_COPILOT, recipients: [subject.email], diff --git a/src/routes/copilotOpportunityApply/list.js b/src/routes/copilotOpportunityApply/list.js index 69aea8fe..6051b027 100644 --- a/src/routes/copilotOpportunityApply/list.js +++ b/src/routes/copilotOpportunityApply/list.js @@ -31,19 +31,68 @@ module.exports = [ canAccessAllApplications ? {} : { createdBy: userId }, ); - return models.CopilotApplication.findAll({ - where: whereCondition, - include: [ - { - model: models.CopilotOpportunity, - as: 'copilotOpportunity', - }, - ], - order: [[sortParams[0], sortParams[1]]], + return models.CopilotOpportunity.findOne({ + where: { + id: opportunityId, + } + }).then((opportunity) => { + if (!opportunity) { + const err = new Error('No opportunity found'); + err.status = 404; + throw err; + } + return models.CopilotApplication.findAll({ + where: whereCondition, + include: [ + { + model: models.CopilotOpportunity, + as: 'copilotOpportunity', + }, + ], + order: [[sortParams[0], sortParams[1]]], + }) + .then(copilotApplications => { + req.log.debug(`CopilotApplications ${JSON.stringify(copilotApplications)}`); + return models.ProjectMember.getActiveProjectMembers(opportunity.projectId).then((members) => { + req.log.debug(`Fetched existing active members ${JSON.stringify(members)}`); + req.log.debug(`Applications ${JSON.stringify(copilotApplications)}`); + const enrichedApplications = copilotApplications.map(application => { + const m = members.find(m => m.userId === application.userId); + + // Using spread operator fails in lint check + // While Object.assign fails silently during run time + // So using this method + const enriched = { + id: application.id, + opportunityId: application.opportunityId, + notes: application.notes, + status: application.status, + userId: application.userId, + deletedAt: application.deletedAt, + createdAt: application.createdAt, + updatedAt: application.updatedAt, + deletedBy: application.deletedBy, + createdBy: application.createdBy, + updatedBy: application.updatedBy, + copilotOpportunity: application.copilotOpportunity, + }; + + if (m) { + enriched.existingMembership = m; + } + + req.log.debug(`Existing member to application ${JSON.stringify(enriched)}`); + + return enriched; + }); + + req.log.debug(`Enriched Applications ${JSON.stringify(enrichedApplications)}`); + res.status(200).send(enrichedApplications); + }); + }) }) - .then(copilotApplications => res.json(copilotApplications)) - .catch((err) => { - util.handleError('Error fetching copilot applications', err, req, next); - }); + .catch((err) => { + util.handleError('Error fetching copilot applications', err, req, next); + }); }, ]; diff --git a/src/routes/copilotRequest/approveRequest.service.js b/src/routes/copilotRequest/approveRequest.service.js index fc0663d5..5ef4d2c1 100644 --- a/src/routes/copilotRequest/approveRequest.service.js +++ b/src/routes/copilotRequest/approveRequest.service.js @@ -1,10 +1,13 @@ import _ from 'lodash'; import config from 'config'; +import moment from 'moment'; +import { Op } from 'sequelize'; import models from '../../models'; -import { CONNECT_NOTIFICATION_EVENT, COPILOT_REQUEST_STATUS, TEMPLATE_IDS, USER_ROLE } from '../../constants'; +import { CONNECT_NOTIFICATION_EVENT, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS, TEMPLATE_IDS, USER_ROLE } from '../../constants'; import util from '../../util'; import { createEvent } from '../../services/busApi'; +import { getCopilotTypeLabel } from '../../utils/copilot'; const resolveTransaction = (transaction, callback) => { if (transaction) { @@ -15,7 +18,7 @@ const resolveTransaction = (transaction, callback) => { }; module.exports = (req, data, existingTransaction) => { - const { projectId, copilotRequestId } = data; + const { projectId, copilotRequestId, opportunityTitle, type, startDate } = data; return resolveTransaction(existingTransaction, transaction => models.Project.findOne({ @@ -42,11 +45,14 @@ module.exports = (req, data, existingTransaction) => { where: { projectId, type: data.type, + status: { + [Op.in]: [COPILOT_OPPORTUNITY_STATUS.ACTIVE], + } }, }) .then((existingCopilotOpportunityOfSameType) => { if (existingCopilotOpportunityOfSameType) { - const err = new Error('There\'s an opportunity of same type already!'); + const err = new Error('There\'s an active opportunity of same type already!'); _.assign(err, { status: 403, }); @@ -61,18 +67,25 @@ module.exports = (req, data, existingTransaction) => { 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); - }); + + const sendNotification = (userName, recipient) => createEvent(emailEventType, { + data: { + user_name: userName, + opportunity_details_url: `${copilotPortalUrl}/opportunity/${opportunity.id}`, + work_manager_url: config.get('workManagerUrl'), + opportunity_type: getCopilotTypeLabel(type), + opportunity_title: opportunityTitle, + start_date: moment(startDate).format("DD-MM-YYYY"), + }, + sendgrid_template_id: TEMPLATE_IDS.CREATE_REQUEST, + recipients: [recipient], + version: 'v3', + }, req.log); + + subjects.forEach(subject => sendNotification(subject.handle, subject.email)); + + // send email to notify via slack + sendNotification('Copilots', config.copilotsSlackEmail); req.log.info("Finished sending emails to copilots"); diff --git a/src/routes/copilotRequest/create.js b/src/routes/copilotRequest/create.js index 2b05f524..52b640ea 100644 --- a/src/routes/copilotRequest/create.js +++ b/src/routes/copilotRequest/create.js @@ -14,6 +14,7 @@ const addCopilotRequestValidations = { data: Joi.object() .keys({ projectId: Joi.number().required(), + opportunityTitle: Joi.string().required(), copilotUsername: Joi.string(), complexity: Joi.string().valid('low', 'medium', 'high').required(), requiresCommunication: Joi.string().valid('yes', 'no').required(), @@ -97,6 +98,8 @@ module.exports = [ createdBy: req.authUser.userId, updatedBy: req.authUser.userId, type: copilotRequest.data.projectType, + opportunityTitle: copilotRequest.data.opportunityTitle, + startDate: copilotRequest.data.startDate, }); return approveRequest(req, approveData, transaction).then(() => copilotRequest); }).then(copilotRequest => res.status(201).json(copilotRequest)) diff --git a/src/routes/copilotRequest/get.js b/src/routes/copilotRequest/get.js index 6284b227..9102317a 100644 --- a/src/routes/copilotRequest/get.js +++ b/src/routes/copilotRequest/get.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,16 +15,10 @@ module.exports = [ return next(err); } - const isAdmin = util.hasRoles(req, ADMIN_ROLES); - - const userId = req.authUser.userId; const copilotRequestId = _.parseInt(req.params.copilotRequestId); // Admin can see all requests and the PM can only see requests created by them - const whereCondition = _.assign({}, - isAdmin ? {} : { createdBy: userId }, - { id: copilotRequestId }, - ); + const whereCondition = { id: copilotRequestId }; return models.CopilotRequest.findOne({ where: whereCondition, diff --git a/src/routes/copilotRequest/update.js b/src/routes/copilotRequest/update.js new file mode 100644 index 00000000..712a590e --- /dev/null +++ b/src/routes/copilotRequest/update.js @@ -0,0 +1,115 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; +import { COPILOT_OPPORTUNITY_TYPE, COPILOT_REQUEST_STATUS } from '../../constants'; +import { PERMISSION } from '../../permissions/constants'; +import { Op } from 'sequelize'; + +const updateCopilotRequestValidations = { + body: Joi.object().keys({ + data: Joi.object() + .keys({ + projectId: Joi.number().required(), + copilotUsername: Joi.string(), + complexity: Joi.string().valid('low', 'medium', 'high'), + requiresCommunication: Joi.string().valid('yes', 'no'), + paymentType: Joi.string().valid('standard', 'other'), + otherPaymentType: Joi.string(), + opportunityTitle: Joi.string(), + projectType: Joi.string().valid(_.values(COPILOT_OPPORTUNITY_TYPE)), + overview: Joi.string().min(10), + skills: Joi.array().items( + Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), + }), + ), + startDate: Joi.date().iso(), + numWeeks: Joi.number().integer().positive(), + tzRestrictions: Joi.string(), + numHoursPerWeek: Joi.number().integer().positive(), + }) + .required(), + }), +}; + +module.exports = [ + validate(updateCopilotRequestValidations), + async (req, res, next) => { + const copilotRequestId = _.parseInt(req.params.copilotRequestId); + const patchData = req.body.data; + + if (!util.hasPermissionByReq(PERMISSION.MANAGE_COPILOT_REQUEST, req)) { + const err = new Error('Unable to update copilot request'); + _.assign(err, { + details: JSON.stringify({ message: 'You do not have permission to update copilot request' }), + status: 403, + }); + util.handleError('Permission error', err, req, next); + return; + } + + try { + const copilotRequest = await models.CopilotRequest.findOne({ + where: { id: copilotRequestId }, + }); + + if (!copilotRequest) { + const err = new Error(`Copilot request not found for id ${copilotRequestId}`); + err.status = 404; + throw err; + } + + if (['canceled', 'fulfilled'].includes(copilotRequest.status)) { + const err = new Error(`Copilot request with status ${copilotRequest.status} cannot be updated!`); + err.status = 400; + throw err; + } + + // check if same type of copilot request already exists + if (patchData.projectType !== undefined && patchData.projectType !== copilotRequest.data.projectType) { + const sameTypeRequest = await models.CopilotRequest.findOne({ + where: { + projectId: copilotRequest.projectId, + status: { + [Op.in]: [COPILOT_REQUEST_STATUS.NEW, COPILOT_REQUEST_STATUS.APPROVED, COPILOT_REQUEST_STATUS.SEEKING], + }, + data: { + projectType: patchData.projectType, + }, + id: { [Op.not]: copilotRequestId }, + }, + }); + + if (sameTypeRequest) { + const err = new Error('There\'s a request of same type already!'); + _.assign(err, { + status: 400, + }); + throw err; + } + } + + // if type changes, make sure we update "type" on opportunity as well + if (patchData.projectType) { + patchData.type = patchData.projectType; + } + + // Only update fields provided in patchData + await copilotRequest.update(_.assign({ + data: _.assign(copilotRequest.data, patchData), + updatedBy: req.authUser.userId, + })); + + res.status(200).json(copilotRequest); + } catch (err) { + if (err.message) { + _.assign(err, { details: err.message }); + } + util.handleError('Error updating copilot request', err, req, next); + } + }, +]; diff --git a/src/routes/index.js b/src/routes/index.js index fd069fcd..87e48a86 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -396,7 +396,8 @@ router.route('/v5/projects/:projectId(\\d+)/settings') router.route('/v5/projects/copilots/requests') .get(require('./copilotRequest/list')); router.route('/v5/projects/copilots/requests/:copilotRequestId(\\d+)') - .get(require('./copilotRequest/get')); + .get(require('./copilotRequest/get')) + .patch(require('./copilotRequest/update')); router.route('/v5/projects/:projectId(\\d+)/copilots/requests') .get(require('./copilotRequest/list')) .post(require('./copilotRequest/create')); diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index 99f4334a..973fb16d 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -5,6 +5,7 @@ import _ from 'lodash'; import Joi from 'joi'; import config from 'config'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import { Op } from 'sequelize'; import models from '../../models'; import util from '../../util'; import { @@ -299,7 +300,7 @@ module.exports = [ return []; }) - .then((inviteUsers) => { + .then(async (inviteUsers) => { const members = req.context.currentProjectMembers; const projectId = _.parseInt(req.params.projectId); // check user handle exists in returned result @@ -322,13 +323,39 @@ module.exports = [ const errorMessageForAlreadyMemberUser = 'User with such handle is already a member of the team.'; if (inviteUserIds) { - // remove members already in the team + const existingMembers = _.filter(members, (m) => { + return inviteUserIds.includes(m.userId); + }); + + req.log.debug(`Existing members: ${JSON.stringify(existingMembers)}`); + + const projectMembers = await models.ProjectMember.findAll({ + where: { + userId: { + [Op.in]: existingMembers.map(item => item.userId), + }, + projectId, + } + }); + + req.log.debug(`Existing Project Members: ${JSON.stringify(projectMembers)}`); + + const existingProjectMembersMap = projectMembers.reduce((acc, current) => { + return Object.assign({}, acc, { + [current.userId]: current, + }); + }, {}); + + req.log.debug(`Existing Project Members Map: ${JSON.stringify(existingProjectMembersMap)}`); + _.remove(inviteUserIds, u => _.some(members, (m) => { const isPresent = m.userId === u; if (isPresent) { failed.push(_.assign({}, { handle: getUserHandleById(m.userId, inviteUsers), message: errorMessageForAlreadyMemberUser, + error: "ALREADY_MEMBER", + role: existingProjectMembersMap[m.userId].role, })); } return isPresent; diff --git a/src/routes/projectMemberInvites/update.js b/src/routes/projectMemberInvites/update.js index 9f21b1c8..19414817 100644 --- a/src/routes/projectMemberInvites/update.js +++ b/src/routes/projectMemberInvites/update.js @@ -2,11 +2,15 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { Op } from 'sequelize'; +import config from 'config'; + import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { INVITE_STATUS, EVENT, RESOURCES, COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS, INVITE_SOURCE } from '../../constants'; +import { INVITE_STATUS, EVENT, RESOURCES, COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS, INVITE_SOURCE, CONNECT_NOTIFICATION_EVENT, TEMPLATE_IDS, USER_ROLE } from '../../constants'; import { PERMISSION } from '../../permissions/constants'; +import { getCopilotTypeLabel } from '../../utils/copilot'; +import { createEvent } from '../../services/busApi'; /** @@ -46,7 +50,7 @@ module.exports = [ // get invite by id and project id return models.ProjectMemberInvite.getPendingOrRequestedProjectInviteById(projectId, inviteId) - .then((invite) => { + .then(async (invite) => { // if invite doesn't exist, return 404 if (!invite) { const err = new Error(`invite not found for project id ${projectId}, inviteId ${inviteId},` + @@ -81,6 +85,31 @@ module.exports = [ return next(err); } + // Check if the copilot opportunity is still active + // When the invited user tries to accept the invite + if (invite.applicationId) { + req.log.debug(`Invite from copilot application: ${invite.applicationId}`); + const application = await models.CopilotApplication.findOne({ + where: { + id: invite.applicationId, + } + }); + + const opportunity = await models.CopilotOpportunity.findOne({ + where: { + id: application.opportunityId, + }, + }); + + req.log.debug(`Copilot opportunity status: ${opportunity.status}`); + if (opportunity.status !== COPILOT_OPPORTUNITY_STATUS.ACTIVE) { + req.log.debug(`Copilot opportunity status is not active`); + const err = new Error('The copilot opportunity is not in active status'); + err.status = 409; + return next(err); + } + } + req.log.debug('Updating invite status'); return invite .update({ @@ -263,6 +292,68 @@ module.exports = [ }) } + if (source === 'copilot_portal' && invite.applicationId) { + const application = await models.CopilotApplication.findOne({ + where: { + id: invite.applicationId, + }, + }); + + const opportunity = await models.CopilotOpportunity.findOne({ + where: { + id: application.opportunityId, + }, + include: [ + { + model: models.CopilotRequest, + as: 'copilotRequest', + }, + ], + }); + 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 creatorDetails = await util.getMemberDetailsByUserIds([opportunity.createdBy], req.log, req.id); + const inviteeDetails = await util.getMemberDetailsByUserIds([application.userId], req.log, req.id); + const creator = creatorDetails[0]; + const invitee = inviteeDetails[0]; + const listOfSubjects = subjects; + if (creator && creator.email) { + const isCreatorPartofSubjects = subjects.find(item => { + if (!item.email) { + return false; + } + + return item.email.toLowerCase() === creator.email.toLowerCase(); + }); + if (!isCreatorPartofSubjects) { + listOfSubjects.push({ + email: creator.email, + handle: creator.handle, + }); + } + } + + const emailEventType = CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL; + const copilotPortalUrl = config.get('copilotPortalUrl'); + const requestData = opportunity.copilotRequest.data; + listOfSubjects.forEach((subject) => { + createEvent(emailEventType, { + data: { + user_name: subject.handle, + opportunity_details_url: `${copilotPortalUrl}/opportunity/${opportunity.id}#applications`, + work_manager_url: config.get('workManagerUrl'), + opportunity_type: getCopilotTypeLabel(requestData.projectType), + opportunity_title: requestData.opportunityTitle, + copilot_handle: invitee ? invitee.handle : "", + }, + sendgrid_template_id: TEMPLATE_IDS.INFORM_PM_COPILOT_APPLICATION_ACCEPTED, + recipients: [subject.email], + version: 'v3', + }, req.log); + }); + } + await t.commit(); return res.json(util.postProcessInvites('$.email', updatedInvite, req)); } catch (e) { diff --git a/src/routes/projectMembers/update.js b/src/routes/projectMembers/update.js index 48ba6946..882ddfdc 100644 --- a/src/routes/projectMembers/update.js +++ b/src/routes/projectMembers/update.js @@ -2,11 +2,17 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; +import config from 'config'; +import moment from 'moment'; +import { Op } from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { EVENT, RESOURCES, PROJECT_MEMBER_ROLE } from '../../constants'; +import { EVENT, RESOURCES, PROJECT_MEMBER_ROLE, COPILOT_REQUEST_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_APPLICATION_STATUS, USER_ROLE, CONNECT_NOTIFICATION_EVENT, TEMPLATE_IDS } from '../../constants'; import { PERMISSION, PROJECT_TO_TOPCODER_ROLES_MATRIX } from '../../permissions/constants'; +import { createEvent } from '../../services/busApi'; +import { getCopilotTypeLabel } from '../../utils/copilot'; + /** * API to update a project member. @@ -27,12 +33,138 @@ const updateProjectMemberValdiations = { PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, PROJECT_MEMBER_ROLE.PROJECT_MANAGER, ).required(), + action: Joi.string().allow('').optional(), }), query: { fields: Joi.string().optional(), }, }; +const completeAllCopilotRequests = async (req, projectId, _transaction, _member) => { + const allCopilotRequests = await models.CopilotRequest.findAll({ + where: { + projectId, + status: { + [Op.in]: [ + COPILOT_REQUEST_STATUS.APPROVED, + COPILOT_REQUEST_STATUS.NEW, + COPILOT_REQUEST_STATUS.SEEKING, + ], + } + }, + transaction: _transaction, + }); + + req.log.debug(`all copilot requests ${JSON.stringify(allCopilotRequests)}`); + + await models.CopilotRequest.update({ + status: COPILOT_REQUEST_STATUS.FULFILLED, + }, { + where: { + id: { + [Op.in]: allCopilotRequests.map(item => item.id), + } + }, + transaction: _transaction, + }); + + req.log.debug(`updated all copilot requests`); + + const copilotOpportunites = await models.CopilotOpportunity.findAll({ + where: { + copilotRequestId: { + [Op.in]: allCopilotRequests.map(item => item.id), + }, + }, + transaction: _transaction, + }); + + req.log.debug(`all copilot opportunities ${JSON.stringify(copilotOpportunites)}`); + + await models.CopilotOpportunity.update({ + status: COPILOT_OPPORTUNITY_STATUS.COMPLETED, + }, { + where: { + id: { + [Op.in]: copilotOpportunites.map(item => item.id), + } + }, + transaction: _transaction, + }); + + req.log.debug(`updated all copilot opportunities`); + + const allCopilotApplications = await models.CopilotApplication.findAll({ + where: { + opportunityId: { + [Op.in]: copilotOpportunites.map(item => item.id), + }, + }, + transaction: _transaction, + }); + + const memberApplication = allCopilotApplications.find(app => app.userId === _member.userId); + const applicationsWithoutMemberApplication = allCopilotApplications.filter(app => app.userId !== _member.userId); + + req.log.debug(`all copilot applications ${JSON.stringify(allCopilotApplications)}`); + + await models.CopilotApplication.update({ + status: COPILOT_APPLICATION_STATUS.CANCELED, + }, { + where: { + id: { + [Op.in]: applicationsWithoutMemberApplication.map(item => item.id), + }, + }, + transaction: _transaction, + }); + + // If the invited member + if (memberApplication) { + await models.CopilotApplication.update({ + status: COPILOT_APPLICATION_STATUS.ACCEPTED, + }, { + where: { + id: memberApplication.id, + }, + transaction: _transaction, + }); + } + + req.log.debug(`updated all copilot applications`); + + const memberDetails = await util.getMemberDetailsByUserIds([_member.userId], req.log, req.id); + const member = memberDetails[0]; + + req.log.debug(`member details: ${JSON.stringify(member)}`); + + const emailEventType = CONNECT_NOTIFICATION_EVENT.EXTERNAL_ACTION_EMAIL; + const copilotPortalUrl = config.get('copilotPortalUrl'); + allCopilotRequests.forEach((request) => { + const requestData = request.data; + + req.log.debug(`Copilot request data: ${JSON.stringify(requestData)}`); + const opportunity = copilotOpportunites.find(item => item.copilotRequestId === request.id); + + req.log.debug(`Opportunity: ${JSON.stringify(opportunity)}`); + createEvent(emailEventType, { + data: { + opportunity_details_url: `${copilotPortalUrl}/opportunity/${opportunity.id}`, + work_manager_url: config.get('workManagerUrl'), + opportunity_type: getCopilotTypeLabel(requestData.projectType), + opportunity_title: requestData.opportunityTitle, + start_date: moment.utc(requestData.startDate).format('DD-MM-YYYY'), + user_name: member ? member.handle : "", + }, + sendgrid_template_id: TEMPLATE_IDS.COPILOT_ALREADY_PART_OF_PROJECT, + recipients: [member.email], + version: 'v3', + }, req.log); + + req.log.debug(`Sent email to ${member.email}`); + }); +}; + module.exports = [ // handles request validations validate(updateProjectMemberValdiations), @@ -45,15 +177,16 @@ module.exports = [ let updatedProps = req.body; const projectId = _.parseInt(req.params.projectId); const memberRecordId = _.parseInt(req.params.id); + const action = updatedProps.action; updatedProps = _.pick(updatedProps, ['isPrimary', 'role']); const fields = req.query.fields ? req.query.fields.split(',') : null; let previousValue; // let newValue; - models.sequelize.transaction(() => models.ProjectMember.findOne({ + models.sequelize.transaction(async (_transaction) => models.ProjectMember.findOne({ where: { id: memberRecordId, projectId }, }) - .then((_member) => { + .then(async (_member) => { if (!_member) { // handle 404 const err = new Error(`project member not found for project id ${projectId} ` + @@ -76,10 +209,13 @@ module.exports = [ return Promise.reject(err); } + req.log.debug(`updated props ${JSON.stringify(updatedProps)}`); + req.log.debug(`previous values ${JSON.stringify(previousValue)}`); // no updates if no change - if (updatedProps.role === previousValue.role && + if ((updatedProps.role === previousValue.role || action === 'complete-copilot-requests') && (_.isUndefined(updatedProps.isPrimary) || updatedProps.isPrimary === previousValue.isPrimary)) { + await completeAllCopilotRequests(req, projectId, _transaction, _member); return Promise.resolve(); } @@ -121,9 +257,13 @@ module.exports = [ }); }) .then(() => projectMember.reload(projectMember.id)) - .then(() => { + .then(async () => { projectMember = projectMember.get({ plain: true }); projectMember = _.omit(projectMember, ['deletedAt']); + + if (['observer', 'customer'].includes(previousValue.role) && ['copilot', 'manager'].includes(updatedProps.role)) { + await completeAllCopilotRequests(req, projectId, _transaction, projectMember); + } }) .then(() => ( util.getObjectsWithMemberDetails([projectMember], fields, req) @@ -145,6 +285,9 @@ module.exports = [ req.log.debug('updated project member', projectMember); res.json(memberWithDetails || projectMember); }) - .catch(err => next(err))); + .catch(async (err) => { + await _transaction.rollback(); + return next(err); + })); }, ]; diff --git a/src/utils/copilot.js b/src/utils/copilot.js new file mode 100644 index 00000000..b7a722b7 --- /dev/null +++ b/src/utils/copilot.js @@ -0,0 +1,16 @@ +import { COPILOT_OPPORTUNITY_TYPE } from "../constants"; + +export const getCopilotTypeLabel = (type) => { + switch (type) { + case COPILOT_OPPORTUNITY_TYPE.AI: + return 'AI'; + case COPILOT_OPPORTUNITY_TYPE.DATA_SCIENCE: + return "Data Science"; + case COPILOT_OPPORTUNITY_TYPE.DESIGN: + return "Design"; + case COPILOT_OPPORTUNITY_TYPE.DEV: + return "Development"; + default: + return "Quality Assurance"; + } +};