diff --git a/config/default.js b/config/default.js index 54846412..1ddb0594 100644 --- a/config/default.js +++ b/config/default.js @@ -244,10 +244,6 @@ module.exports = { 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 @@ -259,5 +255,15 @@ module.exports = { // 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' + CRON_UPCOMING_RESOURCE_BOOKING: process.env.CRON_UPCOMING_RESOURCE_BOOKING || '00 00 13 * * 1', + // The match window for fetching interviews which are coming up + INTERVIEW_COMING_UP_MATCH_WINDOW: process.env.INTERVIEW_COMING_UP_MATCH_WINDOW || 'PT5M', + // The remind time for fetching interviews which are coming up + INTERVIEW_COMING_UP_REMIND_TIME: (process.env.INTERVIEW_COMING_UP_REMIND_TIME || 'PT1H,PT24H').split(','), + // The match window for fetching completed interviews + INTERVIEW_COMPLETED_MATCH_WINDOW: process.env.INTERVIEW_COMPLETED_MATCH_WINDOW || 'PT5M', + // The interview completed past time for fetching interviews + INTERVIEW_COMPLETED_PAST_TIME: process.env.INTERVIEW_COMPLETED_PAST_TIME || 'PT4H', + // The time before resource booking expiry when we should start sending notifications + RESOURCE_BOOKING_EXPIRY_TIME: process.env.RESOURCE_BOOKING_EXPIRY_TIME || 'P21D' } diff --git a/data/demo-data.json b/data/demo-data.json index 03d62844..0e67b097 100644 --- a/data/demo-data.json +++ b/data/demo-data.json @@ -859,9 +859,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Completed", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -900,9 +900,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Scheduling", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -926,9 +926,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Completed", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -967,9 +967,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Scheduling", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -993,9 +993,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Scheduling", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -1019,9 +1019,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Completed", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", diff --git a/package-lock.json b/package-lock.json index dce8640e..ea69e2a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -585,6 +585,12 @@ } } }, + "@types/bluebird": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.0.tgz", + "integrity": "sha1-JjNHCk6r6aR82aRf2yDtX5NAe8o=", + "dev": true + }, "@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", @@ -645,6 +651,12 @@ "@types/express": "*" } }, + "@types/lodash": { + "version": "4.14.172", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.172.tgz", + "integrity": "sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw==", + "dev": true + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -1297,6 +1309,17 @@ "tweetnacl": "^0.14.3" } }, + "bin-protocol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bin-protocol/-/bin-protocol-3.1.1.tgz", + "integrity": "sha512-9vCGfaHC2GBHZwGQdG+DpyXfmLvx9uKtf570wMLwIc9wmTIDgsdCBXQxTZu5X2GyogkfBks2Ode4N0sUVxJ2qQ==", + "dev": true, + "requires": { + "lodash": "^4.17.11", + "long": "^4.0.0", + "protocol-buffers-schema": "^3.0.0" + } + }, "binary-extensions": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", @@ -1433,6 +1456,12 @@ "isarray": "^1.0.0" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1836,6 +1865,12 @@ "xdg-basedir": "^4.0.0" } }, + "connection-parse": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/connection-parse/-/connection-parse-0.0.7.tgz", + "integrity": "sha1-GOcxiqsGppkmc3KxDFIm0locmmk=", + "dev": true + }, "contains-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", @@ -3301,6 +3336,16 @@ "type-fest": "^0.8.0" } }, + "hashring": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/hashring/-/hashring-3.2.0.tgz", + "integrity": "sha1-/aTv3oqiLNuX+x0qZeiEAeHBRM4=", + "dev": true, + "requires": { + "connection-parse": "0.0.x", + "simple-lru-cache": "0.0.x" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -4629,6 +4674,12 @@ } } }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "dev": true + }, "long-timeout": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", @@ -4973,6 +5024,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "murmur-hash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmur-hash-js/-/murmur-hash-js-1.0.0.tgz", + "integrity": "sha1-UEEEkmnJZjPIZjhpYLL0KJ515bA=", + "dev": true + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -5047,6 +5104,15 @@ "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, + "nice-simple-logger": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nice-simple-logger/-/nice-simple-logger-1.0.1.tgz", + "integrity": "sha1-D55khSe+e+PkmrdvqMjAmK+VG/Y=", + "dev": true, + "requires": { + "lodash": "^4.3.0" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -5082,6 +5148,32 @@ } } }, + "no-kafka": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/no-kafka/-/no-kafka-3.4.3.tgz", + "integrity": "sha512-hYnkg1OWVdaxORdzVvdQ4ueWYpf7IICObPzd24BBiDyVG5219VkUnRxSH9wZmisFb6NpgABzlSIL1pIZaCKmXg==", + "dev": true, + "requires": { + "@types/bluebird": "3.5.0", + "@types/lodash": "^4.14.55", + "bin-protocol": "^3.1.1", + "bluebird": "^3.3.3", + "buffer-crc32": "^0.2.5", + "hashring": "^3.2.0", + "lodash": "=4.17.11", + "murmur-hash-js": "^1.0.0", + "nice-simple-logger": "^1.0.1", + "wrr-pool": "^1.0.3" + }, + "dependencies": { + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + } + } + }, "node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -6133,6 +6225,12 @@ "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", "dev": true }, + "protocol-buffers-schema": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.5.1.tgz", + "integrity": "sha512-YVCvdhxWNDP8/nJDyXLuM+UFsuPk4+1PB7WGPVDzm3HTHbzFLxQYeW2iZpS4mmnXrQJGBzt230t/BbEb7PrQaw==", + "dev": true + }, "proxy-addr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", @@ -6809,6 +6907,12 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, + "simple-lru-cache": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/simple-lru-cache/-/simple-lru-cache-0.0.2.tgz", + "integrity": "sha1-1ZzDoZPBpdAyD4Tucy9uRxPlEd0=", + "dev": true + }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -7993,6 +8097,15 @@ "typedarray-to-buffer": "^3.1.5" } }, + "wrr-pool": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/wrr-pool/-/wrr-pool-1.1.4.tgz", + "integrity": "sha512-+lEdj42HlYqmzhvkZrx6xEymj0wzPBxqr7U1Xh9IWikMzOge03JSQT9YzTGq54SkOh/noViq32UejADZVzrgAg==", + "dev": true, + "requires": { + "lodash": "^4.17.11" + } + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/package.json b/package.json index a1fe6860..4262080f 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "csv-parser": "^3.0.0", "handlebars": "^4.7.7", "mocha": "^8.1.3", + "no-kafka": "^3.4.3", "nodemon": "^2.0.4", "nyc": "^15.1.0", "sequelize-cli": "^6.2.0", diff --git a/scripts/demo-email-notifications/README.md b/scripts/demo-email-notifications/README.md index c9a802d9..3a5dbd47 100644 --- a/scripts/demo-email-notifications/README.md +++ b/scripts/demo-email-notifications/README.md @@ -15,6 +15,8 @@ This script does 2 things: CRON_INTERVIEW_COMPLETED=0 */1 * * * * CRON_POST_INTERVIEW=0 */1 * * * * CRON_UPCOMING_RESOURCE_BOOKING=0 */1 * * * * + INTERVIEW_COMING_UP_MATCH_WINDOW=PT1M + INTERVIEW_COMPLETED_MATCH_WINDOW=PT1M ``` 2. Recreate demo data by: @@ -34,4 +36,4 @@ This script does 2 things: node scripts/demo-email-notifications ``` -Check the rendered emails inside `out` folder. \ No newline at end of file +Check the rendered emails inside `out` folder. diff --git a/scripts/demo-email-notifications/index.js b/scripts/demo-email-notifications/index.js index 7c19d2f3..23f3ac9f 100644 --- a/scripts/demo-email-notifications/index.js +++ b/scripts/demo-email-notifications/index.js @@ -23,13 +23,13 @@ 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() + const startTimestamp = moment().add(moment.duration(config.INTERVIEW_COMING_UP_REMIND_TIME[0])).add(config.INTERVIEW_COMING_UP_MATCH_WINDOW).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 pastTime = moment.duration(config.INTERVIEW_COMPLETED_PAST_TIME) + const endTimestamp = moment().subtract(pastTime).add(config.INTERVIEW_COMPLETED_MATCH_WINDOW).toDate() const completedInterview = await Interview.findById('9efd72c3-1dc7-4ce2-9869-8cca81d0adeb') const duration = 30 const completedStartTimestamp = moment().subtract(pastTime).subtract(30, 'm').toDate() @@ -45,7 +45,8 @@ async function resetNotificationRecords () { // 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() }) + const testEnd = moment().add(moment.duration(config.RESOURCE_BOOKING_EXPIRY_TIME)).toDate() + await resourceBooking.update({ endDate: testEnd }) } /** diff --git a/src/services/EmailNotificationService.js b/src/services/EmailNotificationService.js index f8bef90f..5572933c 100644 --- a/src/services/EmailNotificationService.js +++ b/src/services/EmailNotificationService.js @@ -88,7 +88,7 @@ async function getDataForInterview (interview, jobCandidate, job) { 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() + const startTime = interview.startTimestamp ? interview.startTimestamp.toUTCString() : '' return { jobTitle: job.title, @@ -126,8 +126,6 @@ async function sendCandidatesAvailableEmails () { if (!project) { continue } const recipientEmails = getProjectMembersEmails(project) - if (_.isEmpty(recipientEmails)) { continue } - const projectJobs = _.filter(jobs, job => job.projectId === projectId) const teamJobs = [] @@ -182,43 +180,33 @@ async function sendCandidatesAvailableEmails () { */ 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 timestampFilter = { + [Op.or]: [] + } + const window = moment.duration(config.INTERVIEW_COMING_UP_MATCH_WINDOW) + for (const remindTime of config.INTERVIEW_COMING_UP_REMIND_TIME) { + const rangeStart = currentTime.clone().add(moment.duration(remindTime)) + const rangeEnd = rangeStart.clone().add(window) + + timestampFilter[Op.or].push({ + [Op.and]: [ + { + [Op.gt]: rangeStart + }, + { + [Op.lte]: rangeEnd + } + ] + }) + } - 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 - } - ] - } - ] - } + startTimestamp: timestampFilter } ] } @@ -272,9 +260,9 @@ async function 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 window = moment.duration(config.INTERVIEW_COMPLETED_MATCH_WINDOW) + const rangeStart = moment.utc().subtract(moment.duration(config.INTERVIEW_COMPLETED_PAST_TIME)) + const rangeEnd = rangeStart.clone().add(window) const filter = { [Op.and]: [ { @@ -284,10 +272,10 @@ async function sendInterviewCompletedEmails () { endTimestamp: { [Op.and]: [ { - [Op.gte]: hoursBeforeNow + [Op.gte]: rangeStart }, { - [Op.lt]: endTime + [Op.lt]: rangeEnd } ] } @@ -358,8 +346,6 @@ async function sendPostInterviewActionEmails () { if (!project) { continue } const recipientEmails = getProjectMembersEmails(project) - if (_.isEmpty(recipientEmails)) { continue } - const projectJobs = _.filter(jobs, job => job.projectId === projectId) const teamInterviews = [] let numCandidates = 0 @@ -396,7 +382,8 @@ async function sendPostInterviewActionEmails () { */ async function sendResourceBookingExpirationEmails () { const currentTime = moment.utc() - const maxEndDate = currentTime.clone().add(config.RESOURCE_BOOKING_EXPIRY_NOTIFICATION_WEEKS, 'weeks') + const maxEndDate = currentTime.clone().add(moment.duration(config.RESOURCE_BOOKING_EXPIRY_TIME)) + const expiringResourceBookings = await ResourceBooking.findAll({ where: { endDate: { @@ -427,8 +414,6 @@ async function sendResourceBookingExpirationEmails () { 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