diff --git a/app.js b/app.js index e6d79c69..08395ffc 100644 --- a/app.js +++ b/app.js @@ -14,6 +14,7 @@ const logger = require('./src/common/logger') const eventHandlers = require('./src/eventHandlers') const interviewService = require('./src/services/InterviewService') const { processScheduler } = require('./src/services/PaymentSchedulerService') +const { sendSurveys } = require('./src/services/SurveyService') // setup express app const app = express() @@ -98,7 +99,8 @@ const server = app.listen(app.get('port'), () => { eventHandlers.init() // schedule updateCompletedInterviews to run every hour schedule.scheduleJob('0 0 * * * *', interviewService.updateCompletedInterviews) - + // schedule sendSurveys + schedule.scheduleJob(config.WEEKLY_SURVEY.CRON, sendSurveys) // schedule payment processing schedule.scheduleJob(config.PAYMENT_PROCESSING.CRON, processScheduler) }) diff --git a/config/default.js b/config/default.js index cb589290..4675ff09 100644 --- a/config/default.js +++ b/config/default.js @@ -180,6 +180,16 @@ module.exports = { INTERNAL_MEMBER_GROUPS: process.env.INTERNAL_MEMBER_GROUPS || ['20000000', '20000001', '20000003', '20000010', '20000015'], // Topcoder skills cache time in minutes TOPCODER_SKILLS_CACHE_TIME: process.env.TOPCODER_SKILLS_CACHE_TIME || 60, + // weekly survey scheduler config + WEEKLY_SURVEY: { + CRON: process.env.WEEKLY_SURVEY_CRON || '0 1 * * 7', + BASE_URL: process.env.WEEKLY_SURVEY_BASE_URL || 'https://api.surveymonkey.net/v3/surveys', + JWT_TOKEN: process.env.WEEKLY_SURVEY_JWT_TOKEN || '', + SURVEY_ID: process.env.WEEKLY_SURVEY_SURVEY_ID || '', + SURVEY_MASTER_COLLECTOR_ID: process.env.WEEKLY_SURVEY_SURVEY_MASTER_COLLECTOR_ID || '', + SURVEY_MASTER_MESSAGE_ID: process.env.WEEKLY_SURVEY_SURVEY_MASTER_MESSAGE_ID || '', + SURVEY_CONTACT_GROUP_ID: process.env.WEEKLY_SURVEY_SURVEY_CONTACT_GROUP_ID || '' + }, // payment scheduler config PAYMENT_PROCESSING: { // switch off actual API calls in Payment Scheduler diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 65d420f1..355b72b4 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -4595,6 +4595,10 @@ components: format: float example: 13 description: "The member rate." + sendWeeklySurvey: + type: boolean + example: true, + description: "whether we should send weekly survey to this ResourceBooking or no" customerRate: type: integer format: float @@ -4652,6 +4656,10 @@ components: format: uuid example: "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a" description: "The external id." + sendWeeklySurvey: + type: boolean + example: true, + description: "whether we should send weekly survey to this ResourceBooking or no" jobId: type: string format: uuid @@ -4709,6 +4717,10 @@ components: format: float example: 13.23 description: "The member rate." + sendWeeklySurvey: + type: boolean + example: true, + description: "whether we should send weekly survey to this ResourceBooking or no" customerRate: type: number format: float @@ -4745,6 +4757,22 @@ components: type: string format: uuid description: "The resource booking id." + sentSurvey: + type: boolean + example: true + description: "whether we've already sent a survey for this WorkPeriod of no" + sentSurveyError: + description: "error details if error happened during sending survey" + type: object + properties: + errorMessage: + type: string + example: "error message" + description: "The error message" + errorCode: + type: integer + example: 429 + description: "HTTP code of error" userHandle: type: string example: "eisbilir" @@ -4822,6 +4850,22 @@ components: maximum: 10 example: 2 description: "The count of the days worked for that work period." + sentSurvey: + type: boolean + example: true + description: "whether we've already sent a survey for this WorkPeriod of no" + sentSurveyError: + description: "error details if error happened during sending survey" + type: object + properties: + errorMessage: + type: string + example: "error message" + description: "The error message" + errorCode: + type: integer + example: 429 + description: "HTTP code of error" WorkPeriodPayment: required: - id diff --git a/migrations/2021-07-26-add-send-weekly-survery-fields.js b/migrations/2021-07-26-add-send-weekly-survery-fields.js new file mode 100644 index 00000000..06a45672 --- /dev/null +++ b/migrations/2021-07-26-add-send-weekly-survery-fields.js @@ -0,0 +1,46 @@ +const config = require('config') +const moment = require('moment') + +module.exports = { + up: async (queryInterface, Sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + try { + await queryInterface.addColumn({ tableName: 'resource_bookings', schema: config.DB_SCHEMA_NAME }, 'send_weekly_survey', + { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + { transaction }) + await queryInterface.addColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey', + { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + { transaction }) + await queryInterface.addColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey_error', + { + type: Sequelize.JSONB({ + errorCode: { + field: 'error_code', + type: Sequelize.INTEGER, + }, + errorMessage: { + field: 'error_message', + type: Sequelize.STRING(255) + }, + }), allowNull: true }, { transaction }) + await queryInterface.sequelize.query(`UPDATE ${config.DB_SCHEMA_NAME}.work_periods SET sent_survey = true where payment_status = 'completed' and end_date <= '${moment().subtract(7, 'days').format('YYYY-MM-DD')}'`, + { transaction }) + await transaction.commit() + } catch (err) { + await transaction.rollback() + throw err + } + }, + down: async (queryInterface, Sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + try { + await queryInterface.removeColumn({ tableName: 'resource_bookings', schema: config.DB_SCHEMA_NAME }, 'send_weekly_survey', { transaction }) + await queryInterface.removeColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey', { transaction }) + await queryInterface.removeColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey_error', { transaction } ) + await transaction.commit() + } catch (err) { + await transaction.rollback() + throw err + } + }, +} diff --git a/src/common/helper.js b/src/common/helper.js index 7f9625be..319eb86a 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -176,6 +176,7 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_RESOURCE_BOOKING')] = { endDate: { type: 'date', format: 'yyyy-MM-dd' }, memberRate: { type: 'float' }, customerRate: { type: 'float' }, + sendWeeklySurvey: { type: 'boolean' }, rateType: { type: 'keyword' }, billingAccountId: { type: 'integer', null_value: 0 }, workPeriods: { @@ -189,6 +190,14 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_RESOURCE_BOOKING')] = { }, projectId: { type: 'integer' }, userId: { type: 'keyword' }, + sentSurvey: { type: 'boolean' }, + sentSurveyError: { + type: 'nested', + properties: { + errorCode: { type: 'integer' }, + errorMessage: { type: 'keyword' } + } + }, startDate: { type: 'date', format: 'yyyy-MM-dd' }, endDate: { type: 'date', format: 'yyyy-MM-dd' }, daysWorked: { type: 'integer' }, @@ -2012,6 +2021,7 @@ async function getMembersSuggest (fragment) { } module.exports = { + encodeQueryString, getParamFromCliArgs, promptUser, sleep, diff --git a/src/common/surveyMonkey.js b/src/common/surveyMonkey.js new file mode 100644 index 00000000..705ce41b --- /dev/null +++ b/src/common/surveyMonkey.js @@ -0,0 +1,242 @@ +/* + * surveymonkey api + * + */ + +const logger = require('./logger') +const config = require('config') +const _ = require('lodash') +const request = require('superagent') +const moment = require('moment') +const { encodeQueryString } = require('./helper') +/** + * This code uses several environment variables + * + * WEEKLY_SURVEY_SURVEY_CONTACT_GROUP_ID - the ID of contacts list which would be used to store all the contacts, + * see https://developer.surveymonkey.com/api/v3/#contact_lists-id + * WEEKLY_SURVEY_SURVEY_MASTER_COLLECTOR_ID - the ID of master collector - this collector should be created manually, + * and all other collectors would be created by copying this master collector. + * This is needed so we can make some config inside master collector which would + * be applied to all collectors. + * WEEKLY_SURVEY_SURVEY_MASTER_MESSAGE_ID - the ID of master message - similar to collector, this message would be created manually + * and then script would create copies of this message to use the same config. + */ + +const localLogger = { + debug: (message, context) => logger.debug({ component: 'SurveyMonkeyAPI', context, message }), + error: (message, context) => logger.error({ component: 'SurveyMonkeyAPI', context, message }), + info: (message, context) => logger.info({ component: 'SurveyMonkeyAPI', context, message }) +} + +function getRemainingRequestCountMessage (response) { + return `today has sent ${response.header['x-ratelimit-app-global-day-limit'] - response.header['x-ratelimit-app-global-day-remaining']} requests` +} + +function enrichErrorMessage (e) { + e.code = _.get(e, 'response.body.error.http_status_code') + e.message = _.get(e, 'response.body.error.message', e.toString()) + + return e +} + +function getSingleItem (lst, errorMessage) { + if (lst.length === 0) { + return null + } + + if (lst.length > 1) { + throw new Error(errorMessage) + } + + return lst[0].id +} + +/* + * get collector name + * + * format `Week Ending yyyy-nth(weeks)` + */ +function getCollectorName (dt) { + return 'Week Ending ' + moment(dt).format('M/D/YYYY') +} + +/* + * search collector by name + */ +async function searchCollector (collectorName) { + const url = `${config.WEEKLY_SURVEY.BASE_URL}/surveys/${config.WEEKLY_SURVEY.SURVEY_ID}/collectors?${encodeQueryString({ name: collectorName })}` + try { + const response = await request + .get(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'searchCollector') + + return getSingleItem(response.body.data, 'More than 1 collector found by name ' + collectorName) + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'searchCollector') + throw enrichedError + } +} + +/* + * create a named collector if not created + * else return the collectId of the named collector + */ +async function createCollector (collectorName) { + let collectorID = await searchCollector(collectorName) + if (collectorID) { + return collectorID + } + + collectorID = await cloneCollector() + await renameCollector(collectorID, collectorName) + + return collectorID +} + +/* + * clone collector from MASTER_COLLECTOR + */ +async function cloneCollector () { + const body = { from_collector_id: `${config.WEEKLY_SURVEY.SURVEY_MASTER_COLLECTOR_ID}` } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/surveys/${config.WEEKLY_SURVEY.SURVEY_ID}/collectors` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'cloneCollector') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'cloneCollector') + throw enrichedError + } +} + +/* + * rename collector + */ +async function renameCollector (collectorId, name) { + const body = { name: name } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}` + try { + const response = await request + .patch(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'renameCollector') + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'renameCollector') + throw enrichedError + } +} + +/* + * create message + */ +async function createMessage (collectorId) { + const body = { + from_collector_id: `${config.WEEKLY_SURVEY.SURVEY_MASTER_COLLECTOR_ID}`, + from_message_id: `${config.WEEKLY_SURVEY.SURVEY_MASTER_MESSAGE_ID}` + } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}/messages` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'createMessage') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'createMessage') + throw enrichedError + } +} + +/** + * Add Contact Email to List for sending a survey + */ +async function upsertContactInSurveyMonkey (list) { + list = _.filter(list, p => p.email) + if (!list.length) { + return [] + } + const body = { + contacts: list + } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/contact_lists/${config.WEEKLY_SURVEY.SURVEY_CONTACT_GROUP_ID}/contacts/bulk` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'upsertContactInSurveyMonkey') + return _.concat(response.body.existing, response.body.succeeded) + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'createMessage') + throw enrichedError + } +} + +async function addContactsToSurvey (collectorId, messageId, contactIds) { + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}/messages/${messageId}/recipients/bulk` + const body = { contact_ids: _.map(contactIds, 'id') } + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'addContactsToSurvey') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'addContactsToSurvey') + throw enrichedError + } +} + +async function sendSurveyAPI (collectorId, messageId) { + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}/messages/${messageId}/send` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({}) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'sendSurveyAPI') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'sendSurveyAPI') + throw enrichedError + } +} + +module.exports = { + getCollectorName, + createCollector, + createMessage, + upsertContactInSurveyMonkey, + addContactsToSurvey, + sendSurveyAPI +} diff --git a/src/models/ResourceBooking.js b/src/models/ResourceBooking.js index 580e6e96..21a222f1 100644 --- a/src/models/ResourceBooking.js +++ b/src/models/ResourceBooking.js @@ -122,6 +122,12 @@ module.exports = (sequelize) => { type: Sequelize.STRING(255), allowNull: false }, + sendWeeklySurvey: { + field: 'send_weekly_survey', + type: Sequelize.BOOLEAN, + defaultValue: true, + allowNull: false + }, billingAccountId: { field: 'billing_account_id', type: Sequelize.BIGINT diff --git a/src/models/WorkPeriod.js b/src/models/WorkPeriod.js index 720e4870..d2a3b12c 100644 --- a/src/models/WorkPeriod.js +++ b/src/models/WorkPeriod.js @@ -56,6 +56,26 @@ module.exports = (sequelize) => { type: Sequelize.UUID, allowNull: false }, + sentSurvey: { + field: 'sent_survey', + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + }, + sentSurveyError: { + field: 'sent_survey_error', + allowNull: true, + type: Sequelize.JSONB({ + errorCode: { + field: 'error_code', + type: Sequelize.INTEGER + }, + errorMessage: { + field: 'error_message', + type: Sequelize.STRING(255) + } + }) + }, userHandle: { field: 'user_handle', type: Sequelize.STRING(50), diff --git a/src/services/ResourceBookingService.js b/src/services/ResourceBookingService.js index a69d205f..23ab5811 100644 --- a/src/services/ResourceBookingService.js +++ b/src/services/ResourceBookingService.js @@ -344,6 +344,7 @@ createResourceBooking.schema = Joi.object().keys({ projectId: Joi.number().integer().required(), userId: Joi.string().uuid().required(), jobId: Joi.string().uuid().allow(null), + sendWeeklySurvey: Joi.boolean().default(true), startDate: Joi.date().format('YYYY-MM-DD').allow(null), endDate: Joi.date().format('YYYY-MM-DD').when('startDate', { is: Joi.exist(), @@ -417,6 +418,7 @@ partiallyUpdateResourceBooking.schema = Joi.object().keys({ memberRate: Joi.number().allow(null), customerRate: Joi.number().allow(null), rateType: Joi.rateType(), + sendWeeklySurvey: Joi.boolean().allow(null), billingAccountId: Joi.number().allow(null) }).required() }).required() @@ -456,6 +458,7 @@ fullyUpdateResourceBooking.schema = Joi.object().keys({ customerRate: Joi.number().allow(null).default(null), rateType: Joi.rateType().required(), status: Joi.resourceBookingStatus().required(), + sendWeeklySurvey: Joi.boolean().allow(null), billingAccountId: Joi.number().allow(null).default(null) }).required() }).required() @@ -536,6 +539,10 @@ async function searchResourceBookings (currentUser, criteria, options) { if (!criteria.sortOrder) { criteria.sortOrder = 'desc' } + + if (_.has(criteria, 'workPeriods.sentSurveyError') && !criteria['workPeriods.sentSurveyError']) { + criteria['workPeriods.sentSurveyError'] = null + } // this option to return data from DB is only for internal usage, and it cannot be passed from the endpoint if (!options.returnFromDB) { try { @@ -580,7 +587,7 @@ async function searchResourceBookings (currentUser, criteria, options) { } esQuery.body.sort.push(sort) // Apply ResourceBooking filters - _.each(_.pick(criteria, ['status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId', 'billingAccountId']), (value, key) => { + _.each(_.pick(criteria, ['sendWeeklySurvey', 'status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId', 'billingAccountId']), (value, key) => { esQuery.body.query.bool.must.push({ term: { [key]: { @@ -616,7 +623,7 @@ async function searchResourceBookings (currentUser, criteria, options) { }) } // Apply WorkPeriod and WorkPeriodPayment filters - const workPeriodFilters = _.pick(criteria, ['workPeriods.paymentStatus', 'workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.userHandle']) + const workPeriodFilters = _.pick(criteria, ['workPeriods.sentSurveyError', 'workPeriods.sentSurvey', 'workPeriods.paymentStatus', 'workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.userHandle']) const workPeriodPaymentFilters = _.pick(criteria, ['workPeriods.payments.status', 'workPeriods.payments.days']) if (!_.isEmpty(workPeriodFilters) || !_.isEmpty(workPeriodPaymentFilters)) { const workPeriodsMust = [] @@ -627,7 +634,7 @@ async function searchResourceBookings (currentUser, criteria, options) { [key]: value } }) - } else { + } else if (key !== 'workPeriods.sentSurveyError') { workPeriodsMust.push({ term: { [key]: { @@ -656,6 +663,7 @@ async function searchResourceBookings (currentUser, criteria, options) { } }) } + esQuery.body.query.bool.must.push({ nested: { path: 'workPeriods', @@ -678,7 +686,9 @@ async function searchResourceBookings (currentUser, criteria, options) { r.workPeriods = _.filter(r.workPeriods, wp => { return _.every(_.omit(workPeriodFilters, 'workPeriods.userHandle'), (value, key) => { key = key.split('.')[1] - if (key === 'paymentStatus') { + if (key === 'sentSurveyError' && !workPeriodFilters['workPeriods.sentSurveyError']) { + return !wp[key] + } else if (key === 'paymentStatus') { return _.includes(value, wp[key]) } else { return wp[key] === value @@ -713,7 +723,7 @@ async function searchResourceBookings (currentUser, criteria, options) { logger.info({ component: 'ResourceBookingService', context: 'searchResourceBookings', message: 'fallback to DB query' }) const filter = { [Op.and]: [] } // Apply ResourceBooking filters - _.each(_.pick(criteria, ['status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId']), (value, key) => { + _.each(_.pick(criteria, ['sendWeeklySurvey', 'status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId']), (value, key) => { filter[Op.and].push({ [key]: value }) }) if (!_.isUndefined(criteria.billingAccountId)) { @@ -763,7 +773,7 @@ async function searchResourceBookings (currentUser, criteria, options) { queryCriteria.include[0].attributes = { exclude: _.map(queryOpt.excludeWP, f => _.split(f, '.')[1]) } } // Apply WorkPeriod filters - _.each(_.pick(criteria, ['workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.paymentStatus']), (value, key) => { + _.each(_.pick(criteria, ['workPeriods.sentSurveyError', 'workPeriods.sentSurvey', 'workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.paymentStatus']), (value, key) => { key = key.split('.')[1] queryCriteria.include[0].where[Op.and].push({ [key]: value }) }) @@ -859,6 +869,7 @@ searchResourceBookings.schema = Joi.object().keys({ Joi.string(), Joi.array().items(Joi.number().integer()) ), + sendWeeklySurvey: Joi.boolean(), billingAccountId: Joi.number().integer(), 'workPeriods.paymentStatus': Joi.alternatives( Joi.string(), @@ -881,6 +892,11 @@ searchResourceBookings.schema = Joi.object().keys({ return value }), 'workPeriods.userHandle': Joi.string(), + 'workPeriods.sentSurvey': Joi.boolean(), + 'workPeriods.sentSurveyError': Joi.object().keys({ + errorCode: Joi.number().integer().min(0), + errorMessage: Joi.string() + }).allow('').optional(), 'workPeriods.isFirstWeek': Joi.when(Joi.ref('workPeriods.startDate', { separator: false }), { is: Joi.exist(), then: Joi.boolean().default(false), diff --git a/src/services/SurveyService.js b/src/services/SurveyService.js new file mode 100644 index 00000000..33641062 --- /dev/null +++ b/src/services/SurveyService.js @@ -0,0 +1,159 @@ +const _ = require('lodash') +const logger = require('../common/logger') +const { searchResourceBookings } = require('./ResourceBookingService') +const { partiallyUpdateWorkPeriod } = require('./WorkPeriodService') +const { Scopes } = require('../../app-constants') +const { getUserById, getMemberDetailsByHandle } = require('../common/helper') +const { getCollectorName, createCollector, createMessage, upsertContactInSurveyMonkey, addContactsToSurvey, sendSurveyAPI } = require('../common/surveyMonkey') + +const resourceBookingCache = {} +const contactIdToWorkPeriodIdMap = {} +const emailToWorkPeriodIdMap = {} + +function buildSentSurveyError (e) { + return { + errorCode: _.get(e, 'code'), + errorMessage: _.get(e, 'message', e.toString()) + } +} + +/** + * Scheduler process entrance + */ +async function sendSurveys () { + const currentUser = { + isMachine: true, + scopes: [Scopes.ALL_WORK_PERIOD, Scopes.ALL_WORK_PERIOD_PAYMENT] + } + + const criteria = { + fields: 'workPeriods,userId,id,sendWeeklySurvey', + sendWeeklySurvey: true, + 'workPeriods.paymentStatus': 'completed', + 'workPeriods.sentSurvey': false, + 'workPeriods.sentSurveyError': '', + jobIds: [], + page: 1 + } + + const options = { + returnAll: true, + returnFromDB: true + } + try { + let resourceBookings = await searchResourceBookings(currentUser, criteria, options) + resourceBookings = resourceBookings.result + + logger.info({ component: 'SurveyService', context: 'sendSurvey', message: 'load workPeriod successfully' }) + + const workPeriods = _.flatten(_.map(resourceBookings, 'workPeriods')) + + const collectors = {} + + // for each WorkPeriod make sure to creat a collector (one per week) + // so several WorkPeriods for the same week would be included into on collector + // and gather contacts (members) from each WorkPeriods + for (const workPeriod of workPeriods) { + try { + const collectorName = getCollectorName(workPeriod.endDate) + + // create collector and message for each week if not yet + if (!collectors[collectorName]) { + const collectorId = await createCollector(collectorName) + const messageId = await createMessage(collectorId) + // create map + contactIdToWorkPeriodIdMap[collectorName] = {} + emailToWorkPeriodIdMap[collectorName] = {} + collectors[collectorName] = { + collectorId, + messageId, + contacts: [] + } + } + + const resourceBooking = _.find(resourceBookings, (r) => r.id === workPeriod.resourceBookingId) + const userInfo = {} + if (!resourceBookingCache[resourceBooking.userId]) { + let user = await getUserById(resourceBooking.userId) + if (!user.email && user.handle) { + user = await getMemberDetailsByHandle(user.handle) + } + if (user.email) { + userInfo.email = user.email + if (user.firstName) { + userInfo.first_name = user.firstName + } + if (user.lastName) { + userInfo.last_name = user.lastName + } + resourceBookingCache[resourceBooking.userId] = userInfo + } + } + emailToWorkPeriodIdMap[collectorName][resourceBookingCache[resourceBooking.userId].email] = workPeriod.id + collectors[collectorName].contacts.push(resourceBookingCache[resourceBooking.userId]) + } catch (e) { + try { + await partiallyUpdateWorkPeriod( + currentUser, + workPeriod.id, + { sentSurveyError: buildSentSurveyError(e) } + ) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: `Error updating survey as failed for Work Period "${workPeriod.id}": ` + e.message }) + } + } + } + + // add contacts + for (const collectorName in collectors) { + const collector = collectors[collectorName] + collectors[collectorName].contacts = await upsertContactInSurveyMonkey(collector.contacts) + + for (const contact of collectors[collectorName].contacts) { + contactIdToWorkPeriodIdMap[collectorName][contact.id] = emailToWorkPeriodIdMap[collectorName][contact.email] + } + } + + // send surveys + for (const collectorName in collectors) { + const collector = collectors[collectorName] + if (collector.contacts.length) { + try { + await addContactsToSurvey( + collector.collectorId, + collector.messageId, + collector.contacts + ) + await sendSurveyAPI(collector.collectorId, collector.messageId) + for (const contactId in contactIdToWorkPeriodIdMap[collectorName]) { + try { + await partiallyUpdateWorkPeriod(currentUser, contactIdToWorkPeriodIdMap[collectorName][contactId], { sentSurvey: true }) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: `Error updating survey as sent for Work Period "${contactIdToWorkPeriodIdMap[collectorName][contactId]}": ` + e.message }) + } + } + } catch (e) { + for (const contactId in contactIdToWorkPeriodIdMap[collectorName]) { + try { + await partiallyUpdateWorkPeriod( + currentUser, + contactIdToWorkPeriodIdMap[collectorName][contactId], + { sentSurveyError: buildSentSurveyError(e) } + ) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: `Error updating survey as failed for Work Period "${contactIdToWorkPeriodIdMap[collectorName][contactId]}": ` + e.message }) + } + } + } + } + } + + logger.info({ component: 'SurveyService', context: 'sendSurvey', message: 'Processing weekly surveys is completed' }) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: 'Error sending surveys: ' + e.message }) + } +} + +module.exports = { + sendSurveys +} diff --git a/src/services/WorkPeriodService.js b/src/services/WorkPeriodService.js index aa061d5d..2e0b28d1 100644 --- a/src/services/WorkPeriodService.js +++ b/src/services/WorkPeriodService.js @@ -241,6 +241,7 @@ createWorkPeriod.schema = Joi.object().keys({ resourceBookingId: Joi.string().uuid().required(), startDate: Joi.workPeriodStartDate(), endDate: Joi.workPeriodEndDate(), + sentSurvey: Joi.boolean().default(true), daysWorked: Joi.number().integer().min(0).max(5).required(), daysPaid: Joi.number().default(0).forbidden(), paymentTotal: Joi.number().default(0).forbidden(), @@ -274,7 +275,9 @@ async function updateWorkPeriod (currentUser, id, data) { throw new errors.ConflictError('Work Period dates are not compatible with Resource Booking dates') } data.paymentStatus = helper.calculateWorkPeriodPaymentStatus(_.assign({}, oldValue, data)) - data.updatedBy = await helper.getUserId(currentUser.userId) + if (!currentUser.isMachine) { + data.updatedBy = await helper.getUserId(currentUser.userId) + } const updated = await workPeriod.update(data) const updatedDataWithoutPayments = _.omit(updated.toJSON(), ['payments']) const oldValueWithoutPayments = _.omit(oldValue, ['payments']) @@ -297,7 +300,12 @@ partiallyUpdateWorkPeriod.schema = Joi.object().keys({ currentUser: Joi.object().required(), id: Joi.string().uuid().required(), data: Joi.object().keys({ - daysWorked: Joi.number().integer().min(0).max(10) + daysWorked: Joi.number().integer().min(0).max(10), + sentSurvey: Joi.boolean(), + sentSurveyError: Joi.object().keys({ + errorCode: Joi.number().integer().min(0), + errorMessage: Joi.string() + }) }).required().min(1) }).required() @@ -496,6 +504,11 @@ searchWorkPeriods.schema = Joi.object().keys({ userHandle: Joi.string(), projectId: Joi.number().integer(), resourceBookingId: Joi.string().uuid(), + sentSurvey: Joi.boolean(), + sentSurveyError: Joi.object().keys({ + errorCode: Joi.number().integer().min(0), + errorMessage: Joi.string() + }), resourceBookingIds: Joi.alternatives( Joi.string(), Joi.array().items(Joi.string().uuid())