diff --git a/config/default.js b/config/default.js index 912c909..4916bab 100644 --- a/config/default.js +++ b/config/default.js @@ -28,9 +28,9 @@ module.exports = { KAFKA_CLIENT_CERT_KEY: process.env.KAFKA_CLIENT_CERT_KEY ? process.env.KAFKA_CLIENT_CERT_KEY.replace('\\n', '\n') : null, - TC_API_V3_BASE_URL: process.env.TC_API_V3_BASE_URL || '', + TC_API_V3_BASE_URL: process.env.TC_API_V3_BASE_URL || 'http://api.topcoder-dev.com/v3', TC_API_V4_BASE_URL: process.env.TC_API_V4_BASE_URL || '', - TC_API_V5_BASE_URL: process.env.TC_API_V5_BASE_URL || '', + TC_API_V5_BASE_URL: process.env.TC_API_V5_BASE_URL || 'https://api.topcoder-dev.com/v5', API_CONTEXT_PATH: process.env.API_CONTEXT_PATH || '/v5/notifications', TC_API_BASE_URL: process.env.TC_API_BASE_URL || '', @@ -135,7 +135,7 @@ module.exports = { // email notification service related variables ENV: process.env.ENV, ENABLE_EMAILS: process.env.ENABLE_EMAILS ? Boolean(process.env.ENABLE_EMAILS) : false, - ENABLE_DEV_MODE: process.env.ENABLE_DEV_MODE ? Boolean(process.env.ENABLE_DEV_MODE) : true, + ENABLE_DEV_MODE: process.env.ENABLE_DEV_MODE === 'true', DEV_MODE_EMAIL: process.env.DEV_MODE_EMAIL, DEFAULT_REPLY_EMAIL: process.env.DEFAULT_REPLY_EMAIL, ENABLE_HOOK_BULK_NOTIFICATION: process.env.ENABLE_HOOK_BULK_NOTIFICATION || false, diff --git a/src/common/tcApiHelper.js b/src/common/tcApiHelper.js index 0bd9ca5..8a030b7 100644 --- a/src/common/tcApiHelper.js +++ b/src/common/tcApiHelper.js @@ -88,6 +88,103 @@ function* getUsersByHandles(handles) { return yield searchUsersByQuery(query); } +/** + * Get users by handles or userIds. + * @param {Array} handles the objects that has user handles. + * @param {Array} userIds the objects that has userIds. + * @returns {Array} the matched users + */ +function* getUsersByHandlesAndUserIds(handles, userIds) { + if ((!handles || handles.length === 0) && (!userIds || userIds.length === 0)) { + return []; + } + const handlesQuery = _.map(handles, h => `handleLower:${h.handle.toLowerCase()}`); + const userIdsQuery = _.map(userIds, u => `userId:${u.userId}`); + const query = _.concat(handlesQuery, userIdsQuery).join(URI.encodeQuery(' OR ', 'utf8')); + try { + return yield searchUsersByQuery(query); + } catch (err) { + const error = new Error(err.response.text); + error.status = err.status; + throw error; + } +} + +/** + * Search users by query string. + * @param {String} query the query string + * @returns {Array} the matched users + */ +function* searchUsersByEmailQuery(query) { + const token = yield getM2MToken(); + const res = yield request + .get(`${ + config.TC_API_V3_BASE_URL + }/users?filter=${ + query + }&fields=id,email,handle`) + .set('Authorization', `Bearer ${token}`); + if (!_.get(res, 'body.result.success')) { + throw new Error(`Failed to search users by query: ${query}`); + } + const records = _.get(res, 'body.result.content') || []; + + logger.verbose(`Searched users: ${JSON.stringify(records, null, 4)}`); + return records; +} + +/** + * Get users by emails. + * @param {Array} emails the objects that has user emails. + * @returns {Array} the matched users + */ +function* getUsersByEmails(emails) { + if (!emails || emails.length === 0) { + return []; + } + const users = []; + try { + for (const email of emails) { + const query = `email%3D${email.email}`; + const result = yield searchUsersByEmailQuery(query); + users.push(...result); + } + return users; + } catch (err) { + const error = new Error(err.response.text); + error.status = err.status; + throw error; + } +} + +/** + * Get users by uuid. + * @param {Array} ids the objects that has user uuids. + * @returns {Array} the matched users + */ +function* getUsersByUserUUIDs(ids, enrich) { + if (!ids || ids.length === 0) { + return []; + } + const users = []; + const token = yield getM2MToken(); + try { + for (const id of ids) { + const res = yield request + .get(`${config.TC_API_V5_BASE_URL}/users/${id.userUUID}${enrich ? '?enrich=true' : ''}`) + .set('Authorization', `Bearer ${token}`); + const user = res.body; + logger.verbose(`Searched users: ${JSON.stringify(user, null, 4)}`); + users.push(user); + } + return users; + } catch (err) { + const error = new Error(err.response.text); + error.status = err.status; + throw error; + } +} + /** * Send message to bus. * @param {Object} data the data to send @@ -158,21 +255,30 @@ function* checkNotificationSetting(userId, notificationType, serviceId) { } /** - * Notify user via email. + * Notify user via web. * @param {Object} message the Kafka message payload - * @return {Object} notification details. + * @return {Array} notification details. */ function* notifyUserViaWeb(message) { const notificationType = message.type; - const userId = message.details.userId; - // if web notification is explicitly disabled for current notification type do nothing - const allowed = yield checkNotificationSetting(userId, notificationType, constants.SETTINGS_WEB_SERVICE_ID); - if (!allowed) { - logger.verbose(`Notification '${notificationType}' won't be sent by '${constants.SETTINGS_WEB_SERVICE_ID}'` + const notifications = []; + for (const recipient of message.details.recipients) { + const userId = recipient.userId; + if (_.isUndefined(userId)) { + logger.error(`userId not received for user: ${JSON.stringify(recipient, null, 4)}`); + continue; + } + // if web notification is explicitly disabled for current notification type do nothing + const allowed = yield checkNotificationSetting(userId, notificationType, constants.SETTINGS_WEB_SERVICE_ID); + if (!allowed) { + logger.verbose(`Notification '${notificationType}' won't be sent by '${constants.SETTINGS_WEB_SERVICE_ID}'` + ` service to the userId '${userId}' due to his notification settings.`); - return; + continue; + } + notifications.push(_.assign({}, _.pick(message.details, ['contents', 'version']), { userId })); } - return message.details; + + return notifications; } /** @@ -182,15 +288,9 @@ function* notifyUserViaWeb(message) { function* notifyUserViaEmail(message) { const notificationType = message.type; const topic = constants.BUS_API_EVENT.EMAIL.UNIVERSAL; + const cc = _.map(_.filter(message.details.cc, c => !_.isUndefined(c.email)), 'email'); for (const recipient of message.details.recipients) { const userId = recipient.userId; - // if email notification is explicitly disabled for current notification type do nothing - const allowed = yield checkNotificationSetting(userId, notificationType, constants.SETTINGS_EMAIL_SERVICE_ID); - if (!allowed) { - logger.verbose(`Notification '${notificationType}' won't be sent by '${constants.SETTINGS_EMAIL_SERVICE_ID}'` - + ` service to the userId '${userId}' due to his notification settings.`); - continue; - } let userEmail; // if dev mode for email is enabled then replace recipient email if (config.ENABLE_DEV_MODE) { @@ -198,7 +298,17 @@ function* notifyUserViaEmail(message) { } else { userEmail = recipient.email; if (!userEmail) { - logger.error(`Email not received for user: ${userId}`); + logger.error(`Email not received for user: ${JSON.stringify(recipient, null, 4)}`); + continue; + } + } + // skip checking notification setting if userId is not found. + if (!_.isUndefined(userId)) { + // if email notification is explicitly disabled for current notification type do nothing + const allowed = yield checkNotificationSetting(userId, notificationType, constants.SETTINGS_EMAIL_SERVICE_ID); + if (!allowed) { + logger.verbose(`Notification '${notificationType}' won't be sent by '${constants.SETTINGS_EMAIL_SERVICE_ID}'` + + ` service to the userId '${userId}' due to his notification settings.`); continue; } } @@ -206,7 +316,7 @@ function* notifyUserViaEmail(message) { const payload = { from: message.details.from, recipients, - cc: message.details.cc || [], + cc, data: message.details.data || {}, sendgrid_template_id: message.details.sendgridTemplateId, version: message.details.version, @@ -496,6 +606,9 @@ module.exports = { getM2MToken, getUsersBySkills, getUsersByHandles, + getUsersByHandlesAndUserIds, + getUsersByEmails, + getUsersByUserUUIDs, sendMessageToBus, notifySlackChannel, checkNotificationSetting, diff --git a/src/services/UniversalNotificationService.js b/src/services/UniversalNotificationService.js index 3f41d86..c03f896 100644 --- a/src/services/UniversalNotificationService.js +++ b/src/services/UniversalNotificationService.js @@ -3,7 +3,7 @@ */ 'use strict'; - +const _ = require('lodash'); const joi = require('joi'); const logger = require('../common/logger'); const tcApiHelper = require('../common/tcApiHelper'); @@ -16,15 +16,19 @@ const emailSchema = joi.object().keys({ from: joi.string().email().required(), recipients: joi.array().items( joi.object().keys({ - userId: joi.number().integer().required(), - email: joi.string().email().required(), - }).required() + userId: joi.number().integer(), + userUUID: joi.string().uuid(), + email: joi.string().email(), + handle: joi.string(), + }).min(1).required() ).min(1).required(), cc: joi.array().items( joi.object().keys({ userId: joi.number().integer(), - email: joi.string().email().required(), - }).required() + userUUID: joi.string().uuid(), + email: joi.string().email(), + handle: joi.string(), + }).min(1) ), data: joi.object().keys({ subject: joi.string(), @@ -48,7 +52,14 @@ const webSchema = joi.object().keys({ serviceId: joi.string().valid(constants.SETTINGS_WEB_SERVICE_ID).required(), type: joi.string().required(), details: joi.object().keys({ - userId: joi.number().integer().required(), + recipients: joi.array().items( + joi.object().keys({ + userId: joi.number().integer(), + userUUID: joi.string().uuid(), + email: joi.string().email(), + handle: joi.string(), + }).min(1).required() + ).min(1).required(), contents: joi.object(), version: joi.number().integer().required(), }).required(), @@ -63,6 +74,97 @@ function validator(data, schema) { return true; } + +/** + * Complete missing user fields of given payload details + * This function mutates the given details object. + * @param {Object} details the object which has recipients array + * @param {Boolean} findEmail true if emails are needed + * @param {Boolean} findUserId true if userIds are needed + * @returns {undefined} + */ +function* completeMissingFields(details, findEmail, findUserId) { + const getFieldsByUserId = []; + const getFieldsByHandle = []; + const getFieldsByUserUUID = []; + const getFieldsByEmail = []; + function findMissingFields(data, email, userId) { + for (const recipient of data) { + if (_.isUndefined(recipient.email) && email) { + if (!_.isUndefined(recipient.userId)) { + getFieldsByUserId.push(recipient); + } else if (!_.isUndefined(recipient.handle)) { + getFieldsByHandle.push(recipient); + } else { + getFieldsByUserUUID.push(recipient); + } + } else if (_.isUndefined(recipient.userId) && userId) { + if (!_.isUndefined(recipient.handle)) { + getFieldsByHandle.push(recipient); + } else if (!_.isUndefined(recipient.email)) { + getFieldsByEmail.push(recipient); + } else { + getFieldsByUserUUID.push(recipient); + } + } + } + } + + findMissingFields(details.recipients, findEmail, findUserId); + if (_.isArray(details.cc) && !_.isEmpty(details.cc)) { + findMissingFields(details.cc, findEmail, false); + } + const foundUsersByHandleOrId = yield tcApiHelper.getUsersByHandlesAndUserIds(getFieldsByHandle, getFieldsByUserId); + if (!_.isEmpty(foundUsersByHandleOrId)) { + for (const user of [...getFieldsByUserId, ...getFieldsByHandle]) { + const found = _.find(foundUsersByHandleOrId, !_.isUndefined(user.handle) + ? ['handle', user.handle] : ['userId', user.userId]) || {}; + if (!_.isUndefined(found.email) && _.isUndefined(user.email)) { + _.assign(user, { email: found.email }); + } + if (!_.isUndefined(found.userId) && _.isUndefined(user.userId)) { + _.assign(user, { userId: found.userId }); + } + } + } + const foundUsersByEmail = yield tcApiHelper.getUsersByEmails(getFieldsByEmail); + if (!_.isEmpty(foundUsersByEmail)) { + for (const user of getFieldsByEmail) { + const found = _.find(foundUsersByEmail, ['email', user.email]) || {}; + if (!_.isUndefined(found.id)) { + _.assign(user, { userId: found.id }); + } + } + } + const foundUsersByUUID = yield tcApiHelper.getUsersByUserUUIDs(getFieldsByUserUUID, true); + if (!_.isEmpty(foundUsersByUUID)) { + for (const user of getFieldsByUserUUID) { + const found = _.find(foundUsersByUUID, ['id', user.userUUID]) || {}; + if (!_.isUndefined(found.externalProfiles) && !_.isEmpty(found.externalProfiles)) { + _.assign(user, { userId: _.toInteger(_.get(found.externalProfiles[0], 'externalId')) }); + } + if (!_.isUndefined(found.handle) && _.isUndefined(user.handle)) { + _.assign(user, { handle: found.handle }); + } + } + + if (findEmail) { + const usersHaveId = _.filter(getFieldsByUserUUID, u => !_.isUndefined(u.userId)); + const usersHaveHandle = _.filter(getFieldsByUserUUID, u => _.isUndefined(u.userId) && !_.isUndefined(u.handle)); + const foundUser = yield tcApiHelper.getUsersByHandlesAndUserIds(usersHaveHandle, usersHaveId); + if (!_.isEmpty(foundUser)) { + for (const user of getFieldsByUserUUID) { + const found = _.find(foundUser, !_.isUndefined(user.handle) + ? ['handle', user.handle] : ['userId', user.userId]) || {}; + if (!_.isUndefined(found.email)) { + _.assign(user, { email: found.email }); + } + } + } + } + } +} + /** * Handle notification message * @param {Object} message the Kafka message @@ -70,11 +172,13 @@ function validator(data, schema) { */ function* handle(message) { const notifications = []; - for (const data of message.payload) { + for (const data of message.payload.notifications) { try { switch (data.serviceId) { case constants.SETTINGS_EMAIL_SERVICE_ID: if (validator(data, emailSchema)) { + // find missing emails and userIds + yield completeMissingFields(data.details, true, true); yield tcApiHelper.notifyUserViaEmail(data); } break; @@ -85,9 +189,11 @@ function* handle(message) { break; case constants.SETTINGS_WEB_SERVICE_ID: if (validator(data, webSchema)) { - const notification = yield tcApiHelper.notifyUserViaWeb(data); - if (notification) { - notifications.push(notification); + // find missing userIds + yield completeMissingFields(data.details, false, true); + const _notifications = yield tcApiHelper.notifyUserViaWeb(data); + if (_notifications) { + notifications.push(..._notifications); } } break; @@ -107,14 +213,16 @@ handle.schema = { originator: joi.string().required(), timestamp: joi.date().required(), 'mime-type': joi.string().required(), - payload: joi.array().items( - joi.object().keys({ - serviceId: joi.string().valid( - constants.SETTINGS_EMAIL_SERVICE_ID, - constants.SETTINGS_SLACK_SERVICE_ID, - constants.SETTINGS_WEB_SERVICE_ID).required(), - }).unknown() - ).min(1).required(), + payload: joi.object().keys({ + notifications: joi.array().items( + joi.object().keys({ + serviceId: joi.string().valid( + constants.SETTINGS_EMAIL_SERVICE_ID, + constants.SETTINGS_SLACK_SERVICE_ID, + constants.SETTINGS_WEB_SERVICE_ID).required(), + }).unknown() + ).min(1).required(), + }).required(), }).required(), };