diff --git a/config/default.js b/config/default.js index 1a012a6d..bf498d2e 100644 --- a/config/default.js +++ b/config/default.js @@ -142,6 +142,8 @@ module.exports = { TAAS_ROLE_UPDATE_TOPIC: process.env.TAAS_ROLE_UPDATE_TOPIC || 'taas.role.update', // the delete role entity Kafka message topic TAAS_ROLE_DELETE_TOPIC: process.env.TAAS_ROLE_DELETE_TOPIC || 'taas.role.delete', + // the create team entity message topic, only used for eventHandler + TAAS_TEAM_CREATE_TOPIC: process.env.TAAS_TEAM_CREATE_TOPIC || 'taas.team.create', // special kafka topics TAAS_ACTION_RETRY_TOPIC: process.env.TAAS_ACTION_RETRY_TOPIC || 'taas.action.retry', @@ -161,6 +163,10 @@ module.exports = { // INTERVIEW_INVITATION_RECIPIENTS_LIST may contain comma-separated list of email which is converted to array // scheduler@x.ai should be in the RECIPIENTS list INTERVIEW_INVITATION_RECIPIENTS_LIST: (process.env.INTERVIEW_INVITATION_RECIPIENTS_LIST || 'scheduler@topcoder.com').split(','), + // the emails address for overlapping interview + NOTIFICATION_OPS_EMAILS: (process.env.NOTIFICATION_OPS_EMAILS || 'overlapping@topcoder.com').split(','), + // the slack channel for sending notifications + NOTIFICATION_SLACK_CHANNEL: process.env.NOTIFICATION_SLACK_CHANNEL || '#dev-general', // SendGrid email template ID for reporting issue REPORT_ISSUE_SENDGRID_TEMPLATE_ID: process.env.REPORT_ISSUE_SENDGRID_TEMPLATE_ID, // SendGrid email template ID for requesting extension @@ -268,5 +274,7 @@ module.exports = { RESOURCE_BOOKING_EXPIRY_TIME: process.env.RESOURCE_BOOKING_EXPIRY_TIME || 'P21D', // The Stripe STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, - CURRENCY: process.env.CURRENCY || 'usd' + CURRENCY: process.env.CURRENCY || 'usd', + // The slack webhook url to send slack notifications + SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL } diff --git a/config/email_template.config.js b/config/email_template.config.js index c76bd482..06ea4fb0 100644 --- a/config/email_template.config.js +++ b/config/email_template.config.js @@ -152,6 +152,41 @@ module.exports = { recipients: [], from: config.NOTIFICATION_SENDER_EMAIL, sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.team-created': { + subject: 'Topcoder - Team Created', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.job-created': { + subject: 'Topcoder - Job Created', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.interviews-overlapping': { + subject: 'Topcoder - Interviews overlapping', + body: '', + recipients: config.NOTIFICATION_OPS_EMAILS, + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.job-candidate-selected': { + subject: 'Topcoder - Job Candidate Selected', + body: '', + recipients: config.NOTIFICATION_OPS_EMAILS, + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.resource-booking-placed': { + subject: 'Topcoder - Resource Booking Placed', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID } } } diff --git a/data/notifications-email-template.html b/data/notifications-email-template.html index 7dcb8f30..fd213800 100644 --- a/data/notifications-email-template.html +++ b/data/notifications-email-template.html @@ -240,6 +240,96 @@ {{/each}} {{/if}} + {{#if notificationType.newTeamCreated}} + Team Name: + {{teamName}} + + + + + + + {{#each jobList}} + + + + + + {{/each}} +
Job TitleDurationStart Date
{{this.title}}{{this.duration}}{{this.startDate}}
+ {{/if}} + {{#if notificationType.newJobCreated}} + + + + + + + + + + + + + +
Team NameJob titleDurationStart Date
{{teamName}}{{jobTitle}}{{jobDuration}}{{jobStartDate}}
+ {{/if}} + {{#if notificationType.overlappingInterview}} + + + + + + + + + {{#each interviews}} + + + + + + + + {{/each}} +
Team NameJob titleJob CandidateInterview Start DateInterview End Date
{{this.teamName}}{{this.jobTitle}}{{this.candidateUserHandle}}{{this.startTime}}{{this.endTime}}
+ {{/if}} + {{#if notificationType.candidateSelected}} + + + + + + + + + + + + + + + +
Team NameJob titleJob Start DateJob DurationCandidate User Handle
{{teamName}}{{jobTitle}}{{jobStartDate}}{{jobDuration}}{{userHandle}}
+ {{/if}} + {{#if notificationType.resourceBookingPlaced}} + + + + + + + + + + + + + + + +
Team NameJob TitleResource Bookings HandleResource Bookings Start DateResource Bookings End Date
{{teamName}}{{jobTitle}}{{userHandle}}{{startDate}}{{endDate}}
+ {{/if}}
If you have any questions about this process or if you encounter any issues coordinating your availability, you may reply to this email or send a separate email to our Gig Work operations team. diff --git a/docs/Topcoder-bookings-api.postman_collection.json b/docs/Topcoder-bookings-api.postman_collection.json index 47e81133..e20d62bf 100644 --- a/docs/Topcoder-bookings-api.postman_collection.json +++ b/docs/Topcoder-bookings-api.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "709e8fdf-f5f4-4053-a679-b89504637cc8", + "_postman_id": "99905d78-e7c8-42ee-8aca-2c7fe3f8df4a", "name": "Topcoder-bookings-api", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -33806,6 +33806,9 @@ } ], "request": { + "auth": { + "type": "noauth" + }, "method": "POST", "header": [ { @@ -34297,6 +34300,326 @@ } }, "response": [] + }, + { + "name": "[New Team created]", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_administrator}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"teamName\": \"New Team created notification\",\r\n \"teamDescription\":\"New Team created notification description\",\r\n \"positions\": [\r\n {\r\n \"roleName\": \"Dev Ops Engineer\",\r\n \"roleSearchRequestId\": \"{{roleSearchRequestId1-1}}\",\r\n \"numberOfResources\": 10,\r\n \"durationWeeks\": 7,\r\n \"startMonth\": \"2021-08-15\"\r\n },\r\n {\r\n \"roleName\": \"Salesforce Developer\",\r\n \"roleSearchRequestId\": \"{{roleSearchRequestId1-2}}\",\r\n \"numberOfResources\": 5,\r\n \"durationWeeks\": 5,\r\n \"startMonth\": \"2021-08-15\"\r\n }\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/taas-teams/submitTeamRequest", + "host": [ + "{{URL}}" + ], + "path": [ + "taas-teams", + "submitTeamRequest" + ] + } + }, + "response": [] + }, + { + "name": "[New Job created]", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var data = JSON.parse(responseBody);", + "postman.setEnvironmentVariable(\"jobId\",data.id);" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_administrator}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"projectId\": {{projectId}},\r\n \"externalId\": \"1212\",\r\n \"description\": \"Dummy Description\",\r\n \"startDate\": \"2020-09-27T04:17:23.131Z\",\r\n \"duration\": 1,\r\n \"numPositions\": 13,\r\n \"resourceType\": \"Dummy Resource Type\",\r\n \"rateType\": \"hourly\",\r\n \"workload\": \"full-time\",\r\n \"skills\": [\r\n \"23e00d92-207a-4b5b-b3c9-4c5662644941\",\r\n \"7d076384-ccf6-4e43-a45d-1b24b1e624aa\",\r\n \"cbac57a3-7180-4316-8769-73af64893158\",\r\n \"a2b4bc11-c641-4a19-9eb7-33980378f82e\"\r\n ],\r\n \"title\": \"Dummy title - at most 64 characters\",\r\n \"minSalary\": 100,\r\n \"maxSalary\": 200,\r\n \"hoursPerWeek\": 20,\r\n \"jobLocation\": \"Any location\",\r\n \"jobTimezone\": \"GMT\",\r\n \"currency\": \"USD\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobs", + "host": [ + "{{URL}}" + ], + "path": [ + "jobs" + ] + } + }, + "response": [] + }, + { + "name": "[Overlapping Interview Invites prepare]", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_administrator}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"startTimestamp\": \"2022-08-12T12:54:54.948Z\",\r\n \"endTimestamp\": \"2022-08-12T13:24:54.948Z\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates/827ee401-df04-42e1-abbe-7b97ce7937ff/updateInterview/2", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "827ee401-df04-42e1-abbe-7b97ce7937ff", + "updateInterview", + "2" + ] + } + }, + "response": [] + }, + { + "name": "[Overlapping Interview Invites]", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_administrator}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"startTimestamp\": \"2022-08-12T13:00:54.948Z\",\r\n \"endTimestamp\": \"2022-08-12T13:30:54.948Z\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates/a4ea7bcf-5b99-4381-b99c-a9bd05d83a36/updateInterview/3", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "a4ea7bcf-5b99-4381-b99c-a9bd05d83a36", + "updateInterview", + "3" + ] + } + }, + "response": [] + }, + { + "name": "[Job Candidate is Selected]", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_administrator}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"jobId\": \"{{jobId}}\",\r\n \"userId\": \"b15feb1a-5a9d-456a-86d9-5fd8fad3ac04\",\r\n \"externalId\": \"300234321\",\r\n \"resume\": \"http://example.com\",\r\n \"remark\": \"Job Candidate Notifications Test remark {{testCaseNumber}}\",\r\n \"status\": \"selected\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates" + ] + } + }, + "response": [] + }, + { + "name": "[Resource Booking is Placed]", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_administrator}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"projectId\": {{projectId}},\r\n \"userId\": \"a55fe1bc-1754-45fa-9adc-cf3d6d7c377a\",\r\n \"jobId\": \"{{jobId}}\",\r\n \"startDate\": \"2020-09-27\",\r\n \"endDate\": \"2020-10-27\",\r\n \"memberRate\": 13.23,\r\n \"customerRate\": 13,\r\n \"rateType\": \"hourly\",\r\n \"billingAccountId\": 80000071\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/resourceBookings", + "host": [ + "{{URL}}" + ], + "path": [ + "resourceBookings" + ] + } + }, + "response": [] } ] } diff --git a/scripts/demo-email-notifications/README.md b/scripts/demo-email-notifications/README.md index 3a5dbd47..e55e275d 100644 --- a/scripts/demo-email-notifications/README.md +++ b/scripts/demo-email-notifications/README.md @@ -18,19 +18,24 @@ This script does 2 things: INTERVIEW_COMING_UP_MATCH_WINDOW=PT1M INTERVIEW_COMPLETED_MATCH_WINDOW=PT1M ``` +2. Config `SLACK_WEBHOOK_URL` env, if you want to send slack notifications -2. Recreate demo data by: + ```sh + SLACK_WEBHOOK_URL=https://hooks.slack.com/services/*** + ``` + +3. Recreate demo data by: ```sh npm run local:init` -3. Run TaaS API by: +4. Run TaaS API by: ```sh npm run dev ``` -4. Run this demo script: +5. Run this demo script: ```sh node scripts/demo-email-notifications diff --git a/scripts/demo-email-notifications/index.js b/scripts/demo-email-notifications/index.js index 23f3ac9f..761541ff 100644 --- a/scripts/demo-email-notifications/index.js +++ b/scripts/demo-email-notifications/index.js @@ -1,6 +1,8 @@ const Kafka = require('no-kafka') const fs = require('fs') const config = require('config') +const axios = require('axios') +const _ = require('lodash') const moment = require('moment') const handlebars = require('handlebars') const logger = require('../../src/common/logger') @@ -64,10 +66,15 @@ async function initConsumer () { fs.mkdirSync('out') } if (message.payload.notifications) { - message.payload.notifications.forEach((notification) => { + _.forEach(_.filter(message.payload.notifications, ['serviceId', 'email']), (notification) => { const email = template(notification.details.data) fs.writeFileSync(`./out/${notification.details.data.subject}-${Date.now()}.html`, email) }) + for (const notification of _.filter(message.payload.notifications, ['serviceId', 'slack'])) { + if (config.SLACK_WEBHOOK_URL) { + await axios.post(config.SLACK_WEBHOOK_URL, { text: notification.details.text, blocks: notification.details.blocks }) + } + } } } } diff --git a/src/common/helper.js b/src/common/helper.js index 1fd28f60..5f96e172 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -2041,6 +2041,19 @@ function getEmailTemplatesForKey (key) { }) } +/** + * Format date to be used in email + * + * @param {Date} date date to be formatted + * @returns {String} formatted date + */ +function formatDate (date) { + if (date) { + const tzName = date.toLocaleString('en', { timeZoneName: 'short' }).split(' ').pop() + return `${moment(date).format('MMM D, YYYY, h:mm:ss a')} ${tzName}` + } +} + module.exports = { encodeQueryString, getParamFromCliArgs, @@ -2104,5 +2117,6 @@ module.exports = { getMemberGroups, removeTextFormatting, getMembersSuggest, - getEmailTemplatesForKey + getEmailTemplatesForKey, + formatDate } diff --git a/src/controllers/TeamController.js b/src/controllers/TeamController.js index e34fa943..2f3911d9 100644 --- a/src/controllers/TeamController.js +++ b/src/controllers/TeamController.js @@ -160,8 +160,8 @@ async function suggestMembers (req, res) { * @param req the request * @param res the response */ - async function calculateAmount(req, res) { - res.send(await service.calculateAmount(req.body)); +async function calculateAmount (req, res) { + res.send(await service.calculateAmount(req.body)) } /** @@ -169,8 +169,8 @@ async function suggestMembers (req, res) { * @param req the request * @param res the response */ -async function createPayment(req, res) { - res.send(await service.createPayment(req.body.totalAmount)); +async function createPayment (req, res) { + res.send(await service.createPayment(req.body.totalAmount)) } module.exports = { diff --git a/src/eventHandlers/InterviewEventHandler.js b/src/eventHandlers/InterviewEventHandler.js index 44d055a7..48bad2f2 100644 --- a/src/eventHandlers/InterviewEventHandler.js +++ b/src/eventHandlers/InterviewEventHandler.js @@ -2,9 +2,14 @@ * Handle events for Interview. */ +const { Op } = require('sequelize') +const _ = require('lodash') +const config = require('config') const models = require('../models') -// const logger = require('../common/logger') +const logger = require('../common/logger') +const helper = require('../common/helper') const teamService = require('../services/TeamService') +const Constants = require('../../app-constants') /** * Once we request Interview for a JobCandidate, the invitation emails to be sent out. @@ -33,6 +38,124 @@ async function sendInvitationEmail (payload) { }) } +/** + * Check if there is overlapping interview, if there is overlapping, then send notifications. + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function checkOverlapping (payload) { + const interview = payload.value + if (_.includes([Constants.Interviews.Status.Cancelled, Constants.Interviews.Status.Completed], interview.status)) { + return + } + const overlappingInterview = await models.Interview.findAll({ + where: { + [Op.and]: [{ + status: _.values(_.omit(Constants.Interviews.Status, 'Completed', 'Cancelled')) + }, { + [Op.or]: [{ + startTimestamp: { + [Op.lt]: interview.endTimestamp, + [Op.gte]: interview.startTimestamp + } + }, { + endTimestamp: { + [Op.lte]: interview.endTimestamp, + [Op.gt]: interview.startTimestamp + } + }, { + [Op.and]: [{ + startTimestamp: { + [Op.lt]: interview.startTimestamp + } + }, { + endTimestamp: { + [Op.gt]: interview.endTimestamp + } + }] + }] + }] + } + }) + if (_.size(overlappingInterview) > 1) { + const template = helper.getEmailTemplatesForKey('notificationEmailTemplates')['taas.notification.interviews-overlapping'] + const jobCandidates = await models.JobCandidate.findAll({ where: { id: _.map(overlappingInterview, 'jobCandidateId') } }) + const jobs = await models.Job.findAll({ where: { id: _.uniq(_.map(jobCandidates, 'jobId')) } }) + + const interviews = [] + for (const oli of overlappingInterview) { + const jobCandidate = _.find(jobCandidates, { id: oli.jobCandidateId }) + const job = _.find(jobs, { id: jobCandidate.jobId }) + const project = await helper.getProjectById({ isMachine: true }, job.projectId) + const user = await helper.getUserById(jobCandidate.userId) + interviews.push({ + teamName: project.name, + teamURL: `${config.TAAS_APP_URL}/${project.id}`, + jobTitle: job.title, + jobURL: `${config.TAAS_APP_URL}/${project.id}/positions/${job.id}`, + candidateUserHandle: user.handle, + startTime: helper.formatDate(oli.startTimestamp), + endTime: helper.formatDate(oli.endTimestamp) + }) + } + + const emailData = { + serviceId: 'email', + type: 'taas.notification.interviews-overlapping', + details: { + from: template.from, + recipients: template.recipients, + data: { + subject: template.subject, + interviews, + notificationType: { + overlappingInterview: true + }, + description: 'Overlapping Interview Invites' + }, + sendgridTemplateId: template.sendgridTemplateId, + version: 'v3' + } + } + const slackData = { + serviceId: 'slack', + type: 'taas.notification.interviews-overlapping', + details: { + channel: config.NOTIFICATION_SLACK_CHANNEL, + text: template.subject, + blocks: _.flatMap(interviews, iv => [{ + type: 'context', + elements: [{ + type: 'mrkdwn', + text: `teamName: <${iv.teamURL}|*${iv.teamName}*>` + }, { + type: 'mrkdwn', + text: `jobTitle: <${iv.jobURL}|*${iv.jobTitle}*>` + }, { + type: 'mrkdwn', + text: `candidateUserHandle: *${iv.candidateUserHandle}*` + }, { + type: 'mrkdwn', + text: `startTime: *${helper.formatDate(iv.startTime)}*` + }, { + type: 'mrkdwn', + text: `endTime: *${helper.formatDate(iv.endTime)}*` + }] + }, { type: 'divider' }]) + } + } + await helper.postEvent(config.NOTIFICATIONS_CREATE_TOPIC, { + notifications: [emailData, slackData] + }) + logger.debug({ + component: 'InterviewEventHandler', + context: 'checkOverlapping', + message: `interviewIds: ${_.join(_.map(overlappingInterview, 'id'), ',')}` + }) + } +} + /** * Process interview request event. * @@ -41,8 +164,20 @@ async function sendInvitationEmail (payload) { */ async function processRequest (payload) { await sendInvitationEmail(payload) + await checkOverlapping(payload) +} + +/** + * Process interview update event. + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function processUpdate (payload) { + await checkOverlapping(payload) } module.exports = { - processRequest + processRequest, + processUpdate } diff --git a/src/eventHandlers/JobCandidateEventHandler.js b/src/eventHandlers/JobCandidateEventHandler.js index 2ad9005b..424f8fd1 100644 --- a/src/eventHandlers/JobCandidateEventHandler.js +++ b/src/eventHandlers/JobCandidateEventHandler.js @@ -136,6 +136,84 @@ async function processCreate (payload) { if (payload.value.status === 'placed') { await withDrawnJobCandidates(payload) } + if (payload.value.status === 'selected') { + await sendJobCandidateSelectedNotification(payload) + } +} + +/** + * Send job candidate selected notification. + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function sendJobCandidateSelectedNotification (payload) { + const jobCandidate = payload.value + const job = await models.Job.findById(jobCandidate.jobId) + const user = await helper.getUserById(jobCandidate.userId) + const template = helper.getEmailTemplatesForKey('notificationEmailTemplates')['taas.notification.job-candidate-selected'] + const project = await helper.getProjectById({ isMachine: true }, job.projectId) + const jobUrl = `${config.TAAS_APP_URL}/${project.id}/positions/${job.id}` + const teamUrl = `${config.TAAS_APP_URL}/${project.id}` + const emailData = { + serviceId: 'email', + type: 'taas.notification.job-candidate-selected', + details: { + from: template.from, + recipients: template.recipients, + data: { + subject: template.subject, + teamName: project.name, + teamUrl, + jobTitle: job.title, + jobDuration: job.duration, + jobStartDate: helper.formatDate(job.startDate), + userHandle: user.handle, + jobUrl, + notificationType: { + candidateSelected: true + }, + description: 'Job Candidate is Selected' + }, + sendgridTemplateId: template.sendgridTemplateId, + version: 'v3' + } + } + const slackData = { + serviceId: 'slack', + type: 'taas.notification.job-candidate-selected', + details: { + channel: config.NOTIFICATION_SLACK_CHANNEL, + text: template.subject, + blocks: [{ + type: 'context', + elements: [{ + type: 'mrkdwn', + text: `teamName: <${teamUrl}|*${project.name}*>` + }, { + type: 'mrkdwn', + text: `jobTitle: <${jobUrl}|*${job.title}*>` + }, { + type: 'mrkdwn', + text: `jobDuration: *${job.duration}*` + }, { + type: 'mrkdwn', + text: `jobStartDate: *${helper.formatDate(job.startDate)}*` + }, { + type: 'mrkdwn', + text: `userHandle: *${user.handle}*` + }] + }] + } + } + await helper.postEvent(config.NOTIFICATIONS_CREATE_TOPIC, { + notifications: [emailData, slackData] + }) + logger.debug({ + component: 'JobCandidateEventHandler', + context: 'sendJobCandidateSelectedNotification', + message: `teamName: ${project.name}, jobTitle: ${payload.value.title}, jobDuration: ${payload.value.duration}, jobStartDate: ${payload.value.startDate}` + }) } /** @@ -149,6 +227,9 @@ async function processUpdate (payload) { if (payload.value.status === 'placed' && payload.options.oldValue.status !== 'placed') { await withDrawnJobCandidates(payload) } + if (payload.value.status === 'selected' && payload.options.oldValue.status !== 'selected') { + await sendJobCandidateSelectedNotification(payload) + } } module.exports = { diff --git a/src/eventHandlers/JobEventHandler.js b/src/eventHandlers/JobEventHandler.js index e1938de2..50a2f537 100644 --- a/src/eventHandlers/JobEventHandler.js +++ b/src/eventHandlers/JobEventHandler.js @@ -3,6 +3,8 @@ */ const { Op } = require('sequelize') +const config = require('config') +const _ = require('lodash') const models = require('../models') const logger = require('../common/logger') const helper = require('../common/helper') @@ -65,6 +67,67 @@ async function processUpdate (payload) { await cancelJob(payload) } +/** + * When Job is created, send notification to user. + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function sendNotifications (payload) { + if (payload.options.onTeamCreating) { + logger.debug({ + component: 'JobEventHandler', + context: 'jobCreate', + message: 'skip these jobs which are created together with the Team' + }) + return + } + const template = helper.getEmailTemplatesForKey('notificationEmailTemplates')['taas.notification.job-created'] + const project = await helper.getProjectById({ isMachine: true }, payload.value.projectId) + const emailData = { + serviceId: 'email', + type: 'taas.notification.job-created', + details: { + from: template.from, + recipients: _.map(project.members, m => _.pick(m, 'email')), + data: { + subject: template.subject, + teamName: project.name, + teamURL: `${config.TAAS_APP_URL}/${project.id}`, + jobTitle: payload.value.title, + jobURL: `${config.TAAS_APP_URL}/${project.id}/positions/${payload.value.id}`, + jobDuration: payload.value.duration, + jobStartDate: helper.formatDate(payload.value.startDate), + notificationType: { + newJobCreated: true + }, + description: 'New Job created' + }, + sendgridTemplateId: template.sendgridTemplateId, + version: 'v3' + } + } + await helper.postEvent(config.NOTIFICATIONS_CREATE_TOPIC, { + notifications: [emailData] + }) + logger.debug({ + component: 'JobEventHandler', + context: 'jobCreate', + message: `teamName: ${project.name}, jobTitle: ${payload.value.title}, jobDuration: ${payload.value.duration}, jobStartDate: ${payload.value.startDate}` + }) +} + +/** + * Process job create event. + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function processCreate (payload) { + await sendNotifications(payload) +} + module.exports = { - processUpdate + processUpdate, + processCreate } diff --git a/src/eventHandlers/ResourceBookingEventHandler.js b/src/eventHandlers/ResourceBookingEventHandler.js index 067deb75..5a1f15a0 100644 --- a/src/eventHandlers/ResourceBookingEventHandler.js +++ b/src/eventHandlers/ResourceBookingEventHandler.js @@ -4,6 +4,7 @@ const { Op } = require('sequelize') const _ = require('lodash') +const config = require('config') const models = require('../models') const logger = require('../common/logger') const helper = require('../common/helper') @@ -69,6 +70,68 @@ async function placeJobCandidate (payload) { }))) } +/** + * When ResourceBooking's status is changed to `placed` + * send notifications to user + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function sendPlacedNotifications (payload) { + if (payload.value.status !== 'placed' || _.get(payload, 'options.oldValue.status') === 'placed') { + return + } + const resourceBooking = await models.ResourceBooking.findById(payload.value.id) + const template = helper.getEmailTemplatesForKey('notificationEmailTemplates')['taas.notification.resource-booking-placed'] + const project = await helper.getProjectById({ isMachine: true }, resourceBooking.projectId) + const user = await helper.getUserById(resourceBooking.userId) + const job = await models.Job.findById(resourceBooking.jobId) + const recipients = _.map(project.members, m => _.pick(m, 'email')) + const jobUrl = `${config.TAAS_APP_URL}/${project.id}/positions/${job.id}` + const teamUrl = `${config.TAAS_APP_URL}/${project.id}` + const emailData = { + serviceId: 'email', + type: 'taas.notification.resource-booking-placed', + details: { + from: template.from, + recipients, + data: { + subject: template.subject, + teamName: project.name, + teamUrl, + jobTitle: job.title, + jobUrl, + userHandle: user.handle, + startDate: resourceBooking.startDate, + endDate: resourceBooking.endDate, + notificationType: { + resourceBookingPlaced: true + }, + description: 'Resource Booking is Placed' + }, + sendgridTemplateId: template.sendgridTemplateId, + version: 'v3' + } + } + const webData = { + serviceId: 'web', + type: 'taas.notification.resource-booking-placed', + details: { + recipients, + contents: { teamName: project.name, projectId: project.id, userHandle: user.handle, jobTitle: job.title }, + version: 1 + } + } + await helper.postEvent(config.NOTIFICATIONS_CREATE_TOPIC, { + notifications: [emailData, webData] + }) + logger.debug({ + component: 'ResourceBookingEventHandler', + context: 'placeJobCandidate', + message: `send notifications, teamName: ${project.name}, jobTitle: ${job.title}, projectId: ${project.id}, userHandle: ${user.handle}` + }) +} + /** * Update the status of the Job to assigned when it positions requirement is fulfilled. * @@ -341,6 +404,7 @@ async function processCreate (payload) { await placeJobCandidate(payload) await assignJob(payload) await createWorkPeriods(payload) + await sendPlacedNotifications(payload) } /** @@ -353,6 +417,7 @@ async function processUpdate (payload) { await placeJobCandidate(payload) await assignJob(payload) await updateWorkPeriods(payload) + await sendPlacedNotifications(payload) } /** diff --git a/src/eventHandlers/TeamEventHandler.js b/src/eventHandlers/TeamEventHandler.js new file mode 100644 index 00000000..51ad266b --- /dev/null +++ b/src/eventHandlers/TeamEventHandler.js @@ -0,0 +1,65 @@ +/* + * Handle events for Team. + */ + +const _ = require('lodash') +const config = require('config') +const logger = require('../common/logger') +const helper = require('../common/helper') + +/** + * Once we create a team, the notification emails to be sent out. + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function sendNotificationEmail (payload) { + const template = helper.getEmailTemplatesForKey('notificationEmailTemplates')['taas.notification.team-created'] + const emailData = { + serviceId: 'email', + type: 'taas.notification.team-created', + details: { + from: template.from, + recipients: _.map(payload.project.members, m => _.pick(m, 'email')), + data: { + subject: template.subject, + teamName: payload.project.name, + teamUrl: `${config.TAAS_APP_URL}/${payload.project.id}`, + jobList: _.map(payload.jobs, j => ({ + title: j.title, + duration: j.duration, + startDate: helper.formatDate(j.startDate), + jobUrl: `${config.TAAS_APP_URL}/${payload.project.id}/positions/${j.id}` + })), + notificationType: { + newTeamCreated: true + }, + description: 'New Team created' + }, + sendgridTemplateId: template.sendgridTemplateId, + version: 'v3' + } + } + await helper.postEvent(config.NOTIFICATIONS_CREATE_TOPIC, { + notifications: [emailData] + }) + logger.debug({ + component: 'TeamEventHandler', + context: 'sendNotificationEmail', + message: `project id: ${payload.project.id} created with jobs: ${_.join(_.map(payload.jobs, 'id'), ',')}` + }) +} + +/** + * Process team creating event. + * + * @param {Object} payload the event payload + * @returns {undefined} + */ +async function processCreate (payload) { + await sendNotificationEmail(payload) +} + +module.exports = { + processCreate +} diff --git a/src/eventHandlers/index.js b/src/eventHandlers/index.js index 3f308b18..1992fc61 100644 --- a/src/eventHandlers/index.js +++ b/src/eventHandlers/index.js @@ -10,9 +10,11 @@ const ResourceBookingEventHandler = require('./ResourceBookingEventHandler') const InterviewEventHandler = require('./InterviewEventHandler') const RoleEventHandler = require('./RoleEventHandler') const WorkPeriodPaymentEventHandler = require('./WorkPeriodPaymentEventHandler') +const TeamEventHandler = require('./TeamEventHandler') const logger = require('../common/logger') const TopicOperationMapping = { + [config.TAAS_JOB_CREATE_TOPIC]: JobEventHandler.processCreate, [config.TAAS_JOB_UPDATE_TOPIC]: JobEventHandler.processUpdate, [config.TAAS_JOB_CANDIDATE_CREATE_TOPIC]: JobCandidateEventHandler.processCreate, [config.TAAS_JOB_CANDIDATE_UPDATE_TOPIC]: JobCandidateEventHandler.processUpdate, @@ -22,7 +24,9 @@ const TopicOperationMapping = { [config.TAAS_WORK_PERIOD_PAYMENT_CREATE_TOPIC]: WorkPeriodPaymentEventHandler.processCreate, [config.TAAS_WORK_PERIOD_PAYMENT_UPDATE_TOPIC]: WorkPeriodPaymentEventHandler.processUpdate, [config.TAAS_INTERVIEW_REQUEST_TOPIC]: InterviewEventHandler.processRequest, - [config.TAAS_ROLE_DELETE_TOPIC]: RoleEventHandler.processDelete + [config.TAAS_INTERVIEW_UPDATE_TOPIC]: InterviewEventHandler.processUpdate, + [config.TAAS_ROLE_DELETE_TOPIC]: RoleEventHandler.processDelete, + [config.TAAS_TEAM_CREATE_TOPIC]: TeamEventHandler.processCreate } /** diff --git a/src/routes/TeamRoutes.js b/src/routes/TeamRoutes.js index afc4bf87..2dfd79c6 100644 --- a/src/routes/TeamRoutes.js +++ b/src/routes/TeamRoutes.js @@ -108,20 +108,20 @@ module.exports = { scopes: [] } }, - "/taas-teams/calculateAmount": { + '/taas-teams/calculateAmount': { post: { - controller: "TeamController", - method: "calculateAmount", + controller: 'TeamController', + method: 'calculateAmount', auth: 'jwt', scopes: [constants.Scopes.CREATE_TAAS_TEAM] - }, + } }, - "/taas-teams/createPayment": { + '/taas-teams/createPayment': { post: { - controller: "TeamController", - method: "createPayment", + controller: 'TeamController', + method: 'createPayment', auth: 'jwt', scopes: [constants.Scopes.CREATE_TAAS_TEAM] - }, -} + } + } } diff --git a/src/services/JobService.js b/src/services/JobService.js index 4a183783..7e38227c 100644 --- a/src/services/JobService.js +++ b/src/services/JobService.js @@ -163,7 +163,7 @@ getJob.schema = Joi.object().keys({ * @params {Object} job the job to be created * @returns {Object} the created job */ -async function createJob (currentUser, job) { +async function createJob (currentUser, job, onTeamCreating) { // check user permission if (!currentUser.hasManagePermission && !currentUser.isMachine) { await helper.checkIsMemberOfProject(currentUser.userId, job.projectId) @@ -183,7 +183,7 @@ async function createJob (currentUser, job) { job.createdBy = await helper.getUserId(currentUser.userId) const created = await Job.create(job) - await helper.postEvent(config.TAAS_JOB_CREATE_TOPIC, created.toJSON()) + await helper.postEvent(config.TAAS_JOB_CREATE_TOPIC, created.toJSON(), { onTeamCreating }) return created.toJSON() } @@ -213,7 +213,8 @@ createJob.schema = Joi.object() currency: Joi.stringAllowEmpty().allow(null), roleIds: Joi.array().items(Joi.string().uuid().required()) }) - .required() + .required(), + onTeamCreating: Joi.boolean().default(false) }) .required() diff --git a/src/services/TeamService.js b/src/services/TeamService.js index f6cd6612..ef6a45dc 100644 --- a/src/services/TeamService.js +++ b/src/services/TeamService.js @@ -9,6 +9,7 @@ const config = require('config') const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') +const eventDispatcher = require('../common/eventDispatcher') const JobService = require('./JobService') const ResourceBookingService = require('./ResourceBookingService') const HttpStatus = require('http-status-codes') @@ -19,7 +20,7 @@ const { getAuditM2Muser } = require('../common/helper') const { matchedSkills, unMatchedSkills } = require('../../scripts/emsi-mapping/esmi-skills-mapping') const Role = models.Role const RoleSearchRequest = models.RoleSearchRequest -const stripe = require("stripe")(config.STRIPE_SECRET_KEY,{maxNetworkRetries: 5}); +const stripe = require('stripe')(config.STRIPE_SECRET_KEY, { maxNetworkRetries: 5 }) const emailTemplates = helper.getEmailTemplatesForKey('teamTemplates') @@ -1041,7 +1042,7 @@ async function createTeam (currentUser, data) { // create project with given data const project = await helper.createProject(currentUser, projectRequestBody) // create jobs for the given positions. - await Promise.all(_.map(data.positions, async position => { + const jobs = await Promise.all(_.map(data.positions, async position => { const roleSearchRequest = roleSearchRequests[position.roleSearchRequestId] const job = { projectId: project.id, @@ -1061,8 +1062,9 @@ async function createTeam (currentUser, data) { if (position.durationWeeks) { job.duration = position.durationWeeks } - await JobService.createJob(currentUser, job) + return await JobService.createJob(currentUser, job, true) })) + await eventDispatcher.handleEvent(config.TAAS_TEAM_CREATE_TOPIC, { project, jobs }) return { projectId: project.id } } @@ -1163,10 +1165,9 @@ suggestMembers.schema = Joi.object().keys({ * @param {Object} amount * @returns {int} totalAmount */ - async function calculateAmount(amount) { - let totalAmount = 0; - _.forEach(amount, amt => totalAmount += amt.numberOfResources * amt.rate) - return { totalAmount }; +async function calculateAmount (amount) { + const totalAmount = _.sum(_.map(amount, amt => amt.numberOfResources * amt.rate)) + return { totalAmount } } /** @@ -1174,16 +1175,15 @@ suggestMembers.schema = Joi.object().keys({ * @param {int} totalAmount * @returns {string} paymentIntentToken */ -async function createPayment(totalAmount) { - const dollarToCents = (totalAmount*100); +async function createPayment (totalAmount) { + const dollarToCents = (totalAmount * 100) const paymentIntent = await stripe.paymentIntents.create({ amount: dollarToCents, - currency: config.CURRENCY, - }); - return { paymentIntentToken: paymentIntent.client_secret }; + currency: config.CURRENCY + }) + return { paymentIntentToken: paymentIntent.client_secret } } - module.exports = { searchTeams, getTeam,