diff --git a/.circleci/config.yml b/.circleci/config.yml index 9d4b135..3c0c8c6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -91,7 +91,7 @@ workflows: only: - develop - security - - PM-3327 + - PM-3351 - "build-qa": context: org-global diff --git a/app-constants.js b/app-constants.js index 0c6f361..5c8d576 100644 --- a/app-constants.js +++ b/app-constants.js @@ -151,6 +151,14 @@ const PhaseFact = { UNRECOGNIZED: -1 } +const PhaseChangeNotificationSettings = { + PHASE_CHANGE: { + sendgridTemplateId: config.PHASE_CHANGE_SENDGRID_TEMPLATE_ID, + cc: [], + }, +}; + + const auditFields = [ 'createdAt', 'createdBy', 'updatedAt', 'updatedBy' ] @@ -168,4 +176,5 @@ module.exports = { SelfServiceNotificationSettings, PhaseFact, auditFields, + PhaseChangeNotificationSettings, }; diff --git a/config/default.js b/config/default.js index cc0da6e..93e524f 100644 --- a/config/default.js +++ b/config/default.js @@ -133,4 +133,6 @@ module.exports = { RESOURCES_DB_SCHEMA: process.env.RESOURCES_DB_SCHEMA || "resources", REVIEW_DB_SCHEMA: process.env.REVIEW_DB_SCHEMA || "reviews", CHALLENGE_SERVICE_PRISMA_TIMEOUT: process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT ? parseInt(process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT, 10) : 10000, + CHALLENGE_URL: process.env.CHALLENGE_URL || 'https://www.topcoder-dev.com/challenges' , + PHASE_CHANGE_SENDGRID_TEMPLATE_ID: process.env.PHASE_CHANGE_SENDGRID_TEMPLATE_ID || "", }; diff --git a/src/common/helper.js b/src/common/helper.js index a7453fb..d9e3ef9 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1638,6 +1638,72 @@ async function sendSelfServiceNotification(type, recipients, data) { } } +/** + * Build payload for phase change email notification + * @param {String} challenge Id + * @param {String} challenge name + * @param {String} challenge phase name + * @param {String} operation to be performed on the phase - open | close | reopen + * @param {String|Date} at - The date/time when the phase opened/closed + */ +function buildPhaseChangeEmailData({ challengeId, challengeName, phaseName, operation, at }) { + const isOpen = operation === 'open' || operation === 'reopen'; + const isClose = operation === 'close'; + + return { + challengeURL: `${config.CHALLENGE_URL}/${challengeId}`, + challengeName, + phaseOpen: isOpen ? phaseName : null, + phaseOpenDate: isOpen ? at : null, + phaseClose: isClose ? phaseName : null, + phaseCloseDate: isClose ? at : null, + }; +} + + +/** + * Send phase change notification + * @param {String} type the notification type + * @param {Array} recipients the array of recipients emails + * @param {Object} data the data + */ +async function sendPhaseChangeNotification(type, recipients, data) { + try { + const settings = constants.PhaseChangeNotificationSettings?.[type]; + + if (!settings) { + logger.debug(`sendPhaseChangeNotification: unknown type ${type}`); + return; + } + + if (!settings.sendgridTemplateId) { + logger.debug( + `sendPhaseChangeNotification: sendgridTemplateId not configured for type ${type}` + ); + return; + } + const safeRecipients = Array.isArray(recipients) ? recipients.filter(Boolean) : []; + + if (!safeRecipients.length) { + logger.debug(`sendPhaseChangeNotification: no recipients for type ${type}`); + return; + } + + await postBusEvent('external.action.email', + { + from: config.EMAIL_FROM, + replyTo: config.EMAIL_FROM, + recipients: safeRecipients, + data: data, + sendgrid_template_id: settings.sendgridTemplateId, + version: 'v3', + }, + ); + } catch (e) { + logger.debug(`Failed to post notification ${type}: ${e.message}`); + } +} + /** * Submit a request to zendesk * @param {Object} request the request @@ -1756,6 +1822,8 @@ module.exports = { setToInternalCache, flushInternalCache, removeNullProperties, + buildPhaseChangeEmailData, + sendPhaseChangeNotification }; logger.buildService(module.exports); diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 507eb2f..4c6c4da 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -813,9 +813,74 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) _.assignIn({ id: result.id }, data) ); await postChallengeUpdatedNotification(challengeId); + + // send notification logic + try { + const shouldNotifyClose = Boolean(isClosingPhase); + const shouldNotifyOpen = Boolean(isOpeningPhase); // includes reopen + + if (shouldNotifyClose || shouldNotifyOpen) { + // Single template - single type + const notificationType = "PHASE_CHANGE"; + + const operation = shouldNotifyClose + ? "close" + : (isReopeningPhase ? "reopen" : "open"); + + const at = shouldNotifyClose + ? (result.actualEndDate || new Date().toISOString()) + : (result.actualStartDate || new Date().toISOString()); + + // fetch challenge name + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + select: { name: true }, + }); + + const challengeName = challenge?.name; + + // build recipients + const resources = await helper.getChallengeResources(challengeId); + + const recipients = Array.from( + new Set( + (resources || []) + .map(r => r?.email || r?.memberEmail) + .filter(Boolean) + .map(e => String(e).trim().toLowerCase()) + ) + ); + + if (!recipients.length) { + logger.debug( + `phase change notification skipped: no recipients for challenge ${challengeId}` + ); + return _.omit(result, constants.auditFields); + } + + // build payload that matches the SendGrid HTML template + const phaseName = result.name || data.name || challengePhase.name; + + const payload = helper.buildPhaseChangeEmailData({ + challengeId, + challengeName, + phaseName, + operation, + at, + }); + + await helper.sendPhaseChangeNotification(notificationType, recipients, payload); + } + } catch (e) { + logger.debug( + `phase change notification failed for challenge ${challengeId}, phase ${id}: ${e.message}` + ); + } + return _.omit(result, constants.auditFields); } + partiallyUpdateChallengePhase.schema = { currentUser: Joi.any(), challengeId: Joi.id(),