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 @@
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+  |
+
+ |
+ |
+
+
+
+ |
+
+
+
+ |
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ Topcoder Gig Work |
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+  |
+ |
+
+ {{description}}
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ {{#if notificationType.candidatesAvailableForReview}}
+
+
+ Job title |
+ No of resource bookings |
+ Candidates |
+ Status |
+
+ {{#each teamJobs}}
+
+ {{this.title}} |
+ {{this.nResourceBookings}} |
+
+
+ |
+
+
+ {{#each this.jobCandidates}}
+ - {{this.status}}
+ {{/each}}
+
+ |
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if notificationType.interviewComingUpForHost}}
+
+
+ Job title |
+ Candidate Handle |
+ Interviews |
+ Attendees |
+ Date and Time |
+
+
+ {{this.jobTitle}} |
+ {{this.handle}} |
+
+ Link |
+
+
+ {{#each this.attendees}}
+ - {{this}}
+ {{/each}}
+
+ |
+ {{this.startTime}} |
+
+
+ {{/if}}
+
+ {{#if notificationType.interviewComingUpForGuest}}
+
+
+ Job title |
+ Date and Time |
+ Interviews |
+
+
+ {{this.jobTitle}} |
+ {{this.startTime}} |
+
+ Link |
+
+
+ {{/if}}
+
+ {{#if notificationType.interviewCompleted}}
+
+
+ Job title |
+ Candidate Handle |
+ Date and Time |
+ Attendees |
+ Interviews |
+
+
+ {{this.jobTitle}} |
+ {{this.handle}} |
+ {{this.startTime}} |
+
+
+ {{#each this.attendees}}
+ - {{this}}
+ {{/each}}
+
+ |
+
+ Link |
+
+
+ {{/if}}
+
+ {{#if notificationType.postInterviewCandidateAction}}
+
+
+ Job title |
+ Handle |
+ Date and Time |
+ Attendees |
+ Interviews |
+
+ {{#each teamInterviews}}
+
+ {{this.jobTitle}} |
+ {{this.handle}} |
+ {{this.startTime}} |
+
+
+ {{#each this.attendees}}
+ - {{this}}
+ {{/each}}
+
+ |
+
+ Link |
+
+ {{/each}}
+
+ {{/if}}
+
+ {{#if notificationType.upcomingResourceBookingExpiration}}
+ Team Name:
+ {{teamName}}
+
+
+ Job title |
+ Resource Bookings Handle |
+ End Date |
+
+ {{#each teamResourceBookings}}
+
+ {{this.jobTitle}} |
+ {{this.handle}} |
+ {{this.endDate}} |
+
+ {{/each}}
+
+ {{/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