diff --git a/app-constants.js b/app-constants.js index 83dcbdb2..78bab1f3 100644 --- a/app-constants.js +++ b/app-constants.js @@ -152,6 +152,14 @@ const PaymentSchedulerStatus = { CLOSE_CHALLENGE: 'close-challenge' } +const JobStatus = { + OPEN: 'open' +} + +const JobCandidateStatus = { + INTERVIEW: 'interview' +} + module.exports = { UserRoles, FullManagePermissionRoles, @@ -164,5 +172,7 @@ module.exports = { PaymentSchedulerStatus, PaymentProcessingSwitch, PaymentStatusRules, - ActiveWorkPeriodPaymentStatuses + ActiveWorkPeriodPaymentStatuses, + JobStatus, + JobCandidateStatus } diff --git a/app.js b/app.js index 08395ffc..3e60e0e8 100644 --- a/app.js +++ b/app.js @@ -15,6 +15,7 @@ const eventHandlers = require('./src/eventHandlers') const interviewService = require('./src/services/InterviewService') const { processScheduler } = require('./src/services/PaymentSchedulerService') const { sendSurveys } = require('./src/services/SurveyService') +const emailNotificationService = require('./src/services/EmailNotificationService') // setup express app const app = express() @@ -103,6 +104,12 @@ const server = app.listen(app.get('port'), () => { schedule.scheduleJob(config.WEEKLY_SURVEY.CRON, sendSurveys) // schedule payment processing schedule.scheduleJob(config.PAYMENT_PROCESSING.CRON, processScheduler) + + schedule.scheduleJob(config.CRON_CANDIDATE_REVIEW, emailNotificationService.sendCandidatesAvailableEmails) + schedule.scheduleJob(config.CRON_INTERVIEW_COMING_UP, emailNotificationService.sendInterviewComingUpEmails) + schedule.scheduleJob(config.CRON_INTERVIEW_COMPLETED, emailNotificationService.sendInterviewCompletedEmails) + schedule.scheduleJob(config.CRON_POST_INTERVIEW, emailNotificationService.sendPostInterviewActionEmails) + schedule.scheduleJob(config.CRON_UPCOMING_RESOURCE_BOOKING, emailNotificationService.sendResourceBookingExpirationEmails) }) if (process.env.NODE_ENV === 'test') { diff --git a/config/default.js b/config/default.js index b2a51bea..217eab62 100644 --- a/config/default.js +++ b/config/default.js @@ -147,6 +147,8 @@ module.exports = { // the Kafka message topic for sending email EMAIL_TOPIC: process.env.EMAIL_TOPIC || 'external.action.email', + // the Kafka message topic for creating notifications + NOTIFICATIONS_CREATE_TOPIC: process.env.NOTIFICATIONS_CREATE_TOPIC || 'notifications.action.create', // the emails address for receiving the issue report // REPORT_ISSUE_EMAILS may contain comma-separated list of email which is converted to array REPORT_ISSUE_EMAILS: (process.env.REPORT_ISSUE_EMAILS || '').split(','), @@ -237,5 +239,25 @@ module.exports = { interview: 'withdrawn', selected: 'withdrawn', offered: 'withdrawn' - } + }, + // the sender email + NOTIFICATION_SENDER_EMAIL: process.env.NOTIFICATION_SENDER_EMAIL, + // the email notification sendgrid template id + NOTIFICATION_SENDGRID_TEMPLATE_ID: process.env.NOTIFICATION_SENDGRID_TEMPLATE_ID, + // hours after interview completed when we should post the notification + INTERVIEW_COMPLETED_NOTIFICATION_HOURS: process.env.INTERVIEW_COMPLETED_NOTIFICATION_HOURS || 4, + // no of weeks before expiry when we should post the notification + RESOURCE_BOOKING_EXPIRY_NOTIFICATION_WEEKS: process.env.RESOURCE_BOOKING_EXPIRY_NOTIFICATION_WEEKS || 3, + // frequency of cron checking for available candidates for review + CRON_CANDIDATE_REVIEW: process.env.CRON_CANDIDATE_REVIEW || '00 00 13 * * 0-6', + // frequency of cron checking for coming up interviews + // when changing this to frequency other than 5 mins, please change the minutesRange in sendInterviewComingUpEmails correspondingly + CRON_INTERVIEW_COMING_UP: process.env.CRON_INTERVIEW_COMING_UP || '*/5 * * * *', + // frequency of cron checking for interview completed + // when changing this to frequency other than 5 mins, please change the minutesRange in sendInterviewCompletedEmails correspondingly + CRON_INTERVIEW_COMPLETED: process.env.CRON_INTERVIEW_COMPLETED || '*/5 * * * *', + // frequency of cron checking for post interview actions + CRON_POST_INTERVIEW: process.env.CRON_POST_INTERVIEW || '00 00 13 * * 0-6', + // frequency of cron checking for upcoming resource bookings + CRON_UPCOMING_RESOURCE_BOOKING: process.env.CRON_UPCOMING_RESOURCE_BOOKING || '00 00 13 * * 1' } diff --git a/config/email_template.config.js b/config/email_template.config.js index e223ce95..c76bd482 100644 --- a/config/email_template.config.js +++ b/config/email_template.config.js @@ -6,99 +6,152 @@ const config = require('config') module.exports = { - /* Report a general issue for a team. - * - * - projectId: the project ID. Example: 123412 - * - projectName: the project name. Example: "TaaS API Misc Updates" - * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" + /** + * List all the kind of emails which could be sent by the endpoint `POST /taas-teams/email` inside `teamTemplates`. */ - 'team-issue-report': { - subject: 'Issue Reported on TaaS Team {{projectName}} ({{projectId}}).', - body: 'Project Name: {{projectName}}' + '\n' + - 'Project ID: {{projectId}}' + '\n' + - `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + - '\n' + - '{{reportText}}', - recipients: config.REPORT_ISSUE_EMAILS, - sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID - }, + teamTemplates: { + /* Report a general issue for a team. + * + * - projectId: the project ID. Example: 123412 + * - projectName: the project name. Example: "TaaS API Misc Updates" + * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" + */ + 'team-issue-report': { + subject: 'Issue Reported on TaaS Team {{projectName}} ({{projectId}}).', + body: 'Project Name: {{projectName}}' + '\n' + + 'Project ID: {{projectId}}' + '\n' + + `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + + '\n' + + '{{reportText}}', + recipients: config.REPORT_ISSUE_EMAILS, + sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID + }, - /* Report issue for a particular member - * - * - userHandle: the user handle. Example: "bili_2021" - * - projectId: the project ID. Example: 123412 - * - projectName: the project name. Example: "TaaS API Misc Updates" - * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" - */ - 'member-issue-report': { - subject: 'Issue Reported for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', - body: 'User Handle: {{userHandle}}' + '\n' + - 'Project Name: {{projectName}}' + '\n' + - 'Project ID: {{projectId}}' + '\n' + - `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + - '\n' + - '{{reportText}}', - recipients: config.REPORT_ISSUE_EMAILS, - sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID - }, + /* Report issue for a particular member + * + * - userHandle: the user handle. Example: "bili_2021" + * - projectId: the project ID. Example: 123412 + * - projectName: the project name. Example: "TaaS API Misc Updates" + * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" + */ + 'member-issue-report': { + subject: 'Issue Reported for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', + body: 'User Handle: {{userHandle}}' + '\n' + + 'Project Name: {{projectName}}' + '\n' + + 'Project ID: {{projectId}}' + '\n' + + `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + + '\n' + + '{{reportText}}', + recipients: config.REPORT_ISSUE_EMAILS, + sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID + }, - /* Request extension for a particular member - * - * - userHandle: the user handle. Example: "bili_2021" - * - projectId: the project ID. Example: 123412 - * - projectName: the project name. Example: "TaaS API Misc Updates" - * - text: comment for the request. Example: "I would like to keep working with this member for 2 months..." - */ - 'extension-request': { - subject: 'Extension Requested for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', - body: 'User Handle: {{userHandle}}' + '\n' + - 'Project Name: {{projectName}}' + '\n' + - 'Project ID: {{projectId}}' + '\n' + - `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + - '\n' + - '{{text}}', - recipients: config.REPORT_ISSUE_EMAILS, - sendgridTemplateId: config.REQUEST_EXTENSION_SENDGRID_TEMPLATE_ID + /* Request extension for a particular member + * + * - userHandle: the user handle. Example: "bili_2021" + * - projectId: the project ID. Example: 123412 + * - projectName: the project name. Example: "TaaS API Misc Updates" + * - text: comment for the request. Example: "I would like to keep working with this member for 2 months..." + */ + 'extension-request': { + subject: 'Extension Requested for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', + body: 'User Handle: {{userHandle}}' + '\n' + + 'Project Name: {{projectName}}' + '\n' + + 'Project ID: {{projectId}}' + '\n' + + `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + + '\n' + + '{{text}}', + recipients: config.REPORT_ISSUE_EMAILS, + sendgridTemplateId: config.REQUEST_EXTENSION_SENDGRID_TEMPLATE_ID + }, + + /* Request interview for a job candidate + * + * - interviewType: the x.ai interview type. Example: "interview-30" + * - interviewRound: the round of the interview. Example: 2 + * - interviewDuration: duration of the interview, in minutes. Example: 30 + * - interviewerList: The list of interviewer email addresses. Example: "first@attendee.com, second@attendee.com" + * - candidateId: the id of the jobCandidate. Example: "cc562545-7b75-48bf-87e7-50b3c57e41b1" + * - candidateName: Full name of candidate. Example: "John Doe" + * - jobName: The title of the job. Example: "TaaS API Misc Updates" + * + * Template (defined in SendGrid): + * Subject: '{{interviewType}} tech interview with {{candidateName}} for {{jobName}} is requested by the Customer' + * Body: + * 'Hello! + *

+ * Congratulations, you have been selected to participate in a Topcoder Gig Work Interview! + *

+ * Please monitor your email for a response to this where you can coordinate your availability. + *

+ * Interviewee: {{candidateName}}
+ * Interviewer(s): {{interviewerList}}
+ * Interview Length: {{interviewDuration}} minutes + *

+ * /{{interviewType}} + *

+ * Topcoder Info:
+ * Note: "id: {{candidateId}}, round: {{interviewRound}}"' + * + * Note, that the template should be defined in SendGrid. + * The subject & body above (identical to actual SendGrid template) is for reference purposes. + * We won't pass subject & body but only substitutions (replacements in template subject/body). + */ + 'interview-invitation': { + subject: '', + body: '', + from: config.INTERVIEW_INVITATION_SENDER_EMAIL, + cc: config.INTERVIEW_INVITATION_CC_LIST, + recipients: config.INTERVIEW_INVITATION_RECIPIENTS_LIST, + sendgridTemplateId: config.INTERVIEW_INVITATION_SENDGRID_TEMPLATE_ID + } }, - /* Request interview for a job candidate - * - * - interviewType: the x.ai interview type. Example: "interview-30" - * - interviewRound: the round of the interview. Example: 2 - * - interviewDuration: duration of the interview, in minutes. Example: 30 - * - interviewerList: The list of interviewer email addresses. Example: "first@attendee.com, second@attendee.com" - * - candidateId: the id of the jobCandidate. Example: "cc562545-7b75-48bf-87e7-50b3c57e41b1" - * - candidateName: Full name of candidate. Example: "John Doe" - * - jobName: The title of the job. Example: "TaaS API Misc Updates" - * - * Template (defined in SendGrid): - * Subject: '{{interviewType}} tech interview with {{candidateName}} for {{jobName}} is requested by the Customer' - * Body: - * 'Hello! - *

- * Congratulations, you have been selected to participate in a Topcoder Gig Work Interview! - *

- * Please monitor your email for a response to this where you can coordinate your availability. - *

- * Interviewee: {{candidateName}}
- * Interviewer(s): {{interviewerList}}
- * Interview Length: {{interviewDuration}} minutes - *

- * /{{interviewType}} - *

- * Topcoder Info:
- * Note: "id: {{candidateId}}, round: {{interviewRound}}"' - * - * Note, that the template should be defined in SendGrid. - * The subject & body above (identical to actual SendGrid template) is for reference purposes. - * We won't pass subject & body but only substitutions (replacements in template subject/body). + /** + * List all kind of emails which could be send as Email Notifications by scheduler, API endpoints or anything else. */ - 'interview-invitation': { - subject: '', - body: '', - from: config.INTERVIEW_INVITATION_SENDER_EMAIL, - cc: config.INTERVIEW_INVITATION_CC_LIST, - recipients: config.INTERVIEW_INVITATION_RECIPIENTS_LIST, - sendgridTemplateId: config.INTERVIEW_INVITATION_SENDGRID_TEMPLATE_ID + notificationEmailTemplates: { + 'taas.notification.candidates-available-for-review': { + subject: 'Topcoder - {{teamName}} has job candidates available for review', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.interview-coming-up-host': { + subject: 'Topcoder - Interview Coming Up: {{jobTitle}} with {{guestFullName}}', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.interview-coming-up-guest': { + subject: 'Topcoder - Interview Coming Up: {{jobTitle}} with {{hostFullName}}', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.interview-awaits-resolution': { + subject: 'Topcoder - Interview Awaits Resolution: {{jobTitle}} for {{guestFullName}}', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.post-interview-action-required': { + subject: 'Topcoder - Candidate Action Required in {{teamName}} for {{numCandidates}} candidates', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.resource-booking-expiration': { + subject: 'Topcoder - Resource Booking Expiring in {{teamName}} for {{numResourceBookings}} resource bookings', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + } } } diff --git a/data/notifications-email-demo-data.json b/data/notifications-email-demo-data.json new file mode 100644 index 00000000..98c19b5f --- /dev/null +++ b/data/notifications-email-demo-data.json @@ -0,0 +1,232 @@ +{ + "candidatesAvailableForReview": { + "teamName": "the thing three", + "teamJobs": [ + { + "title": "Dummy title - at most 64 characters", + "nResourceBookings": 0, + "jobCandidates": [ + { + "handle": "testfordevemail", + "name": "John Doe", + "status": "open" + } + ], + "reviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/to-review" + }, + { + "title": "Dummy title - at most 64 characters", + "nResourceBookings": 0, + "jobCandidates": [ + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "testfordevemail", + "name": "John Doe", + "status": "open" + } + ], + "reviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/728ff056-63f6-4730-8a9f-3074acad8479/candidates/to-review" + } + ], + "notificationType": { + "candidatesAvailableForReview": true + }, + "description": "Candidates are available for review", + "subject": "Topcoder - the thing three has job candidates available for review", + "body": "" + }, + "interviewComingUpForHost": { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": "host name", + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews", + "notificationType": { + "interviewComingUpForHost": true + }, + "description": "Interview Coming Up", + "subject": "Topcoder - Interview Coming Up: Dummy title - at most 64 characters with name1", + "body": "" + }, + + "interviewComingUpForGuest": { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": "host name", + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews", + "notificationType": { + "interviewComingUpForGuest": true + }, + "description": "Interview Coming Up", + "subject": "Topcoder - Interview Coming Up: Dummy title - at most 64 characters with host name", + "body": "" + }, + + "interviewCompleted": { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": null, + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews", + "notificationType": { + "interviewCompleted": true + }, + "description": "Interview Completed", + "subject": "Topcoder - Interview Awaits Resolution: Dummy title - at most 64 characters for name1", + "body": "" + }, + + "postInterviewCandidateAction": { + "teamName": "the thing three", + "numCandidates": 3, + "teamInterviews": [ + { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": "host name", + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "guest1", + "hostFullName": null, + "candidateName": "pshah manager", + "handle": "pshah_manager", + "attendees": [ + "guest1", + "guest2", + "guest3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 30, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/728ff056-63f6-4730-8a9f-3074acad8479/candidates/interviews" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "g name1", + "hostFullName": null, + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "g name1", + "g name2" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 60, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/728ff056-63f6-4730-8a9f-3074acad8479/candidates/interviews" + } + ], + "notificationType": { + "postInterviewCandidateAction": true + }, + "description": "Post Interview Candidate Action Reminder", + "subject": "Topcoder - Candidate Action Required in the thing three for 3 candidates", + "body": "" + }, + + "upcomingResourceBookingExpiration": { + "teamName": "the thing three", + "numResourceBookings": 6, + "teamResourceBookings": [ + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "testcat", + "endDate": "2021-04-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "sachin-wipro", + "endDate": "2020-10-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "aaaa", + "endDate": "2021-04-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "pshah_manager", + "endDate": "2021-01-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "amy_admin", + "endDate": "2021-06-15" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "lakshmiaconnmgr", + "endDate": "2021-06-15" + } + ], + "notificationType": { + "upcomingResourceBookingExpiration": true + }, + "description": "Upcoming Resource Booking Expiration", + "subject": "Topcoder - Resource Booking Expiring in the thing three for 6 resource bookings", + "body": "" + } +} diff --git a/data/notifications-email-template.html b/data/notifications-email-template.html new file mode 100644 index 00000000..7dcb8f30 --- /dev/null +++ b/data/notifications-email-template.html @@ -0,0 +1,385 @@ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + +
IMG +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ Topcoder Gig Work
+
+ + + + + + + + + +
IMG + {{description}} +
+
+ + + + + + +
+ + + + + + + + +
+ + {{#if notificationType.candidatesAvailableForReview}} + + + + + + + + {{#each teamJobs}} + + + + + + + {{/each}} +
Job titleNo of resource bookingsCandidatesStatus
{{this.title}}{{this.nResourceBookings}} + + +
    + {{#each this.jobCandidates}} +
  • {{this.status}}
  • + {{/each}} +
+
+ {{/if}} + + {{#if notificationType.interviewComingUpForHost}} + + + + + + + + + + + + + + + +
Job titleCandidate HandleInterviewsAttendeesDate and Time
{{this.jobTitle}}{{this.handle}} + Link +
    + {{#each this.attendees}} +
  • {{this}}
  • + {{/each}} +
+
{{this.startTime}}
+ {{/if}} + + {{#if notificationType.interviewComingUpForGuest}} + + + + + + + + + + + +
Job titleDate and TimeInterviews
{{this.jobTitle}}{{this.startTime}} + Link
+ {{/if}} + + {{#if notificationType.interviewCompleted}} + + + + + + + + + + + + + + + +
Job titleCandidate HandleDate and TimeAttendeesInterviews
{{this.jobTitle}}{{this.handle}}{{this.startTime}} +
    + {{#each this.attendees}} +
  • {{this}}
  • + {{/each}} +
+
+ Link
+ {{/if}} + + {{#if notificationType.postInterviewCandidateAction}} + + + + + + + + + {{#each teamInterviews}} + + + + + + + + {{/each}} +
Job titleHandleDate and TimeAttendeesInterviews
{{this.jobTitle}}{{this.handle}}{{this.startTime}} +
    + {{#each this.attendees}} +
  • {{this}}
  • + {{/each}} +
+
+ Link
+ {{/if}} + + {{#if notificationType.upcomingResourceBookingExpiration}} + Team Name: + {{teamName}} + + + + + + + {{#each teamResourceBookings}} + + + + + + {{/each}} +
Job titleResource Bookings HandleEnd Date
{{this.jobTitle}}{{this.handle}}{{this.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. + +

Thanks!
+ The Topcoder Team +

+
+
+
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ 201 S Capitol Ave #1100
+ Indianapolis, IN 46225 United States
+
+ ●●●
+
+ Topcoder System Information: +
+ InterviewType: {{xai_template}}
+
+ + + + + + +
+
+
diff --git a/local/kafka-client/topics.txt b/local/kafka-client/topics.txt index 2611220b..405b2510 100644 --- a/local/kafka-client/topics.txt +++ b/local/kafka-client/topics.txt @@ -21,3 +21,4 @@ taas.interview.update taas.interview.bulkUpdate external.action.email taas.action.retry +notifications.action.create \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3d174e12..dce8640e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3232,6 +3232,27 @@ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -4935,9 +4956,9 @@ } }, "moment": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.0.tgz", - "integrity": "sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA==" + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" }, "moment-timezone": { "version": "0.5.33", @@ -5014,6 +5035,12 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", @@ -7489,6 +7516,13 @@ "is-typedarray": "^1.0.0" } }, + "uglify-js": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.14.0.tgz", + "integrity": "sha512-R/tiGB1ZXp2BC+TkRGLwj8xUZgdfT2f4UZEgX6aVjJ5uttPrr4fYmwTWDGqVnBCLbOXRMY6nr/BTbwCtVfps0g==", + "dev": true, + "optional": true + }, "umzug": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", @@ -7886,6 +7920,12 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, "workerpool": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.0.0.tgz", diff --git a/package.json b/package.json index 8811efb4..a1fe6860 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "http-status-codes": "^2.1.4", "joi": "^17.2.1", "lodash": "^4.17.20", + "moment": "^2.29.1", "node-schedule": "^2.0.0", "pg": "^8.4.0", "pg-hstore": "^2.3.3", @@ -70,6 +71,7 @@ "devDependencies": { "chai": "^4.2.0", "csv-parser": "^3.0.0", + "handlebars": "^4.7.7", "mocha": "^8.1.3", "nodemon": "^2.0.4", "nyc": "^15.1.0", diff --git a/scripts/demo-email-notifications/README.md b/scripts/demo-email-notifications/README.md new file mode 100644 index 00000000..c9a802d9 --- /dev/null +++ b/scripts/demo-email-notifications/README.md @@ -0,0 +1,37 @@ +# Trigger and render demo Email Notifications. + +This script does 2 things: + +- update demo data created by `npm run local:init` inside the DB in such a way that it would create situation for Email Notifications which would be triggered by the scheduler to demonstrate all possible cases. +- start Kafka Consumer that would listen to the Kafka Topic `config.NOTIFICATIONS_CREATE_TOPIC` and if there is email notification created, it would render it using provided email template `data/notifications-email-template.html` into `out` folder. + +## Usage + +1. Config scheduler to run more often so we don't have to wait to long for triggering notification, like every minute: + + ```sh + CRON_CANDIDATE_REVIEW=0 */1 * * * * + CRON_INTERVIEW_COMING_UP=0 */1 * * * * + CRON_INTERVIEW_COMPLETED=0 */1 * * * * + CRON_POST_INTERVIEW=0 */1 * * * * + CRON_UPCOMING_RESOURCE_BOOKING=0 */1 * * * * + ``` + +2. Recreate demo data by: + + ```sh + npm run local:init` + +3. Run TaaS API by: + + ```sh + npm run dev + ``` + +4. Run this demo script: + + ```sh + node scripts/demo-email-notifications + ``` + +Check the rendered emails inside `out` folder. \ No newline at end of file diff --git a/scripts/demo-email-notifications/index.js b/scripts/demo-email-notifications/index.js new file mode 100644 index 00000000..6a508f2b --- /dev/null +++ b/scripts/demo-email-notifications/index.js @@ -0,0 +1,91 @@ +const Kafka = require('no-kafka') +const fs = require('fs') +const config = require('config') +const moment = require('moment') +const handlebars = require('handlebars') +const logger = require('../../src/common/logger') +const { Interview, JobCandidate, ResourceBooking } = require('../../src/models') +const { Interviews } = require('../../app-constants') + +const consumer = new Kafka.GroupConsumer({ connectionString: process.env.KAFKA_URL, groupId: 'test-render-email' }) + +const localLogger = { + debug: message => logger.debug({ component: 'render email content', context: 'test', message }), + info: message => logger.info({ component: 'render email content', context: 'test', message }) +} + +const template = handlebars.compile(fs.readFileSync('./data/notifications-email-template.html', 'utf8')) + +/** + * Reset notification records + */ +async function resetNotificationRecords () { + // reset coming up interview records + localLogger.info('reset coming up interview records') + const interview = await Interview.findById('976d23a9-5710-453f-99d9-f57a588bb610') + const startTimestamp = moment().add(moment.duration(`PT1H`)).add('PT1M').toDate() + await interview.update({ startTimestamp, duration: 30, status: Interviews.Status.Scheduled, guestNames: ['test1', 'test2'], hostName: 'hostName' }) + + // reset completed interview records + localLogger.info('reset completed interview records') + const pastTime = moment.duration('PT1H') + const endTimestamp = moment().subtract(pastTime).toDate() + const completedInterview = await Interview.findById('9efd72c3-1dc7-4ce2-9869-8cca81d0adeb') + const duration = 30 + const completedStartTimestamp = moment().subtract(pastTime).subtract(30, 'm').toDate() + await completedInterview.update({ startTimestamp: completedStartTimestamp, duration, endTimestamp, status: Interviews.Status.Scheduled, guestNames: ['guest1', 'guest2'], hostName: 'hostName' }) + + // reset post interview candidate action reminder records + localLogger.info('reset post interview candidate action reminder records') + const jobCandidate = await JobCandidate.findById('881a19de-2b0c-4bb9-b36a-4cb5e223bdb5') + await jobCandidate.update({ status: 'interview' }) + const c2Interview = await Interview.findById('077aa2ca-5b60-4ad9-a965-1b37e08a5046') + await c2Interview.update({ startTimestamp: completedStartTimestamp, duration, endTimestamp, guestNames: ['guest1', 'guest2'], hostName: 'hostName' }) + + // reset upcoming resource booking expiration records + localLogger.info('reset upcoming resource booking expiration records') + const resourceBooking = await ResourceBooking.findById('62c3f0c9-2bf0-4f24-8647-2c802a39cbcb') + await resourceBooking.update({ endDate: moment().add(1, 'weeks').toDate() }) +} + +/** + * Init consumer. + */ +async function initConsumer () { + await consumer + .init([{ + subscriptions: [config.NOTIFICATIONS_CREATE_TOPIC], + handler: async (messageSet, topic, partition) => { + localLogger.debug(`Consumer handler. Topic: ${topic}, partition: ${partition}, message set length: ${messageSet.length}`) + for (const m of messageSet) { + const message = JSON.parse(m.message.value.toString('utf8')) + if (!fs.existsSync('out')) { + fs.mkdirSync('out') + } + if (message.payload.notifications) { + message.payload.notifications.forEach((notification) => { + const email = template(notification.details.data) + fs.writeFileSync(`./out/${notification.details.data.subject}-${Date.now()}.html`, email) + }) + } + } + } + }]) + .then(() => { + localLogger.info('Initialized.......') + localLogger.info([config.NOTIFICATIONS_CREATE_TOPIC]) + localLogger.info('Kick Start.......') + }).catch(err => { + logger.logFullError(err, { component: 'app' }) + }) +} + +/** + * Main function + */ +async function main () { + await resetNotificationRecords() + await initConsumer() +} + +main() diff --git a/scripts/notification-email-template-renderer/README.md b/scripts/notification-email-template-renderer/README.md new file mode 100644 index 00000000..19819690 --- /dev/null +++ b/scripts/notification-email-template-renderer/README.md @@ -0,0 +1,22 @@ +# Render Email Notification Template with some data + +This script can render SendGrid Email Template (handlebars) `data/notifications-email-template.html` using some data from `data/notifications-email-demo-data.json` into `out/notifications-email-template-with-data.html`. + +## Usage + +Please run + +``` +node scripts/notification-email-template-renderer +``` + +where `` can be one of the keys in `data/notifications-email-demo-data.json` i.e: + +- `candidatesAvailableForReview` +- `interviewComingUpForHost` +- `interviewComingUpForGuest` +- `interviewCompleted` +- `postInterviewCandidateAction` +- `upcomingResourceBookingExpiration` + +The resulting file would be placed into `out/notifications-email-template-with-data.html` \ No newline at end of file diff --git a/scripts/notification-email-template-renderer/index.js b/scripts/notification-email-template-renderer/index.js new file mode 100644 index 00000000..8a95b772 --- /dev/null +++ b/scripts/notification-email-template-renderer/index.js @@ -0,0 +1,32 @@ +/** + * Script for rendering email template + */ +const fs = require('fs') +const Handlebars = require('handlebars') +const path = require('path') + +function render (filename, data) { + const source = fs.readFileSync(filename, 'utf8').toString() + const template = Handlebars.compile(source) + const output = template(data) + return output +} + +const data = JSON.parse(fs.readFileSync(path.join(__dirname, '../../data/notifications-email-demo-data.json'), 'utf8')) + +const key = process.argv.length >= 3 ? process.argv[2] : 'candidatesAvailableForReview' + +if (!data[key]) { + console.error('Please provide a proper key which is present in notifications.json') + process.exit(1) +} + +const outputDir = path.join(__dirname, '../../out') +const outputFile = path.join(__dirname, '../../out/notifications-email-template-with-data.html') +const result = render(path.join(__dirname, '../../data/notifications-email-template.html'), data[key]) +if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir) +} +fs.writeFileSync(outputFile, result) + +console.log(`Template has been rendered to: ${outputFile}`) diff --git a/src/common/helper.js b/src/common/helper.js index 319eb86a..1fd28f60 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -22,6 +22,7 @@ const eventDispatcher = require('./eventDispatcher') const busApi = require('@topcoder-platform/topcoder-bus-api-wrapper') const moment = require('moment') const { PaymentStatusRules } = require('../../app-constants') +const emailTemplateConfig = require('../../config/email_template.config') const localLogger = { debug: (message) => @@ -2020,6 +2021,26 @@ async function getMembersSuggest (fragment) { return res.body } +/** + * Returns the email templates for given key + * @param key the type of email template ex: teamTemplates + * @returns the list of templates for the given key + */ +function getEmailTemplatesForKey (key) { + if (!_.has(emailTemplateConfig, key)) { return [] } + + return _.mapValues(emailTemplateConfig[key], (template) => { + return { + subject: template.subject, + body: template.body, + from: template.from, + recipients: template.recipients, + cc: template.cc, + sendgridTemplateId: template.sendgridTemplateId + } + }) +} + module.exports = { encodeQueryString, getParamFromCliArgs, @@ -2082,5 +2103,6 @@ module.exports = { createProject, getMemberGroups, removeTextFormatting, - getMembersSuggest + getMembersSuggest, + getEmailTemplatesForKey } diff --git a/src/services/EmailNotificationService.js b/src/services/EmailNotificationService.js new file mode 100644 index 00000000..f3b9bad7 --- /dev/null +++ b/src/services/EmailNotificationService.js @@ -0,0 +1,513 @@ +/** + * Email notification service - has the cron handlers for sending different types of email notifications + */ +const _ = require('lodash') +const { Op } = require('sequelize') +const moment = require('moment') +const config = require('config') +const models = require('../models') +const Job = models.Job +const JobCandidate = models.JobCandidate +const Interview = models.Interview +const ResourceBooking = models.ResourceBooking +const helper = require('../common/helper') +const constants = require('../../app-constants') +const logger = require('../common/logger') + +const localLogger = { + debug: (message, context) => logger.debug({ component: 'EmailNotificationService', context, message }), + error: (message, context) => logger.error({ component: 'EmailNotificationService', context, message }), + info: (message, context) => logger.info({ component: 'EmailNotificationService', context, message }) +} + +const emailTemplates = helper.getEmailTemplatesForKey('notificationEmailTemplates') + +/** + * Returns the project with the given id + * @param projectId the project id + * @returns the project + */ +async function getProjectWithId (projectId) { + let project = null + try { + project = await helper.getProjectById(helper.getAuditM2Muser(), projectId) + } catch (err) { + localLogger.error( + `exception fetching project with id: ${projectId} Status Code: ${err.status} message: ${err.response.text}`, 'getProjectWithId') + } + + return project +} + +/** + * extract the members emails from the given project + * @param project the project + * @returns {string[]} array of emails + */ +function getProjectMembersEmails (project) { + let recipientEmails = _.map(_.get(project, 'members', []), member => member.email) + recipientEmails = _.filter(recipientEmails, email => email) + if (_.isEmpty(recipientEmails)) { + localLogger.error(`No recipients for projectId:${project.id}`, 'getProjectMembersEmails') + } + + return recipientEmails +} + +/** + * Gets the user with the given id + * @param userId the user id + * @returns the user + */ +async function getUserWithId (userId) { + let user = null + try { + user = await helper.ensureUserById(userId) + } catch (err) { + localLogger.error( + `exception fetching user with id: ${userId} Status Code: ${err.status} message: ${err.response.text}`, 'getUserWithId') + } + + return user +} + +/** + * returns the data for the interview + * @param interview the interview + * @param jobCandidate optional jobCandidate corresponding to interview + * @param job option job corresponding to interview + * @returns the interview details in format used by client + */ +async function getDataForInterview (interview, jobCandidate, job) { + jobCandidate = jobCandidate || await JobCandidate.findById(interview.jobCandidateId) + + job = job || await Job.findById(jobCandidate.jobId) + + const user = await getUserWithId(jobCandidate.userId) + if (!user) { return null } + + const interviewLink = `${config.TAAS_APP_URL}/${job.projectId}/positions/${job.id}/candidates/interviews` + const guestName = _.isEmpty(interview.guestNames) ? '' : interview.guestNames[0] + const startTime = _.isEmpty(interview.startTimestamp) ? '' : interview.startTimestamp.toUTCString() + + return { + jobTitle: job.title, + guestFullName: guestName, + hostFullName: interview.hostName, + candidateName: `${user.firstName} ${user.lastName}`, + handle: user.handle, + attendees: interview.guestNames, + startTime: startTime, + duration: interview.duration, + interviewLink + } +} + +/** + * Sends email notifications to all the teams which have candidates available for review + */ +async function sendCandidatesAvailableEmails () { + const jobsDao = await Job.findAll({ + include: [{ + model: JobCandidate, + as: 'candidates', + required: true, + where: { + status: constants.JobStatus.OPEN + } + }] + }) + const jobs = _.map(jobsDao, dao => dao.dataValues) + + const projectIds = _.uniq(_.map(jobs, job => job.projectId)) + // for each unique project id, send an email + for (const projectId of projectIds) { + const project = await getProjectWithId(projectId) + if (!project) { continue } + + const recipientEmails = getProjectMembersEmails(project) + if (_.isEmpty(recipientEmails)) { continue } + + const projectJobs = _.filter(jobs, job => job.projectId === projectId) + + const teamJobs = [] + for (const projectJob of projectJobs) { + // get candidate list + const jobCandidates = [] + for (const jobCandidate of projectJob.candidates) { + const user = await getUserWithId(jobCandidate.userId) + if (!user) { continue } + + jobCandidates.push({ + handle: user.handle, + status: jobCandidate.status + }) + } + + // review link + const reviewLink = `${config.TAAS_APP_URL}/${projectId}/positions/${projectJob.id}/candidates/to-review` + + // get # of resource bookings + const nResourceBookings = await ResourceBooking.count({ + where: { + jobId: projectJob.id + } + }) + + teamJobs.push({ + title: projectJob.title, + nResourceBookings, + jobCandidates, + reviewLink + }) + } + + sendEmail({}, { + template: 'taas.notification.candidates-available-for-review', + recipients: recipientEmails, + data: { + teamName: project.name, + teamJobs, + notificationType: { + candidatesAvailableForReview: true + }, + description: 'Candidates are available for review' + } + }) + } +} + +/** + * Sends email reminders to the hosts and guests about their upcoming interview(s) + */ +async function sendInterviewComingUpEmails () { + const currentTime = moment.utc() + const minutesRange = 5 + + const oneDayFromNow = currentTime.clone().add(24, 'hours') + const dayEndTime = oneDayFromNow.clone().add(minutesRange, 'minutes') + + const oneHourFromNow = currentTime.clone().add(1, 'hour') + const hourEndTime = oneHourFromNow.clone().add(minutesRange, 'minutes') + const filter = { + [Op.and]: [ + { + status: { [Op.eq]: constants.Interviews.Status.Scheduled } + }, + { + startTimestamp: { + [Op.or]: [ + { + [Op.and]: [ + { + [Op.gt]: oneDayFromNow + }, + { + [Op.lte]: dayEndTime + } + ] + }, + { + [Op.and]: [ + { + [Op.gt]: oneHourFromNow + }, + { + [Op.lte]: hourEndTime + } + ] + } + ] + } + } + ] + } + + const interviews = await Interview.findAll({ + where: filter, + raw: true + }) + + for (const interview of interviews) { + // send host email + const data = await getDataForInterview(interview) + if (!data) { continue } + + if (!_.isEmpty(interview.hostEmail)) { + sendEmail({}, { + template: 'taas.notification.interview-coming-up-host', + recipients: [interview.hostEmail], + data: { + ...data, + notificationType: { + interviewComingUpForHost: true + }, + description: 'Interview Coming Up' + } + }) + } else { + localLogger.error(`Interview id: ${interview.id} host email not present`, 'sendInterviewComingUpEmails') + } + + if (!_.isEmpty(interview.guestEmails)) { + // send guest emails + sendEmail({}, { + template: 'taas.notification.interview-coming-up-guest', + recipients: interview.guestEmails, + data: { + ...data, + notificationType: { + interviewComingUpForGuest: true + }, + description: 'Interview Coming Up' + } + }) + } else { + localLogger.error(`Interview id: ${interview.id} guest emails not present`, 'sendInterviewComingUpEmails') + } + } +} + +/** + * Sends email reminder to the interview host after it ends to change the interview status + */ +async function sendInterviewCompletedEmails () { + const minutesRange = 5 + const hoursBeforeNow = moment.utc().subtract(config.INTERVIEW_COMPLETED_NOTIFICATION_HOURS, 'hours') + const endTime = hoursBeforeNow.clone().add(minutesRange, 'minutes') + const filter = { + [Op.and]: [ + { + status: { [Op.eq]: constants.Interviews.Status.Scheduled } + }, + { + endTimestamp: { + [Op.and]: [ + { + [Op.gte]: hoursBeforeNow + }, + { + [Op.lt]: endTime + } + ] + } + } + ] + } + + const interviews = await Interview.findAll({ + where: filter, + raw: true + }) + + for (const interview of interviews) { + if (_.isEmpty(interview.hostEmail)) { + localLogger.error(`Interview id: ${interview.id} host email not present`) + continue + } + + const data = await getDataForInterview(interview) + if (!data) { continue } + + sendEmail({}, { + template: 'taas.notification.interview-awaits-resolution', + recipients: [interview.hostEmail], + data: { + ...data, + notificationType: { + interviewCompleted: true + }, + description: 'Interview Completed' + } + }) + } +} + +/** + * Sends email reminder to the all members of teams which have interview completed to take action + * to update the job candidate status + */ +async function sendPostInterviewActionEmails () { + const completedJobCandidates = await JobCandidate.findAll({ + where: { + status: constants.JobCandidateStatus.INTERVIEW + }, + include: [{ + model: Interview, + as: 'interviews', + required: true, + where: { + status: constants.Interviews.Status.Completed + } + }] + }) + + // get all project ids for this job candidates + const jobs = await Job.findAll({ + where: { + id: { + [Op.in]: completedJobCandidates.map(jc => jc.jobId) + } + }, + raw: true + }) + + const projectIds = _.uniq(_.map(jobs, job => job.projectId)) + for (const projectId of projectIds) { + const project = await getProjectWithId(projectId) + if (!project) { continue } + + const recipientEmails = getProjectMembersEmails(project) + if (_.isEmpty(recipientEmails)) { continue } + + const projectJobs = _.filter(jobs, job => job.projectId === projectId) + const teamInterviews = [] + let numCandidates = 0 + for (const projectJob of projectJobs) { + const projectJcs = _.filter(completedJobCandidates, jc => jc.jobId === projectJob.id) + numCandidates += projectJcs.length + for (const projectJc of projectJcs) { + for (const interview of projectJc.interviews) { + const d = await getDataForInterview(interview, projectJc, projectJob) + if (!d) { continue } + teamInterviews.push(d) + } + } + } + + sendEmail({}, { + template: 'taas.notification.post-interview-action-required', + recipients: recipientEmails, + data: { + teamName: project.name, + numCandidates, + teamInterviews, + notificationType: { + postInterviewCandidateAction: true + }, + description: 'Post Interview Candidate Action Reminder' + } + }) + } +} + +/** + * Sends reminder emails to all members of teams which have atleast one upcoming resource booking expiration + */ +async function sendResourceBookingExpirationEmails () { + const currentTime = moment.utc() + const maxEndDate = currentTime.clone().add(config.RESOURCE_BOOKING_EXPIRY_NOTIFICATION_WEEKS, 'weeks') + const expiringResourceBookings = await ResourceBooking.findAll({ + where: { + endDate: { + [Op.and]: [ + { + [Op.gt]: currentTime + }, + { + [Op.lte]: maxEndDate + } + ] + } + }, + raw: true + }) + + const jobs = await Job.findAll({ + where: { + id: { + [Op.in]: _.map(expiringResourceBookings, rb => rb.jobId) + } + }, + raw: true + }) + const projectIds = _.uniq(_.map(expiringResourceBookings, rb => rb.projectId)) + + for (const projectId of projectIds) { + const project = await getProjectWithId(projectId) + if (!project) { continue } + const recipientEmails = getProjectMembersEmails(project) + if (_.isEmpty(recipientEmails)) { continue } + + const projectJobs = _.filter(jobs, job => job.projectId === projectId) + + let numResourceBookings = 0 + const teamResourceBookings = [] + for (const projectJob of projectJobs) { + const resBookings = _.filter(expiringResourceBookings, rb => rb.jobId === projectJob.id) + numResourceBookings += resBookings.length + + for (const booking of resBookings) { + const user = await getUserWithId(booking.userId) + if (!user) { continue } + + teamResourceBookings.push({ + jobTitle: projectJob.title, + handle: user.handle, + endDate: booking.endDate + }) + } + } + + sendEmail({}, { + template: 'taas.notification.resource-booking-expiration', + recipients: recipientEmails, + data: { + teamName: project.name, + numResourceBookings, + teamResourceBookings, + notificationType: { + upcomingResourceBookingExpiration: true + }, + description: 'Upcoming Resource Booking Expiration' + } + }) + } +} + +/** + * Send email through a particular template + * @param {Object} currentUser the user who perform this operation + * @param {Object} data the email object + * @returns {undefined} + */ +async function sendEmail (currentUser, data) { + const template = emailTemplates[data.template] + const dataCC = data.cc || [] + const templateCC = template.cc || [] + const dataRecipients = data.recipients || [] + const templateRecipients = template.recipients || [] + const subjectBody = { + subject: data.subject || template.subject, + body: data.body || template.body + } + for (const key in subjectBody) { + subjectBody[key] = await helper.substituteStringByObject( + subjectBody[key], + data.data + ) + } + const emailData = { + serviceId: 'email', + type: data.template, + details: { + from: data.from || template.from, + recipients: _.map(_.uniq([...dataRecipients, ...templateRecipients]), function (r) { return { email: r } }), + cc: _.map(_.uniq([...dataCC, ...templateCC]), function (r) { return { email: r } }), + data: { ...data.data, ...subjectBody }, + sendgrid_template_id: template.sendgridTemplateId, + version: 'v3' + } + } + await helper.postEvent(config.NOTIFICATIONS_CREATE_TOPIC, { + notifications: [emailData] + }) +} + +module.exports = { + sendCandidatesAvailableEmails, + sendInterviewComingUpEmails, + sendInterviewCompletedEmails, + sendPostInterviewActionEmails, + sendResourceBookingExpirationEmails +} diff --git a/src/services/TeamService.js b/src/services/TeamService.js index 3d1f390b..f3eb7ac4 100644 --- a/src/services/TeamService.js +++ b/src/services/TeamService.js @@ -6,7 +6,6 @@ const _ = require('lodash') const Joi = require('joi') const dateFNS = require('date-fns') const config = require('config') -const emailTemplateConfig = require('../../config/email_template.config') const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') @@ -21,16 +20,7 @@ const { matchedSkills, unMatchedSkills } = require('../../scripts/emsi-mapping/e const Role = models.Role const RoleSearchRequest = models.RoleSearchRequest -const emailTemplates = _.mapValues(emailTemplateConfig, (template) => { - return { - subject: template.subject, - body: template.body, - from: template.from, - recipients: template.recipients, - cc: template.cc, - sendgridTemplateId: template.sendgridTemplateId - } -}) +const emailTemplates = helper.getEmailTemplatesForKey('teamTemplates') /** * Function to get placed resource bookings with specific projectIds