diff --git a/.circleci/config.yml b/.circleci/config.yml index 8b2a88c..2297526 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -102,7 +102,7 @@ workflows: context : org-global filters: branches: - only: [dev, 'feature/general-purpose-notifications-usage'] + only: [dev, 'hotfix/V5-API-Standards'] - "build-prod": context : org-global filters: diff --git a/config/default.js b/config/default.js index 750802c..bbcc8c5 100644 --- a/config/default.js +++ b/config/default.js @@ -32,6 +32,7 @@ module.exports = { TC_API_V4_BASE_URL: process.env.TC_API_V4_BASE_URL || '', TC_API_V5_BASE_URL: process.env.TC_API_V5_BASE_URL || '', API_CONTEXT_PATH: process.env.API_CONTEXT_PATH || '/v5/notifications', + TC_API_BASE_URL: process.env.TC_API_BASE_URL || '', // Configuration for generating machine to machine auth0 token. // The token will be used for calling another internal API. @@ -45,22 +46,23 @@ module.exports = { AUTH0_PROXY_SERVER_URL: process.env.AUTH0_PROXY_SERVER_URL, KAFKA_CONSUMER_RULESETS: { - // key is Kafka topic name, value is array of ruleset which have key as handler function name defined in src/processors/index.js + // key is Kafka topic name, value is array of ruleset which have key as + // handler function name defined in src/processors/index.js 'challenge.notification.events': [ { handleChallenge: /** topic handler name */ { type: 'UPDATE_DRAFT_CHALLENGE', - roles: ["Submitter" /** Competitor */, "Copilot", "Reviewer"], + roles: ['Submitter' /** Competitor */, 'Copilot', 'Reviewer'], notification: { id: 0, /** challengeid or projectid */ name: '', /** challenge name */ group: 'Challenge', - title: 'Challenge specification is modified.' - } - } - } + title: 'Challenge specification is modified.', + }, + }, + }, ], 'notifications.autopilot.events': [ { @@ -68,33 +70,33 @@ module.exports = { { phaseTypeName: 'Checkpoint Screening', state: 'START', - roles: ["Copilot", "Reviewer"], + roles: ['Copilot', 'Reviewer'], notification: { id: 0, /** challengeid or projectid */ name: '', /** challenge name */ group: 'Challenge', - title: 'Challenge checkpoint review.' - } - } - } + title: 'Challenge checkpoint review.', + }, + }, + }, ], 'submission.notification.create': [ { handleSubmission: { resource: 'submission', - roles: ["Copilot", "Reviewer"], + roles: ['Copilot', 'Reviewer'], selfOnly: true /** Submitter only */, notification: { id: 0, /** challengeid or projectid */ name: '', /** challenge name */ group: 'Submission', - title: 'A new submission is uploaded.' - } - } - } + title: 'A new submission is uploaded.', + }, + }, + }, ], //'notifications.community.challenge.created': ['handleChallengeCreated'], //'notifications.community.challenge.phasewarning': ['handleChallengePhaseWarning'], diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 8bcbc3a..4cc98be 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -9,6 +9,7 @@ const config = require('./config'); const notificationServer = require('../index'); const _ = require('lodash'); const service = require('./service'); +const helpers = require('./helpers'); const { BUS_API_EVENT } = require('./constants'); const EVENTS = require('./events-config').EVENTS; const PROJECT_ROLE_RULES = require('./events-config').PROJECT_ROLE_RULES; @@ -85,27 +86,28 @@ const getNotificationsForMentionedUser = (logger, eventConfig, content) => { // only one per userHandle notifications = _.uniqBy(notifications, 'userHandle'); - return new Promise((resolve, reject) => { // eslint-disable-line no-unused-vars - const handles = _.map(notifications, 'userHandle'); - if (handles.length > 0) { - service.getUsersByHandle(handles).then((users) => { - _.forEach(notifications, (notification) => { - const mentionedUser = _.find(users, { handle: notification.userHandle }); - notification.userId = mentionedUser ? mentionedUser.userId.toString() : notification.userHandle; - }); - resolve(notifications); - }).catch((error) => { - if (logger) { - logger.error(error); - logger.info('Unable to send notification to mentioned user') + const handles = _.map(notifications, 'userHandle'); + if (handles.length > 0) { + return service.getUsersByHandle(handles).then((users) => { + _.forEach(notifications, (notification) => { + const mentionedUser = _.find(users, { handle: notification.userHandle }); + notification.userId = mentionedUser ? mentionedUser.userId.toString() : null; + if (!notification.userId && logger) {// such notifications would be discarded later after aggregation + logger.info(`Unable to find user with handle ${notification.userHandle}`); } - //resolves with empty notification which essentially means we are unable to send notification to mentioned user - resolve([]); }); - } else { - resolve([]); - } - }); + return Promise.resolve(notifications); + }).catch((error) => { + if (logger) { + logger.error(error); + logger.info('Unable to send notification to mentioned user'); + } + //resolves with empty notification which essentially means we are unable to send notification to mentioned user + return Promise.resolve([]); + }); + } else { + return Promise.resolve([]); + } }; /** @@ -150,7 +152,7 @@ const getProjectMembersNotifications = (eventConfig, project) => { return Promise.resolve([]); } - return new Promise((resolve) => { + return Promise.promisify((callback) => { let notifications = []; const projectMembers = _.get(project, 'members', []); @@ -185,8 +187,8 @@ const getProjectMembersNotifications = (eventConfig, project) => { // only one per userId notifications = _.uniqBy(notifications, 'userId'); - resolve(notifications); - }); + callback(null, notifications); + })(); }; /** @@ -254,6 +256,83 @@ const getNotificationsForTopicStarter = (eventConfig, topicId) => { }); }; +/** + * Filter members by project roles + * + * @params {Array} List of project roles + * @params {Array} List of project members + * + * @returns {Array} List of objects with user ids + */ +const filterMembersByRoles = (roles, members) => { + let result = []; + + roles.forEach(projectRole => { + result = result.concat( + _.filter(members, PROJECT_ROLE_RULES[projectRole]) + .map(projectMember => ({ + userId: projectMember.userId.toString(), + })) + ); + }); + + return result; +}; + +/** + * Exclude private posts notification + * + * @param {Object} eventConfig event configuration + * @param {Object} project project details + * @param {Array} tags list of message tags + * + * @return {Promise} resolves to a list of notifications + */ +const getExcludedPrivatePostNotifications = (eventConfig, project, tags) => { + // skip if message is not private or exclusion rule is not configured + if (!_.includes(tags, 'MESSAGES') || !eventConfig.privatePostsForProjectRoles) { + return Promise.resolve([]); + } + + const members = _.get(project, 'members', []); + const notifications = filterMembersByRoles(eventConfig.privatePostsForProjectRoles, members); + + return Promise.resolve(notifications); +}; + +/** + * Exclude notifications about posts inside draft phases + * + * @param {Object} eventConfig event configuration + * @param {Object} project project details + * @param {Array} tags list of message tags + * + * @return {Promise} resolves to a list of notifications + */ +const getExcludeDraftPhasesNotifications = (eventConfig, project, tags) => { + // skip is no exclusion rule is configured + if (!eventConfig.draftPhasesForProjectRoles) { + return Promise.resolve([]); + } + + const phaseId = helpers.extractPhaseId(tags); + // skip if it is not phase notification + if (!phaseId) { + return Promise.resolve([]); + } + + // exclude all user with configured roles if phase is in draft state + return service.getPhase(project.id, phaseId) + .then((phase) => { + if (phase.status === 'draft') { + const members = _.get(project, 'members', []); + const notifications = filterMembersByRoles(eventConfig.draftPhasesForProjectRoles, members); + + return Promise.resolve(notifications); + } + }); +}; + /** * Exclude notifications using exclude rules of the event config * @@ -281,12 +360,17 @@ const excludeNotifications = (logger, notifications, eventConfig, message, data) // and after filter out such notifications from the notifications list // TODO move this promise all together with `_.uniqBy` to one function // and reuse it here and in `handler` function + const tags = _.get(message, 'tags', []); + return Promise.all([ getNotificationsForTopicStarter(excludeEventConfig, message.topicId), getNotificationsForUserId(excludeEventConfig, message.userId), getNotificationsForMentionedUser(logger, excludeEventConfig, message.postContent), getProjectMembersNotifications(excludeEventConfig, project), getTopCoderMembersNotifications(excludeEventConfig), + // these are special exclude rules which are only working for excluding notifications but not including + getExcludedPrivatePostNotifications(excludeEventConfig, project, tags), + getExcludeDraftPhasesNotifications(excludeEventConfig, project, tags), ]).then((notificationsPerSource) => ( _.uniqBy(_.flatten(notificationsPerSource), 'userId') )).then((excludedNotifications) => { @@ -332,10 +416,10 @@ const handler = (topic, message, logger, callback) => { } // get project details - service.getProject(projectId).then(project => { + return service.getProject(projectId).then(project => { let allNotifications = []; - Promise.all([ + return Promise.all([ // the order in this list defines the priority of notification for the SAME user // upper in this list - higher priority // NOTE: always add all handles here, they have to check by themselves: @@ -357,7 +441,7 @@ const handler = (topic, message, logger, callback) => { project, }) )).then((notifications) => { - allNotifications = _.filter(notifications, notification => notification.userId !== `${message.initiatorUserId}`); + allNotifications = _.filter(notifications, n => n.userId && n.userId !== `${message.initiatorUserId}`); if (eventConfig.includeUsers && message[eventConfig.includeUsers] && message[eventConfig.includeUsers].length > 0) { diff --git a/connect/events-config.js b/connect/events-config.js index acf92bc..460cd47 100644 --- a/connect/events-config.js +++ b/connect/events-config.js @@ -5,6 +5,7 @@ const { BUS_API_EVENT } = require('./constants'); // project member role names const PROJECT_ROLE_OWNER = 'owner'; +const PROJECT_ROLE_CUSTOMER = 'customer'; const PROJECT_ROLE_COPILOT = 'copilot'; const PROJECT_ROLE_MANAGER = 'manager'; const PROJECT_ROLE_MEMBER = 'member'; @@ -13,6 +14,7 @@ const PROJECT_ROLE_ACCOUNT_MANAGER = 'account_manager'; // project member role rules const PROJECT_ROLE_RULES = { [PROJECT_ROLE_OWNER]: { role: 'customer', isPrimary: true }, + [PROJECT_ROLE_CUSTOMER]: { role: 'customer' }, [PROJECT_ROLE_COPILOT]: { role: 'copilot' }, [PROJECT_ROLE_MANAGER]: { role: 'manager' }, [PROJECT_ROLE_ACCOUNT_MANAGER]: { role: 'account_manager' }, @@ -20,6 +22,7 @@ const PROJECT_ROLE_RULES = { }; // TopCoder roles +// eslint-disable-next-line no-unused-vars const ROLE_CONNECT_COPILOT = 'Connect Copilot'; const ROLE_CONNECT_MANAGER = 'Connect Manager'; const ROLE_CONNECT_COPILOT_MANAGER = 'Connect Copilot Manager'; @@ -123,31 +126,53 @@ const EVENTS = [ version: 2, projectRoles: [PROJECT_ROLE_OWNER, PROJECT_ROLE_COPILOT, PROJECT_ROLE_MANAGER, PROJECT_ROLE_MEMBER], toMentionedUsers: true, + exclude: { + privatePostsForProjectRoles: [PROJECT_ROLE_CUSTOMER], + }, }, { type: BUS_API_EVENT.CONNECT.POST.CREATED, version: 2, projectRoles: [PROJECT_ROLE_OWNER, PROJECT_ROLE_COPILOT, PROJECT_ROLE_MANAGER, PROJECT_ROLE_MEMBER], toTopicStarter: true, toMentionedUsers: true, + exclude: { + draftPhasesForProjectRoles: [PROJECT_ROLE_CUSTOMER], + privatePostsForProjectRoles: [PROJECT_ROLE_CUSTOMER], + }, }, { type: BUS_API_EVENT.CONNECT.POST.UPDATED, version: 2, projectRoles: [PROJECT_ROLE_OWNER, PROJECT_ROLE_COPILOT, PROJECT_ROLE_MANAGER, PROJECT_ROLE_MEMBER], toTopicStarter: true, toMentionedUsers: true, + exclude: { + draftPhasesForProjectRoles: [PROJECT_ROLE_CUSTOMER], + privatePostsForProjectRoles: [PROJECT_ROLE_CUSTOMER], + }, }, { type: BUS_API_EVENT.CONNECT.POST.MENTION, + exclude: { + draftPhasesForProjectRoles: [PROJECT_ROLE_CUSTOMER], + privatePostsForProjectRoles: [PROJECT_ROLE_CUSTOMER], + }, }, { type: BUS_API_EVENT.CONNECT.TOPIC.DELETED, version: 2, projectRoles: [PROJECT_ROLE_OWNER, PROJECT_ROLE_COPILOT, PROJECT_ROLE_MANAGER, PROJECT_ROLE_MEMBER], toTopicStarter: false, + exclude: { + privatePostsForProjectRoles: [PROJECT_ROLE_CUSTOMER], + }, }, { type: BUS_API_EVENT.CONNECT.POST.DELETED, version: 2, projectRoles: [PROJECT_ROLE_OWNER, PROJECT_ROLE_COPILOT, PROJECT_ROLE_MANAGER, PROJECT_ROLE_MEMBER], + exclude: { + draftPhasesForProjectRoles: [PROJECT_ROLE_CUSTOMER], + privatePostsForProjectRoles: [PROJECT_ROLE_CUSTOMER], + }, }, { type: BUS_API_EVENT.CONNECT.PROJECT.LINK_CREATED, diff --git a/connect/helpers.js b/connect/helpers.js index 3092323..94691fd 100644 --- a/connect/helpers.js +++ b/connect/helpers.js @@ -2,6 +2,9 @@ * Helper functions */ const Remarkable = require('remarkable'); +const _ = require('lodash'); + +const PHASE_ID_REGEXP = /phase#(\d+)/; /** * Convert markdown into raw draftjs state @@ -42,7 +45,20 @@ const sanitizeEmail = (email) => { return ''; }; +/** + * Helper method to extract phaseId from tag + * + * @param {Array} tags list of message tags + * + * @returns {String} phase id + */ +const extractPhaseId = (tags) => { + const phaseIds = tags.map((tag) => _.get(tag.match(PHASE_ID_REGEXP), '1', null)); + return _.find(phaseIds, (phaseId) => phaseId !== null); +}; + module.exports = { markdownToHTML, sanitizeEmail, + extractPhaseId, }; diff --git a/connect/notificationServices/email.js b/connect/notificationServices/email.js index 40a64a5..cdcdaa2 100644 --- a/connect/notificationServices/email.js +++ b/connect/notificationServices/email.js @@ -4,8 +4,6 @@ const _ = require('lodash'); const jwt = require('jsonwebtoken'); const co = require('co'); -const fs = require('fs'); -const path = require('path'); const { logger, busService, eventScheduler, notificationService } = require('../../index'); const { createEventScheduler, SCHEDULED_EVENT_STATUS } = eventScheduler; @@ -16,29 +14,43 @@ const { SETTINGS_EMAIL_SERVICE_ID, ACTIVE_USER_STATUSES, } = require('../constants'); -const { EVENTS, EVENT_BUNDLES } = require('../events-config'); +const { EVENT_BUNDLES } = require('../events-config'); const helpers = require('../helpers'); const service = require('../service'); function replacePlaceholders(term, data) { - let placeholders = term.match(/<[a-zA-Z]+>/g); + const placeholders = term.match(/<[a-zA-Z]+>/g); let ret = term; if (placeholders && placeholders.length) { _(placeholders).each(p => { - let values = _.map(data, p.slice(1, -1)); - const total = values.length; - let replacement = values.length < 3 ? - values.join(', ') : - values.slice(0, 2).join(', ') + ' and ' + (total - 3) + 'others'; + const values = _.map(data, p.slice(1, -1)); + // TODO remove this code if possible. + // This code appears to be not in use causing lint errors. + // For now I'm commenting it, in case it contains some valuable logic. + // But after confirmation that it's redundant it has to be removed. + // + // const total = values.length; + // const replacement = values.length < 3 ? + // values.join(', ') : + // values.slice(0, 2).join(', ') + ' and ' + (total - 3) + 'others'; ret = ret.replace(p, values.join(', ')); }); } return ret; } +function getEventGroupKey(value) { + const key = _.chain(EVENT_BUNDLES) + .keys() + .find(k => _.includes(_.get(EVENT_BUNDLES, `${k}.types`), _.get(value, 'data.data.type'))) + .value(); + if (!key) return 'DEFAULT'; + return key; +} + function getSections(projectUserEvents) { - let sections = []; + const sections = []; _.chain(projectUserEvents) .groupBy(value => getEventGroupKey(value)) .forIn((value, key) => { @@ -49,7 +61,7 @@ function getSections(projectUserEvents) { notifications: _(value).map(v => v.data.data).value(), }); } else { - _.chain(value).groupBy(n => n.data.data[EVENT_BUNDLES[key].groupBy]).forIn((groupValue, groupKey) => { + _.chain(value).groupBy(n => n.data.data[EVENT_BUNDLES[key].groupBy]).forIn((groupValue) => { let title = EVENT_BUNDLES[key].title; title = replacePlaceholders(title, _(groupValue).map(g => g.data.data).value()); sections.push({ @@ -140,15 +152,6 @@ const scheduler = createEventScheduler( handleScheduledEvents ); -function getEventGroupKey(value) { - let key = _.chain(EVENT_BUNDLES) - .keys() - .find(key => _.includes(_.get(EVENT_BUNDLES, `${key}.types`), _.get(value, 'data.data.type'))) - .value(); - if (!key) return 'DEFAULT'; - return key; -} - /** * Prepares data to be provided to the template to render a single notification. * @@ -245,30 +248,31 @@ function handler(topicName, messageJSON, notification) { }; eventMessage.data[eventMessage.data.type] = true; _.assign(eventMessage.data, notification.contents); - + // message service may return tags // to understand if post notification is regarding phases or no, we will try to get phaseId from the tags - const tags = _.get(notification.contents, 'tags', []) - const PHASE_ID_REGEXP = /phase#(\d+)/ - const phaseIds = tags.map((tag) => _.get(tag.match(PHASE_ID_REGEXP), '1', null)) - const phaseId = _.find(phaseIds, (phaseId) => phaseId !== null) + const tags = _.get(notification.contents, 'tags', []); + const phaseId = helpers.extractPhaseId(tags); if (phaseId) { eventMessage.data.phaseId = phaseId; } - // if the notification is regarding topic: dashboard topic, dashboard post or phase post + // if the notification is regarding topic: dashboard topic, dashboard post or phase post // we build a link to the post if (eventMessage.data.topicId) { // phase post if (eventMessage.data.phaseId) { + // eslint-disable-next-line max-len eventMessage.data.postURL = `${config.CONNECT_URL}/projects/${eventMessage.data.projectId}/plan#phase-${eventMessage.data.phaseId}-posts-${eventMessage.data.postId}`; // dashboard post } else if (eventMessage.data.postId) { + // eslint-disable-next-line max-len eventMessage.data.postURL = `${config.CONNECT_URL}/projects/${eventMessage.data.projectId}#comment-${eventMessage.data.postId}`; // dashboard topic } else { + // eslint-disable-next-line max-len eventMessage.data.postURL = `${config.CONNECT_URL}/projects/${eventMessage.data.projectId}#feed-${eventMessage.data.topicId}`; } } @@ -319,19 +323,21 @@ function handler(topicName, messageJSON, notification) { } // if notifications has to be bundled - let bundlePeriod = _.get(settings, `notifications['${notificationType}'].${SETTINGS_EMAIL_SERVICE_ID}.bundlePeriod`); + let bundlePeriod = _.get(settings, + `notifications['${notificationType}'].${SETTINGS_EMAIL_SERVICE_ID}.bundlePeriod`); bundlePeriod = bundlePeriod && bundlePeriod.trim().length > 0 ? bundlePeriod : null; // if bundling is not explicitly set and the event is not a messaging event, assume bundling enabled if (!bundlePeriod && !messagingEvent) { // finds the event category for the notification type - let eventBundleCategory = _.findKey(EVENT_BUNDLES, b => b.types && b.types.indexOf(notificationType) !== -1); + const eventBundleCategory = _.findKey(EVENT_BUNDLES, b => b.types && b.types.indexOf(notificationType) !== -1); if (eventBundleCategory) { const eventBundle = EVENT_BUNDLES[eventBundleCategory]; // if we find the event category for the notification, use the bundle settings from the first event if (eventBundle && eventBundle.types && eventBundle.types.length) { const firstEvtInBundle = eventBundle.types[0]; - const firstEvtBundleSettingPath = `notifications['${firstEvtInBundle}'].${SETTINGS_EMAIL_SERVICE_ID}.bundlePeriod`; - let firstEvtBundlePeriod = _.get(settings, firstEvtBundleSettingPath); + const firstEvtBundleSettingPath = + `notifications['${firstEvtInBundle}'].${SETTINGS_EMAIL_SERVICE_ID}.bundlePeriod`; + const firstEvtBundlePeriod = _.get(settings, firstEvtBundleSettingPath); bundlePeriod = firstEvtBundlePeriod; logger.debug('Assuming bundle period of first event in the event category=>', bundlePeriod); } @@ -341,7 +347,7 @@ function handler(topicName, messageJSON, notification) { } logger.debug('bundlePeriod=>', bundlePeriod); - if (bundlePeriod && 'immediately' !== bundlePeriod && !requiresImmediateAttention) { + if (bundlePeriod && bundlePeriod !== 'immediately' && !requiresImmediateAttention) { if (!SCHEDULED_EVENT_PERIOD[bundlePeriod]) { throw new Error(`User's '${notification.userId}' setting for service` + ` '${SETTINGS_EMAIL_SERVICE_ID}' option 'bundlePeriod' has unsupported value '${bundlePeriod}'.`); @@ -359,7 +365,7 @@ function handler(topicName, messageJSON, notification) { } else { // send single field "notificationsHTML" with the rendered template eventMessage.data = wrapIndividualNotification({ data: eventMessage }); - //console.log(eventMessage.data.contents); + // console.log(eventMessage.data.contents); // send event to bus api return busService.postEvent({ @@ -374,7 +380,7 @@ function handler(topicName, messageJSON, notification) { .catch((err) => { logger.error(`Failed to send ${eventType} event` + `; error: ${err.message}` - + `; with body ${JSON.stringify(eventMessage)} to bus api`); + + `; with body ${JSON.stringify(eventMessage)} to bus api`); }); } }); diff --git a/connect/service.js b/connect/service.js index adff1db..aae9f38 100644 --- a/connect/service.js +++ b/connect/service.js @@ -232,10 +232,46 @@ const getTopic = (topicId, logger) => ( }) ); +/** + * Get phase details + * + * @param {String} projectId project id + * @param {String} phaseId phase id + * + * @return {Promise} promise resolved to phase details + */ +const getPhase = (projectId, phaseId) => ( + M2m.getMachineToken(config.AUTH0_CLIENT_ID, config.AUTH0_CLIENT_SECRET) + .then((token) => ( + request + .get(`${config.TC_API_V4_BASE_URL}/projects/${projectId}/phases/${phaseId}`) + .set('accept', 'application/json') + .set('authorization', `Bearer ${token}`) + .then((res) => { + if (!_.get(res, 'body.result.success')) { + throw new Error(`Failed to get phase details of project id: ${projectId}, phase id: ${phaseId}`); + } + const project = _.get(res, 'body.result.content'); + return project; + }).catch((err) => { + const errorDetails = _.get(err, 'response.body.result.content.message'); + throw new Error( + `Failed to get phase details of project id: ${projectId}, phase id: ${phaseId}.` + + (errorDetails ? ' Server response: ' + errorDetails : '') + ); + }) + )) + .catch((err) => { + err.message = 'Error generating m2m token: ' + err.message; + throw err; + }) +); + module.exports = { getProject, getRoleMembers, getUsersById, getUsersByHandle, getTopic, + getPhase, }; diff --git a/consumer.js b/consumer.js index 60676ae..5313f0a 100644 --- a/consumer.js +++ b/consumer.js @@ -8,7 +8,7 @@ const _ = require('lodash'); const Kafka = require('no-kafka'); const co = require('co'); global.Promise = require('bluebird'); -const healthcheck = require('topcoder-healthcheck-dropin') +const healthcheck = require('topcoder-healthcheck-dropin'); const logger = require('./src/common/logger'); const models = require('./src/models'); @@ -63,13 +63,13 @@ function startKafkaConsumer() { return co(function* () { // run each handler for (let i = 0; i < ruleSets.length; i += 1) { - const rule = ruleSets[i] - const handlerFuncArr = _.keys(rule) - const handlerFuncName = _.get(handlerFuncArr, "0") + const rule = ruleSets[i]; + const handlerFuncArr = _.keys(rule); + const handlerFuncName = _.get(handlerFuncArr, '0'); try { - const handler = processors[handlerFuncName] - const handlerRuleSets = rule[handlerFuncName] + const handler = processors[handlerFuncName]; + const handlerRuleSets = rule[handlerFuncName]; if (!handler) { logger.error(`Handler ${handlerFuncName} is not defined`); continue; @@ -79,7 +79,7 @@ function startKafkaConsumer() { const notifications = yield handler(messageJSON, handlerRuleSets); if (notifications && notifications.length > 0) { // save notifications in bulk to improve performance - logger.info(`Going to insert ${notifications.length} notifications in database.`) + logger.info(`Going to insert ${notifications.length} notifications in database.`); yield models.Notification.bulkCreate(_.map(notifications, (n) => ({ userId: n.userId, type: n.type || topic, @@ -87,9 +87,9 @@ function startKafkaConsumer() { read: false, seen: false, version: n.version || null, - }))) + }))); // logging - logger.info(`Saved ${notifications.length} notifications`) + logger.info(`Saved ${notifications.length} notifications`); /* logger.info(` for users: ${ _.map(notifications, (n) => n.userId).join(', ') }`); */ @@ -112,15 +112,15 @@ function startKafkaConsumer() { const check = function () { if (!consumer.client.initialBrokers && !consumer.client.initialBrokers.length) { - return false + return false; } - let connected = true + let connected = true; consumer.client.initialBrokers.forEach(conn => { - logger.debug(`url ${conn.server()} - connected=${conn.connected}`) - connected = conn.connected & connected - }) - return connected - } + logger.debug(`url ${conn.server()} - connected=${conn.connected}`); + connected = conn.connected & connected; + }); + return connected; + }; // Start kafka consumer logger.info('Starting kafka consumer'); @@ -132,7 +132,7 @@ function startKafkaConsumer() { }]) .then(() => { logger.info('Kafka consumer initialized successfully'); - healthcheck.init([check]) + healthcheck.init([check]); }) .catch((err) => { logger.error('Kafka consumer failed'); diff --git a/docs/swagger_api.yaml b/docs/swagger_api.yaml index 1afa80f..1e77b61 100644 --- a/docs/swagger_api.yaml +++ b/docs/swagger_api.yaml @@ -1,275 +1,350 @@ -swagger: "2.0" -info: - title: "TOPCODER NOTIFICATIONS SERIES - NOTIFICATIONS SERVER" - description: "TOPCODER NOTIFICATIONS SERIES - NOTIFICATIONS SERVER" - version: "1.0.0" -host: "localhost:4000" -basePath: "/v5/notifications" -schemes: -- "http" -securityDefinitions: - jwt: - type: apiKey - name: Authorization - in: header - description: JWT Authentication. Provide API Key in the form 'Bearer <token>'. - -paths: - /list: - get: - description: - list notifications - produces: - - application/json - security: - - jwt: [] - parameters: - - name: offset - in: query - description: The offset - required: false - type: integer - format: int32 - - name: limit - in: query - description: The limit - required: false - type: integer - format: int32 - - name: type - in: query - description: The type - required: false - type: string - - name: read - in: query - description: The read flag, either 'true' or 'false' - required: false - type: string - responses: - 200: - description: OK - schema: - type: object - properties: - items: - type: array - items: - type: object - properties: - id: - type: integer - format: int64 - description: the notification id - userId: - type: integer - format: int64 - description: user id - type: - type: string - description: notification type - read: - type: boolean - description: read flag - seen: - type: boolean - description: seen flag - contents: - type: object - description: the event message in JSON format - createdAt: - type: string - description: created at date string - updatedAt: - type: string - description: updated at date string - offset: - type: integer - format: int32 - description: the offset - limit: - type: integer - format: int32 - description: the limit - totalCount: - type: integer - format: int32 - description: the total count - 401: - description: "Authentication failed." - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error." - schema: - $ref: "#/definitions/Error" - /{id}/read: - put: - description: - mark notification(s) as read, id can be single id or '-' separated ids - security: - - jwt: [] - parameters: - - in: path - name: id - description: notification id - required: true - type: integer - format: int64 - responses: - 200: - description: OK, the notification(s) are marked as read - 400: - description: "Invalid input" - schema: - $ref: "#/definitions/Error" - 401: - description: "authentication failed" - schema: - $ref: "#/definitions/Error" - 403: - description: "Action not allowed." - schema: - $ref: "#/definitions/Error" - 404: - description: "Notification is not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error." - schema: - $ref: "#/definitions/Error" - /read: - put: - description: - mark all notifications as read - security: - - jwt: [] - responses: - 200: - description: OK, all notifications are marked as read - 401: - description: "authentication failed" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error." - schema: - $ref: "#/definitions/Error" - /{id}/seen: - put: - description: - mark notification(s) as seen, id can be single id or '-' separated ids - security: - - jwt: [] - parameters: - - in: path - name: id - description: notification id - required: true - type: integer - format: int64 - responses: - 200: - description: OK, the notification(s) are marked as seen - 400: - description: "Invalid input" - schema: - $ref: "#/definitions/Error" - 401: - description: "authentication failed" - schema: - $ref: "#/definitions/Error" - 403: - description: "Action not allowed." - schema: - $ref: "#/definitions/Error" - 404: - description: "Notification is not found" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error." - schema: - $ref: "#/definitions/Error" - /settings: - get: - description: - get notification settings - produces: - - application/json - security: - - jwt: [] - responses: - 200: - description: OK. Each key is topic name, value is object of deliveryMethod - value mappings for the topic - schema: - type: object - 401: - description: "Authentication failed." - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error." - schema: - $ref: "#/definitions/Error" - put: - description: - update notification settings - consumes: - - application/json - security: - - jwt: [] - parameters: - - in: body - name: body - description: notification settings - required: true - schema: - type: array - items: - type: object - properties: - topic: - type: string - description: the topic - deliveryMethod: - type: string - description: the delivery method - value: - type: string - description: the value for the delivery method - responses: - 200: - description: OK - 400: - description: "Invalid input" - schema: - $ref: "#/definitions/Error" - 401: - description: "authentication failed" - schema: - $ref: "#/definitions/Error" - 500: - description: "Internal server error." - schema: - $ref: "#/definitions/Error" - -definitions: - Error: - properties: - error: - type: string - details: - type: array - items: - type: object - properties: - message: - type: string - path: - type: string - type: - type: string - context: - type: object +swagger: "2.0" +info: + title: "TOPCODER NOTIFICATIONS SERIES - NOTIFICATIONS SERVER" + description: "TOPCODER NOTIFICATIONS SERIES - NOTIFICATIONS SERVER" + version: "1.0.0" +host: "localhost:4000" +basePath: "/v5/notifications" +schemes: + - "http" +securityDefinitions: + jwt: + type: apiKey + name: Authorization + in: header + description: JWT Authentication. Provide API Key in the form 'Bearer <token>'. + +paths: + /: + get: + description: + list notifications + produces: + - application/json + security: + - jwt: [] + parameters: + - name: page + in: query + description: The page + required: false + type: integer + format: int32 + - name: per_page + in: query + description: The number of rows served + required: false + type: integer + format: int32 + - name: platform + in: query + description: The platform + required: false + type: string + - name: type + in: query + description: The type + required: false + type: string + - name: read + in: query + description: The read flag, either 'true' or 'false' + required: false + type: string + responses: + 200: + description: OK + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + format: int64 + description: the notification id + userId: + type: integer + format: int64 + description: user id + type: + type: string + description: notification type + read: + type: boolean + description: read flag + seen: + type: boolean + description: seen flag + contents: + type: object + description: the event message in JSON format + createdAt: + type: string + description: created at date string + updatedAt: + type: string + description: updated at date string + page: + type: integer + format: int32 + description: the page + per_page: + type: integer + format: int32 + description: the per_page + totalCount: + type: integer + format: int32 + description: the total count + 401: + description: "Authentication failed." + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error." + schema: + $ref: "#/definitions/Error" + /{id}: + patch: + description: + update notification + security: + - jwt: [] + parameters: + - in: path + name: id + description: notification id + required: true + type: integer + format: int64 + - in: body + name: body + description: notification payload + required: true + schema: + $ref: "#/definitions/NotificationUpdatePayload" + responses: + 200: + description: OK, the notification(s) are updated + schema: + $ref: "#/definitions/Notification" + 400: + description: "Invalid input" + schema: + $ref: "#/definitions/Error" + 401: + description: "authentication failed" + schema: + $ref: "#/definitions/Error" + 403: + description: "Action not allowed." + schema: + $ref: "#/definitions/Error" + 404: + description: "Notification is not found" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error." + schema: + $ref: "#/definitions/Error" + /{id}/read: + put: + description: + mark notification(s) as read, id can be single id or '-' separated ids + security: + - jwt: [] + parameters: + - in: path + name: id + description: notification id + required: true + type: integer + format: int64 + responses: + 200: + description: OK, the notification(s) are marked as read + 400: + description: "Invalid input" + schema: + $ref: "#/definitions/Error" + 401: + description: "authentication failed" + schema: + $ref: "#/definitions/Error" + 403: + description: "Action not allowed." + schema: + $ref: "#/definitions/Error" + 404: + description: "Notification is not found" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error." + schema: + $ref: "#/definitions/Error" + /read: + put: + description: + mark all notifications as read + security: + - jwt: [] + responses: + 200: + description: OK, all notifications are marked as read + 401: + description: "authentication failed" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error." + schema: + $ref: "#/definitions/Error" + /{id}/seen: + put: + description: + mark notification(s) as seen, id can be single id or '-' separated ids + security: + - jwt: [] + parameters: + - in: path + name: id + description: notification id + required: true + type: integer + format: int64 + responses: + 200: + description: OK, the notification(s) are marked as seen + 400: + description: "Invalid input" + schema: + $ref: "#/definitions/Error" + 401: + description: "authentication failed" + schema: + $ref: "#/definitions/Error" + 403: + description: "Action not allowed." + schema: + $ref: "#/definitions/Error" + 404: + description: "Notification is not found" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error." + schema: + $ref: "#/definitions/Error" + /settings: + get: + description: + get notification settings + produces: + - application/json + security: + - jwt: [] + responses: + 200: + description: OK. Each key is topic name, value is object of deliveryMethod - value mappings for the topic + schema: + type: object + 401: + description: "Authentication failed." + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error." + schema: + $ref: "#/definitions/Error" + put: + description: + update notification settings + consumes: + - application/json + security: + - jwt: [] + parameters: + - in: body + name: body + description: notification settings + required: true + schema: + type: array + items: + type: object + properties: + topic: + type: string + description: the topic + deliveryMethod: + type: string + description: the delivery method + value: + type: string + description: the value for the delivery method + responses: + 200: + description: OK + 400: + description: "Invalid input" + schema: + $ref: "#/definitions/Error" + 401: + description: "authentication failed" + schema: + $ref: "#/definitions/Error" + 500: + description: "Internal server error." + schema: + $ref: "#/definitions/Error" + +definitions: + NotificationUpdatePayload: + properties: + read: + type: boolean + seen: + type: boolean + Notification: + properties: + id: + type: integer + userId: + type: integer + type: + type: string + contents: + type: object + version: + type: integer + read: + type: boolean + seen: + type: boolean + createdAt: + type: string + updatedAt: + type: string + Error: + properties: + error: + type: string + details: + type: array + items: + type: object + properties: + message: + type: string + path: + type: string + type: + type: string + context: + type: object diff --git a/docs/tc-notification-server-api.postman_collection.json b/docs/tc-notification-server-api.postman_collection.json index f7c65c0..7267bf8 100644 --- a/docs/tc-notification-server-api.postman_collection.json +++ b/docs/tc-notification-server-api.postman_collection.json @@ -1,83 +1,108 @@ { - "id": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "id": "8fe3fe1d-744f-4c0e-ac34-e2029e6308e9", "name": "tc-notification-server-api", "description": "", "auth": null, "events": null, - "variables": null, + "variables": [], "order": [ - "19332a51-03e8-4f5c-8f85-4d28d6dfe6f4", - "543cab06-2c7d-4aed-8cf3-0808463254d5", - "76779830-a8a4-4636-8c03-1801b3d1863d", - "cb2299a5-dac7-4c40-80c4-7b1694138354", - "d57ba947-a5e7-410a-b978-76882f33c86e", - "fce69847-5bf8-4b07-bcaf-6352db4ba923" + "68de66b4-c2c6-4b3b-9df4-42f7cf4c4fa3", + "c31aa2cc-e377-4b59-a4ee-9e5181449996", + "4dc587b5-2da8-4055-8317-88f7a677eb34", + "e323baef-7406-4665-9bbe-3f64ce4a427c", + "af4744ee-ceba-4a35-a14a-eb38290139fb", + "0012295a-9139-47bb-91b3-1f53d94bd928", + "8981f01c-fd95-4d19-abcf-36265682a610" ], "folders_order": [ - "dbebd550-6c33-4778-b467-d56decf16c91" + "060feceb-1658-4fee-9d71-0fedc75effe9" ], "folders": [ { - "id": "dbebd550-6c33-4778-b467-d56decf16c91", + "id": "060feceb-1658-4fee-9d71-0fedc75effe9", "name": "failure", "description": "", "auth": null, "events": null, - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "collection": "8fe3fe1d-744f-4c0e-ac34-e2029e6308e9", "folder": null, "order": [ - "1b3b6480-ea94-4027-8898-f82f28e2bea6", - "59fc9f2b-28c5-4cff-b21b-11ab51bf67d8", - "cbc03cb1-6dfe-43fd-8e99-8c56923c2978", - "d293d2c5-230d-4f34-8c97-1adc1f2f89b4", - "da23d550-55b3-4f7d-9131-735956d62f6d", - "f2246cf7-7aae-4ea0-9d92-1d932d340302", - "f3f3a847-46f6-4059-b167-b436078fb112" + "47aaac74-b661-48d6-bed1-d61efd13b668", + "c2d28896-3208-4326-a65e-1718c1f891c5", + "c8bb53de-91fc-443c-a8fc-d13f503d0e5f", + "208a8afb-8287-4b93-88fd-67320d0a7f0f", + "021b87b0-caca-41e9-85ea-16a9d09bab22", + "334aae9e-38fc-425e-9649-e09c6188ddc2", + "9445564c-74bb-4599-8eca-ae292d5b37fc", + "88038b95-0a16-4a36-8835-0713c731e433" ], "folders_order": [] } ], "requests": [ { - "id": "19332a51-03e8-4f5c-8f85-4d28d6dfe6f4", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "name": "getSettings", - "url": "{{URL}}/settings", + "id": "0012295a-9139-47bb-91b3-1f53d94bd928", + "name": "updateNotification", + "url": "{{URL}}/1", "description": "", "data": [], "dataMode": "raw", "headerData": [ { - "key": "Content-Type", - "value": "application/json", "description": "", - "enabled": true + "enabled": true, + "key": "Content-Type", + "value": "application/json" }, { - "key": "Authorization", - "value": "Bearer {{TOKEN}}", "description": "", - "enabled": true + "enabled": true, + "key": "Authorization", + "value": "Bearer {{TOKEN}}" } ], - "method": "GET", + "method": "PATCH", "pathVariableData": [], "queryParams": [], "auth": null, "events": null, "folder": null, - "rawModeData": "", + "rawModeData": "{\n\t\"read\": true\n}", "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "1b3b6480-ea94-4027-8898-f82f28e2bea6", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "name": "listNotifications - invalid read filter", - "url": "{{URL}}/list?offset=0&limit=20&type=notifications.connect.project.updated&read=yes", + "id": "021b87b0-caca-41e9-85ea-16a9d09bab22", + "name": "markAllRead - missing token", + "url": "{{URL}}/read", "description": "", "data": [], "dataMode": "raw", + "headerData": [ + { + "key": "Content-Type", + "value": "application/json", + "description": "", + "enabled": false + } + ], + "method": "PUT", + "pathVariableData": [], + "queryParams": [], + "auth": null, + "events": null, + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "rawModeData": "", + "headers": "//Content-Type: application/json\n", + "pathVariables": {} + }, + { + "id": "208a8afb-8287-4b93-88fd-67320d0a7f0f", + "name": "listNotifications - invalid limit", + "url": "{{URL}}/list?offset=0&limit=abc&type=notifications.connect.project.updated", + "description": "", + "data": null, + "dataMode": null, "headerData": [ { "key": "Content-Type", @@ -104,7 +129,7 @@ }, { "key": "limit", - "value": "20", + "value": "abc", "equals": true, "description": "", "enabled": true @@ -118,24 +143,22 @@ }, { "key": "read", - "value": "yes", + "value": "false", "equals": true, "description": "", - "enabled": true + "enabled": false } ], "auth": null, "events": null, - "folder": "dbebd550-6c33-4778-b467-d56decf16c91", - "rawModeData": "", + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "543cab06-2c7d-4aed-8cf3-0808463254d5", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "name": "markAllRead", - "url": "{{URL}}/read", + "id": "334aae9e-38fc-425e-9649-e09c6188ddc2", + "name": "updateSettings - invalid body", + "url": "{{URL}}/settings", "description": "", "data": [], "dataMode": "raw", @@ -144,7 +167,7 @@ "key": "Content-Type", "value": "application/json", "description": "", - "enabled": false + "enabled": true }, { "key": "Authorization", @@ -158,19 +181,18 @@ "queryParams": [], "auth": null, "events": null, - "folder": null, - "rawModeData": "", - "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "rawModeData": "[\n\t{\n\t\t\"wrong\": \"notifications.connect.project.created\",\n\t\t\"deliveryMethod\": 123,\n\t\t\"value\": \"on\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.project.created\",\n\t\t\"deliveryMethod\": \"sms\",\n\t\t\"value\": \"off\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.project.created\",\n\t\t\"deliveryMethod\": \"web\",\n\t\t\"value\": \"off\"\n\t},\n\t{\n\t\t\"topic\": \"notifications.connect.message.posted\",\n\t\t\"deliveryMethod\": \"email\",\n\t\t\"value\": \"off\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.message.posted\",\n\t\t\"deliveryMethod\": \"sms\",\n\t\t\"value\": \"on\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.message.posted\",\n\t\t\"deliveryMethod\": \"web\",\n\t\t\"value\": \"on\"\n\t}\n]", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "59fc9f2b-28c5-4cff-b21b-11ab51bf67d8", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "name": "getSettings - invalid token", - "url": "{{URL}}/settings", + "id": "47aaac74-b661-48d6-bed1-d61efd13b668", + "name": "listNotifications - invalid read filter", + "url": "{{URL}}/list?offset=0&limit=20&type=notifications.connect.project.updated&read=yes", "description": "", - "data": [], - "dataMode": "raw", + "data": null, + "dataMode": null, "headerData": [ { "key": "Content-Type", @@ -180,24 +202,51 @@ }, { "key": "Authorization", - "value": "Bearer invalid", + "value": "Bearer {{TOKEN}}", "description": "", "enabled": true } ], "method": "GET", "pathVariableData": [], - "queryParams": [], + "queryParams": [ + { + "key": "offset", + "value": "0", + "equals": true, + "description": "", + "enabled": true + }, + { + "key": "limit", + "value": "20", + "equals": true, + "description": "", + "enabled": true + }, + { + "key": "type", + "value": "notifications.connect.project.updated", + "equals": true, + "description": "", + "enabled": true + }, + { + "key": "read", + "value": "yes", + "equals": true, + "description": "", + "enabled": true + } + ], "auth": null, "events": null, - "folder": "dbebd550-6c33-4778-b467-d56decf16c91", - "rawModeData": "", - "headers": "Content-Type: application/json\nAuthorization: Bearer invalid\n", + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "76779830-a8a4-4636-8c03-1801b3d1863d", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "id": "4dc587b5-2da8-4055-8317-88f7a677eb34", "name": "markAsRead", "url": "{{URL}}/1/read", "description": "", @@ -228,13 +277,12 @@ "pathVariables": {} }, { - "id": "cb2299a5-dac7-4c40-80c4-7b1694138354", - "name": "TC API - get project", - "url": "https://api.topcoder-dev.com/v4/projects/1936", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "id": "68de66b4-c2c6-4b3b-9df4-42f7cf4c4fa3", + "name": "getSettings", + "url": "{{URL}}/settings", "description": "", - "data": [], - "dataMode": "raw", + "data": null, + "dataMode": null, "headerData": [ { "key": "Content-Type", @@ -243,8 +291,8 @@ "enabled": true }, { - "key": "authorization", - "value": "Bearer {{TC_ADMIN_TOKEN}}", + "key": "Authorization", + "value": "Bearer {{TOKEN}}", "description": "", "enabled": true } @@ -255,143 +303,47 @@ "auth": null, "events": null, "folder": null, - "responses": [ - { - "id": "ae658c70-e29d-4d49-aefd-944af0e4f811", - "name": "test111", - "status": "", - "mime": "", - "language": "json", - "text": "{\"id\":\"95744bd2-2830-4014-8885-7182a6225953\",\"version\":\"v4\",\"result\":{\"success\":true,\"status\":200,\"content\":{\"id\":1936,\"directProjectId\":12147,\"billingAccountId\":null,\"name\":\"Test-prj\",\"description\":\"Test description\",\"external\":null,\"bookmarks\":[],\"estimatedPrice\":null,\"actualPrice\":null,\"terms\":[],\"type\":\"app_dev\",\"status\":\"draft\",\"details\":{\"products\":[\"api_dev\"],\"appDefinition\":{\"primaryTarget\":\"desktop\",\"goal\":{\"value\":\"Goal\"},\"users\":{\"value\":\"Developers\"},\"notes\":\"Notes\"},\"utm\":{},\"hideDiscussions\":true},\"challengeEligibility\":[],\"cancelReason\":null,\"createdAt\":\"2017-11-01T15:45:51.000Z\",\"updatedAt\":\"2017-11-01T15:45:51.000Z\",\"createdBy\":305384,\"updatedBy\":305384,\"members\":[{\"id\":2997,\"userId\":305384,\"role\":\"customer\",\"isPrimary\":true,\"createdAt\":\"2017-11-01T15:45:51.000Z\",\"updatedAt\":\"2017-11-01T15:45:51.000Z\",\"createdBy\":305384,\"updatedBy\":305384,\"projectId\":1936}],\"attachments\":[]},\"metadata\":{\"totalCount\":1}}}", - "responseCode": { - "code": 200, - "name": "OK", - "detail": "Standard response for successful HTTP requests. The actual response will depend on the request method used. In a GET request, the response will contain an entity corresponding to the requested resource. In a POST request the response will contain an entity describing or containing the result of the action." - }, - "requestObject": null, - "headers": [ - { - "name": "access-control-allow-credentials", - "key": "access-control-allow-credentials", - "value": "true", - "description": "Indicates whether or not the response to the request can be exposed when the credentials flag is true. When used as part of a response to a preflight request, this indicates whether or not the actual request can be made using credentials." - }, - { - "name": "access-control-allow-headers", - "key": "access-control-allow-headers", - "value": "Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since", - "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the actual request." - }, - { - "name": "access-control-allow-methods", - "key": "access-control-allow-methods", - "value": "GET, POST, OPTIONS, DELETE, PUT, PATCH", - "description": "Specifies the method or methods allowed when accessing the resource. This is used in response to a preflight request." - }, - { - "name": "connection", - "key": "connection", - "value": "keep-alive", - "description": "Options that are desired for the connection" - }, - { - "name": "content-encoding", - "key": "content-encoding", - "value": "gzip", - "description": "The type of encoding used on the data." - }, - { - "name": "content-length", - "key": "content-length", - "value": "491", - "description": "The length of the response body in octets (8-bit bytes)" - }, - { - "name": "content-type", - "key": "content-type", - "value": "application/json; charset=utf-8", - "description": "The mime type of this content" - }, - { - "name": "date", - "key": "date", - "value": "Thu, 02 Nov 2017 04:28:20 GMT", - "description": "The date and time that the message was sent" - }, - { - "name": "etag", - "key": "etag", - "value": "W/\"3a6-4pbtTNq19Shn10rc0k+HRsoAyMw\"", - "description": "An identifier for a specific version of a resource, often a message digest" - }, - { - "name": "server", - "key": "server", - "value": "nginx/1.9.7", - "description": "A name for the server" - }, - { - "name": "x-powered-by", - "key": "x-powered-by", - "value": "Express", - "description": "Specifies the technology (ASP.NET, PHP, JBoss, e.g.) supporting the web application (version details are often in X-Runtime, X-Version, or X-AspNet-Version)" - }, - { - "name": "x-request-id", - "key": "x-request-id", - "value": "95744bd2-2830-4014-8885-7182a6225953", - "description": "Custom header" - } - ], - "cookies": [], - "request": "cb2299a5-dac7-4c40-80c4-7b1694138354", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452" - } - ], - "rawModeData": "", - "headers": "Content-Type: application/json\nauthorization: Bearer {{TC_ADMIN_TOKEN}}\n", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "cbc03cb1-6dfe-43fd-8e99-8c56923c2978", - "name": "markAsRead - not found", - "url": "{{URL}}/1111111/read", + "id": "88038b95-0a16-4a36-8835-0713c731e433", + "name": "updateNotification - invalid action", + "url": "{{URL}}/1", "description": "", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", "data": [], "dataMode": "raw", "headerData": [ { - "key": "Content-Type", - "value": "application/json", "description": "", - "enabled": false + "enabled": true, + "key": "Content-Type", + "value": "application/json" }, { - "key": "Authorization", - "value": "Bearer {{TOKEN}}", "description": "", - "enabled": true + "enabled": true, + "key": "Authorization", + "value": "Bearer {{TOKEN}}" } ], - "method": "PUT", + "method": "PATCH", "pathVariableData": [], "queryParams": [], "auth": null, "events": null, - "folder": "dbebd550-6c33-4778-b467-d56decf16c91", - "rawModeData": "", - "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "rawModeData": "{\n\t\"read\": true,\n\t\"seen\": false\n}", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "d293d2c5-230d-4f34-8c97-1adc1f2f89b4", - "name": "listNotifications - invalid limit", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "url": "{{URL}}/list?offset=0&limit=abc&type=notifications.connect.project.updated", + "id": "8981f01c-fd95-4d19-abcf-36265682a610", + "name": "listNotifications", + "url": "{{URL}}?page=1&per_page=20&platform=connect", "description": "", - "data": [], - "dataMode": "raw", + "data": null, + "dataMode": null, "headerData": [ { "key": "Content-Type", @@ -410,22 +362,22 @@ "pathVariableData": [], "queryParams": [ { - "key": "offset", - "value": "0", + "key": "page", + "value": "1", "equals": true, "description": "", "enabled": true }, { - "key": "limit", - "value": "abc", + "key": "per_page", + "value": "20", "equals": true, "description": "", "enabled": true }, { - "key": "type", - "value": "notifications.connect.project.updated", + "key": "platform", + "value": "connect", "equals": true, "description": "", "enabled": true @@ -440,48 +392,66 @@ ], "auth": null, "events": null, - "folder": "dbebd550-6c33-4778-b467-d56decf16c91", - "rawModeData": "", + "folder": null, "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "d57ba947-a5e7-410a-b978-76882f33c86e", - "name": "updateSettings", - "url": "{{URL}}/settings", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "id": "9445564c-74bb-4599-8eca-ae292d5b37fc", + "name": "listNotifications - invalid page", + "url": "{{URL}}/list?page=-1&per_page=20", "description": "", - "data": [], - "dataMode": "raw", + "data": null, + "dataMode": null, "headerData": [ { + "description": "", + "enabled": true, "key": "Content-Type", - "value": "application/json", + "value": "application/json" + }, + { + "description": "", + "enabled": true, + "key": "Authorization", + "value": "Bearer {{TOKEN}}" + } + ], + "method": "GET", + "pathVariableData": [], + "queryParams": [ + { + "key": "page", + "value": "-1", + "equals": true, "description": "", "enabled": true }, { - "key": "Authorization", - "value": "Bearer {{TOKEN}}", + "key": "per_page", + "value": "20", + "equals": true, "description": "", "enabled": true + }, + { + "key": "read", + "value": "false", + "equals": true, + "description": "", + "enabled": false } ], - "method": "PUT", - "pathVariableData": [], - "queryParams": [], "auth": null, "events": null, - "folder": null, - "rawModeData": "{\n \"notifications\": {\n \"notifications.connect.project.active\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.updated\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.left\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.paused\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.approved\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.fileUploaded\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.canceled\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.topic.created\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.copilotJoined\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.post.deleted\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.created\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.assignedAsOwner\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.completed\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.topic.deleted\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.post.created\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.joined\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.removed\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.specificationModified\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.managerJoined\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.submittedForReview\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.linkCreated\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.post.edited\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n }\n },\n \"services\": {\n \"email\": {\n \"bundlePeriod\": \"every10minutes\"\n }\n }\n}", + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "da23d550-55b3-4f7d-9131-735956d62f6d", - "name": "markAllRead - missing token", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "url": "{{URL}}/read", + "id": "af4744ee-ceba-4a35-a14a-eb38290139fb", + "name": "updateSettings", + "url": "{{URL}}/settings", "description": "", "data": [], "dataMode": "raw", @@ -490,7 +460,13 @@ "key": "Content-Type", "value": "application/json", "description": "", - "enabled": false + "enabled": true + }, + { + "key": "Authorization", + "value": "Bearer {{TOKEN}}", + "description": "", + "enabled": true } ], "method": "PUT", @@ -498,19 +474,18 @@ "queryParams": [], "auth": null, "events": null, - "folder": "dbebd550-6c33-4778-b467-d56decf16c91", - "rawModeData": "", - "headers": "//Content-Type: application/json\n", + "folder": null, + "rawModeData": "{\n \"notifications\": {\n \"notifications.connect.project.active\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.updated\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.left\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.paused\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.approved\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.fileUploaded\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.canceled\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.topic.created\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.copilotJoined\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.post.deleted\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.created\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.assignedAsOwner\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.completed\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.topic.deleted\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.post.created\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.joined\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.removed\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.specificationModified\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.member.managerJoined\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.submittedForReview\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.linkCreated\": {\n \"email\": {\n \"enabled\": \"yes\"\n },\n \"web\": {\n \"enabled\": \"yes\"\n }\n },\n \"notifications.connect.project.post.edited\": {\n \"web\": {\n \"enabled\": \"yes\"\n },\n \"email\": {\n \"enabled\": \"yes\"\n }\n }\n },\n \"services\": {\n \"email\": {\n \"bundlePeriod\": \"every10minutes\"\n }\n }\n}", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "f2246cf7-7aae-4ea0-9d92-1d932d340302", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "name": "updateSettings - invalid body", + "id": "c2d28896-3208-4326-a65e-1718c1f891c5", + "name": "getSettings - invalid token", "url": "{{URL}}/settings", "description": "", - "data": [], - "dataMode": "raw", + "data": null, + "dataMode": null, "headerData": [ { "key": "Content-Type", @@ -520,26 +495,24 @@ }, { "key": "Authorization", - "value": "Bearer {{TOKEN}}", + "value": "Bearer invalid", "description": "", "enabled": true } ], - "method": "PUT", + "method": "GET", "pathVariableData": [], "queryParams": [], "auth": null, "events": null, - "folder": "dbebd550-6c33-4778-b467-d56decf16c91", - "rawModeData": "[\n\t{\n\t\t\"wrong\": \"notifications.connect.project.created\",\n\t\t\"deliveryMethod\": 123,\n\t\t\"value\": \"on\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.project.created\",\n\t\t\"deliveryMethod\": \"sms\",\n\t\t\"value\": \"off\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.project.created\",\n\t\t\"deliveryMethod\": \"web\",\n\t\t\"value\": \"off\"\n\t},\n\t{\n\t\t\"topic\": \"notifications.connect.message.posted\",\n\t\t\"deliveryMethod\": \"email\",\n\t\t\"value\": \"off\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.message.posted\",\n\t\t\"deliveryMethod\": \"sms\",\n\t\t\"value\": \"on\"\n\t}, {\n\t\t\"topic\": \"notifications.connect.message.posted\",\n\t\t\"deliveryMethod\": \"web\",\n\t\t\"value\": \"on\"\n\t}\n]", - "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "headers": "Content-Type: application/json\nAuthorization: Bearer invalid\n", "pathVariables": {} }, { - "id": "f3f3a847-46f6-4059-b167-b436078fb112", - "name": "listNotifications - invalid offset", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "url": "{{URL}}/list?offset=-1&limit=20&type=notifications.connect.project.updated", + "id": "c31aa2cc-e377-4b59-a4ee-9e5181449996", + "name": "markAllRead", + "url": "{{URL}}/read", "description": "", "data": [], "dataMode": "raw", @@ -548,7 +521,7 @@ "key": "Content-Type", "value": "application/json", "description": "", - "enabled": true + "enabled": false }, { "key": "Authorization", @@ -557,50 +530,20 @@ "enabled": true } ], - "method": "GET", + "method": "PUT", "pathVariableData": [], - "queryParams": [ - { - "key": "offset", - "value": "-1", - "equals": true, - "description": "", - "enabled": true - }, - { - "key": "limit", - "value": "20", - "equals": true, - "description": "", - "enabled": true - }, - { - "key": "type", - "value": "notifications.connect.project.updated", - "equals": true, - "description": "", - "enabled": true - }, - { - "key": "read", - "value": "false", - "equals": true, - "description": "", - "enabled": false - } - ], + "queryParams": [], "auth": null, "events": null, - "folder": "dbebd550-6c33-4778-b467-d56decf16c91", + "folder": null, "rawModeData": "", - "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "fce69847-5bf8-4b07-bcaf-6352db4ba923", - "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", - "name": "listNotifications", - "url": "{{URL}}/list?offset=0&limit=20", + "id": "c8bb53de-91fc-443c-a8fc-d13f503d0e5f", + "name": "markAsRead - not found", + "url": "{{URL}}/1111111/read", "description": "", "data": [], "dataMode": "raw", @@ -609,7 +552,7 @@ "key": "Content-Type", "value": "application/json", "description": "", - "enabled": true + "enabled": false }, { "key": "Authorization", @@ -618,44 +561,138 @@ "enabled": true } ], - "method": "GET", + "method": "PUT", "pathVariableData": [], - "queryParams": [ + "queryParams": [], + "auth": null, + "events": null, + "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "rawModeData": "", + "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "pathVariables": {} + }, + { + "id": "e323baef-7406-4665-9bbe-3f64ce4a427c", + "name": "TC API - get project", + "url": "https://api.topcoder-dev.com/v4/projects/1936", + "description": "", + "data": null, + "dataMode": null, + "headerData": [ { - "key": "offset", - "value": "0", - "equals": true, + "key": "Content-Type", + "value": "application/json", "description": "", "enabled": true }, { - "key": "limit", - "value": "20", - "equals": true, + "key": "authorization", + "value": "Bearer {{TC_ADMIN_TOKEN}}", "description": "", "enabled": true - }, - { - "key": "type", - "value": "notifications.connect.project.updated", - "equals": true, - "description": "", - "enabled": false - }, - { - "key": "read", - "value": "false", - "equals": true, - "description": "", - "enabled": false } ], + "method": "GET", + "pathVariableData": [], + "queryParams": [], "auth": null, "events": null, "folder": null, - "rawModeData": "", - "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "responses": [ + { + "id": "3e0e5441-9c98-4a09-a256-9d825e2c76f8", + "name": "test111", + "status": "", + "mime": "", + "language": "json", + "text": "{\"id\":\"95744bd2-2830-4014-8885-7182a6225953\",\"version\":\"v4\",\"result\":{\"success\":true,\"status\":200,\"content\":{\"id\":1936,\"directProjectId\":12147,\"billingAccountId\":null,\"name\":\"Test-prj\",\"description\":\"Test description\",\"external\":null,\"bookmarks\":[],\"estimatedPrice\":null,\"actualPrice\":null,\"terms\":[],\"type\":\"app_dev\",\"status\":\"draft\",\"details\":{\"products\":[\"api_dev\"],\"appDefinition\":{\"primaryTarget\":\"desktop\",\"goal\":{\"value\":\"Goal\"},\"users\":{\"value\":\"Developers\"},\"notes\":\"Notes\"},\"utm\":{},\"hideDiscussions\":true},\"challengeEligibility\":[],\"cancelReason\":null,\"createdAt\":\"2017-11-01T15:45:51.000Z\",\"updatedAt\":\"2017-11-01T15:45:51.000Z\",\"createdBy\":305384,\"updatedBy\":305384,\"members\":[{\"id\":2997,\"userId\":305384,\"role\":\"customer\",\"isPrimary\":true,\"createdAt\":\"2017-11-01T15:45:51.000Z\",\"updatedAt\":\"2017-11-01T15:45:51.000Z\",\"createdBy\":305384,\"updatedBy\":305384,\"projectId\":1936}],\"attachments\":[]},\"metadata\":{\"totalCount\":1}}}", + "responseCode": { + "code": 200, + "name": "OK", + "detail": "Standard response for successful HTTP requests. The actual response will depend on the request method used. In a GET request, the response will contain an entity corresponding to the requested resource. In a POST request the response will contain an entity describing or containing the result of the action." + }, + "requestObject": null, + "headers": [ + { + "key": "access-control-allow-credentials", + "value": "true", + "name": "access-control-allow-credentials", + "description": "Indicates whether or not the response to the request can be exposed when the credentials flag is true. When used as part of a response to a preflight request, this indicates whether or not the actual request can be made using credentials." + }, + { + "key": "access-control-allow-headers", + "value": "Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since", + "name": "access-control-allow-headers", + "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the actual request." + }, + { + "key": "access-control-allow-methods", + "value": "GET, POST, OPTIONS, DELETE, PUT, PATCH", + "name": "access-control-allow-methods", + "description": "Specifies the method or methods allowed when accessing the resource. This is used in response to a preflight request." + }, + { + "key": "connection", + "value": "keep-alive", + "name": "connection", + "description": "Options that are desired for the connection" + }, + { + "key": "content-encoding", + "value": "gzip", + "name": "content-encoding", + "description": "The type of encoding used on the data." + }, + { + "key": "content-length", + "value": "491", + "name": "content-length", + "description": "The length of the response body in octets (8-bit bytes)" + }, + { + "key": "content-type", + "value": "application/json; charset=utf-8", + "name": "content-type", + "description": "The mime type of this content" + }, + { + "key": "date", + "value": "Thu, 02 Nov 2017 04:28:20 GMT", + "name": "date", + "description": "The date and time that the message was sent" + }, + { + "key": "etag", + "value": "W/\"3a6-4pbtTNq19Shn10rc0k+HRsoAyMw\"", + "name": "etag", + "description": "An identifier for a specific version of a resource, often a message digest" + }, + { + "key": "server", + "value": "nginx/1.9.7", + "name": "server", + "description": "A name for the server" + }, + { + "key": "x-powered-by", + "value": "Express", + "name": "x-powered-by", + "description": "Specifies the technology (ASP.NET, PHP, JBoss, e.g.) supporting the web application (version details are often in X-Runtime, X-Version, or X-AspNet-Version)" + }, + { + "key": "x-request-id", + "value": "95744bd2-2830-4014-8885-7182a6225953", + "name": "x-request-id", + "description": "Custom header" + } + ], + "cookies": [], + "request": "e323baef-7406-4665-9bbe-3f64ce4a427c", + "collection": "8fe3fe1d-744f-4c0e-ac34-e2029e6308e9" + } + ], + "headers": "Content-Type: application/json\nauthorization: Bearer {{TC_ADMIN_TOKEN}}\n", "pathVariables": {} } ] -} +} \ No newline at end of file diff --git a/emails/src/partials/invites.html b/emails/src/partials/invites.html index 4cd14e2..f616a14 100644 --- a/emails/src/partials/invites.html +++ b/emails/src/partials/invites.html @@ -7,7 +7,7 @@ - +
IMGIMG {{title}}. diff --git a/emails/src/partials/project-files.html b/emails/src/partials/project-files.html index 821001c..6276b17 100644 --- a/emails/src/partials/project-files.html +++ b/emails/src/partials/project-files.html @@ -7,7 +7,7 @@ - +
IMGIMG {{title}} diff --git a/emails/src/partials/project-links.html b/emails/src/partials/project-links.html index f70188a..7fb6d6a 100644 --- a/emails/src/partials/project-links.html +++ b/emails/src/partials/project-links.html @@ -7,7 +7,7 @@ - +
IMGIMG {{title}} diff --git a/emails/src/partials/project-plan.html b/emails/src/partials/project-plan.html index 79a5d87..dcd772e 100644 --- a/emails/src/partials/project-plan.html +++ b/emails/src/partials/project-plan.html @@ -7,7 +7,7 @@ - +
IMGIMG {{title}} diff --git a/emails/src/partials/project-specification.html b/emails/src/partials/project-specification.html index e6258c6..b46edfa 100644 --- a/emails/src/partials/project-specification.html +++ b/emails/src/partials/project-specification.html @@ -6,7 +6,7 @@ - +
IMGIMG {{title}} diff --git a/emails/src/partials/project-status.html b/emails/src/partials/project-status.html index ff62ad4..4e058f8 100644 --- a/emails/src/partials/project-status.html +++ b/emails/src/partials/project-status.html @@ -7,7 +7,7 @@ - +
IMGIMG {{title}} diff --git a/emails/src/partials/project-team.html b/emails/src/partials/project-team.html index 89deb86..bb250e3 100644 --- a/emails/src/partials/project-team.html +++ b/emails/src/partials/project-team.html @@ -7,7 +7,7 @@ - +
IMGIMG {{title}} diff --git a/emails/src/partials/topics_and_posts.html b/emails/src/partials/topics_and_posts.html index f7b6751..73e1152 100644 --- a/emails/src/partials/topics_and_posts.html +++ b/emails/src/partials/topics_and_posts.html @@ -7,7 +7,7 @@ - +
IMGIMG {{title}}. diff --git a/emails/src/template.html b/emails/src/template.html index 609cbff..14c2a4a 100644 --- a/emails/src/template.html +++ b/emails/src/template.html @@ -20,7 +20,7 @@ - + diff --git a/package.json b/package.json index 7e802da..3789de8 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "start": "node connect/connectNotificationServer", "startAPI": "node index-api", "startConsumer": "node consumer", - "lint": "eslint *.js src config test connect || true", - "lint:fix": "eslint *.js --fix src config test connect || true", + "lint": "eslint *.js src config test connect", + "lint:fix": "eslint *.js --fix src config test connect", "postinstall": "npm run build", "build": "gulp build", "watch": "gulp watch" @@ -53,8 +53,9 @@ "sequelize": "^4.21.0", "superagent": "^3.8.0", "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v2.6", - "winston": "^2.2.0", - "topcoder-healthcheck-dropin": "^1.0.3" + "topcoder-healthcheck-dropin": "^1.0.3", + "urijs": "^1.19.1", + "winston": "^2.2.0" }, "engines": { "node": "6.x" diff --git a/src/app.js b/src/app.js index 3ee3e4a..1d7d4bc 100644 --- a/src/app.js +++ b/src/app.js @@ -15,7 +15,25 @@ const logger = require('./common/logger'); const errors = require('./common/errors'); const models = require('./models'); const Kafka = require('no-kafka'); -const healthcheck = require('topcoder-healthcheck-dropin') +const healthcheck = require('topcoder-healthcheck-dropin'); + + +// helps in health checking in case of unhandled rejection of promises +const unhandledRejections = []; +process.on('unhandledRejection', (reason, promise) => { + console.log('Unhandled Rejection at:', promise, 'reason:', reason); + // aborts the process to let the HA of the container to restart the task + // process.abort(); + unhandledRejections.push(promise); +}); + +// ideally any unhandled rejection is handled after more than one event loop, it should be removed +// from the unhandledRejections array. We just remove the first element from the array as we only care +// about the count every time an unhandled rejection promise is handled +process.on('rejectionHandled', (promise) => { + console.log('Handled Rejection at:', promise); + unhandledRejections.shift(); +}); /** * Start Kafka consumer for event bus events. @@ -75,15 +93,39 @@ function startKafkaConsumer(handlers, notificationServiceHandlers) { }); }); + var latestSubscriptions = null; + const check = function () { + logger.debug("Checking health"); + if (unhandledRejections && unhandledRejections.length > 0) { + logger.error('Found unhandled promises. Application is potentially in stalled state.'); + return false; + } if (!consumer.client.initialBrokers && !consumer.client.initialBrokers.length) { - return false + logger.error('Found unhealthy Kafka Brokers...'); + return false; + } + let connected = true; + let currentSubscriptions = consumer.subscriptions; + for(var sIdx in currentSubscriptions) { + // current subscription + let sub = currentSubscriptions[sIdx]; + // previous subscription + let prevSub = latestSubscriptions ? latestSubscriptions[sIdx] : null; + // levarage the `paused` field (https://github.com/oleksiyk/kafka/blob/master/lib/base_consumer.js#L66) to + // determine if there was a possibility of an unhandled exception. If we find paused status for the same + // topic in two consecutive health checks, we assume it was stuck because of unhandled error + if (prevSub && prevSub.paused && sub.paused) { + logger.error(`Found subscription for ${sIdx} in paused state for consecutive health checks`); + return false; + } } - let connected = true + // stores the latest subscription status in global variable + latestSubscriptions = consumer.subscriptions; consumer.client.initialBrokers.forEach(conn => { logger.debug(`url ${conn.server()} - connected=${conn.connected}`) connected = conn.connected & connected - }) + }); return connected } @@ -91,8 +133,8 @@ function startKafkaConsumer(handlers, notificationServiceHandlers) { .init() .then(() => { _.each(_.keys(handlers), - (topicName) => consumer.subscribe(topicName, dataHandler)) - healthcheck.init([check]) + (topicName) => consumer.subscribe(topicName, dataHandler)); + healthcheck.init([check]); }) .catch((err) => { logger.error('Kafka Consumer failed'); diff --git a/src/common/tcApiHelper.js b/src/common/tcApiHelper.js index 37ef659..dcd7489 100644 --- a/src/common/tcApiHelper.js +++ b/src/common/tcApiHelper.js @@ -2,6 +2,7 @@ * Contains generic helper methods for TC API */ const _ = require('lodash'); +const URI = require('urijs'); const config = require('config'); const request = require('superagent'); const m2mAuth = require('tc-core-library-js').auth.m2m; @@ -188,8 +189,8 @@ function* notifyUserViaEmail(user, message) { */ function* getChallenge(challengeId) { // this is public API, M2M token is not needed - const url = `${config.TC_API_V4_BASE_URL}/challenges/${challengeId}` - logger.info(`calling public challenge api ${url}`) + const url = `${config.TC_API_V4_BASE_URL}/challenges/${challengeId}`; + logger.info(`calling public challenge api ${url}`); const res = yield request.get(url); if (!_.get(res, 'body.result.success')) { throw new Error(`Failed to get challenge by id ${challengeId}`); @@ -214,9 +215,9 @@ function* notifyUsersOfMessage(users, notification) { for (let i = 0; i < users.length; i += 1) { const user = users[i]; // construct notification, rest fields are set in consumer.js - notifications.push({ userId: user.userId, notification: notification }); + notifications.push({ userId: user.userId, notification }); - /* TODO Sachin disabled this code + /* TODO Sachin disabled this code if (config.ENABLE_EMAILS) { // notify user by email, ignore error in order not to block rest processing try { @@ -226,9 +227,8 @@ function* notifyUsersOfMessage(users, notification) { logger.logFullError(e); } } */ - } - logger.info(`Total ${notifications.length} users would be notified.`) + logger.info(`Total ${notifications.length} users would be notified.`); return notifications; } @@ -238,10 +238,10 @@ function* notifyUsersOfMessage(users, notification) { * @returns {Array} the associated user's detail object */ function* getUsersInfoFromChallenge(challengeId) { - const token = yield getM2MToken() - let usersInfo = [] - const url = `${config.TC_API_V4_BASE_URL}/challenges/${challengeId}/resources` - logger.info(`calling challenge api ${url} `) + const token = yield getM2MToken(); + let usersInfo = []; + const url = `${config.TC_API_V4_BASE_URL}/challenges/${challengeId}/resources`; + logger.info(`calling challenge api ${url} `); const res = yield request .get(url) .set('Authorization', `Bearer ${token}`) @@ -250,71 +250,121 @@ function* getUsersInfoFromChallenge(challengeId) { throw new Error( `Error in call challenge api by id ${challengeId}` + (errorDetails ? ' Server response: ' + errorDetails : '') - ) - }) + ); + }); if (!_.get(res, 'body.result.success')) { throw new Error(`Failed to get challenge by id ${challengeId}`); } usersInfo = _.get(res, 'body.result.content'); - logger.info(`Feteched ${usersInfo.length} records from challenge api`) + logger.info(`Feteched ${usersInfo.length} records from challenge api`); return usersInfo; } -/** - * Filter associated challenge's user based on criteria +/** + * Filter associated challenge's user based on criteria * @param {Array} usersInfo user object array * @param {Array} filterOnRoles on roles * @param {Array} filterOnUsers on user's ids - * - * @returns {Array} of user object + * + * @returns {Array} of user object */ function filterChallengeUsers(usersInfo, filterOnRoles = [], filterOnUsers = []) { - const users = [] // filtered users - const rolesAvailable = [] // available roles in challenge api response + const users = []; // filtered users + const rolesAvailable = []; // available roles in challenge api response _.map(usersInfo, (user) => { - const userId = parseInt(_.get(user, 'properties.External Reference ID')) - const role = _.get(user, 'role') + const userId = parseInt(_.get(user, 'properties.External Reference ID'), 10); + const role = _.get(user, 'role'); - _.indexOf(rolesAvailable, role) == -1 ? rolesAvailable.push(role) : '' + if (_.indexOf(rolesAvailable, role) === -1) { + rolesAvailable.push(role); + } if (filterOnRoles.length > 0 && _.indexOf(filterOnRoles, role) >= 0) { - users.push({ userId: userId }) + users.push({ userId }); } else if (filterOnUsers.length > 0 && _.indexOf(filterOnUsers, userId) >= 0) { - users.push({ userId: userId }) /** Submitter only case */ - } else if (filterOnRoles.length == 0 && filterOnUsers.length == 0) { - users.push({ userId: userId }) + users.push({ userId }); /** Submitter only case */ + } else if (filterOnRoles.length === 0 && filterOnUsers.length === 0) { + users.push({ userId }); } - }) - logger.info(`Total roles available in this challenge are: ${rolesAvailable.join(',')}`) - return users + }); + logger.info(`Total roles available in this challenge are: ${rolesAvailable.join(',')}`); + return users; } -/** - * modify notification template +/** + * modify notification template * @param {Object} ruleSet rule - * @param {Object} data values to be filled - * + * @param {Object} data values to be filled + * * @returns {Object} notification node */ function* modifyNotificationNode(ruleSet, data) { - const notification = _.get(ruleSet, "notification") - const id = data.id || data.challengeId || 0 - const name = _.get(data, "name") + const notification = _.get(ruleSet, 'notification'); + const id = data.id || data.challengeId || 0; + const name = _.get(data, 'name'); - notification.id = id + notification.id = id; if (name) { - notification.name = name + notification.name = name; } else { try { - const challenge = yield getChallenge(id) - notification.name = _.get(challenge, "challengeTitle") + const challenge = yield getChallenge(id); + notification.name = _.get(challenge, 'challengeTitle'); } catch (error) { - notification.name = '' - logger.error(`Error in fetching challenge detail : ${error}`) + notification.name = ''; + logger.error(`Error in fetching challenge detail : ${error}`); } } - return notification + return notification; +} + +/** + * generate header based on v5 specification + * @param {String} url the api url to fetch + * @param {Number} perPage the number served in one page + * @param {Number} currentPage the current page number + * @param {Number} total the total number of rows/entities + * + * @returns {Object} the header response + */ +function generateV5Header({ url, perPage, currentPage, total }) { + const links = []; + const fullUrl = `${config.TC_API_BASE_URL}${url}`; + const generateUrl = (url_, page, rel) => { + const newUrl = new URI(url_); + newUrl.setQuery({ + page, + }); + links.push(`<${newUrl.toString()}>; rel="${rel}"`); + }; + + const totalPages = perPage ? Math.ceil(total / perPage) : 1; + const headers = { + 'X-Page': currentPage || 1, + 'X-Total': total, + 'X-Total-Pages': totalPages || 1, + }; + if (perPage) { + headers['X-Per-Page'] = perPage; + } + + if (currentPage > 1) { + headers['X-Prev-Page'] = currentPage - 1; + generateUrl(fullUrl, currentPage - 1, 'prev'); + generateUrl(fullUrl, 1, 'first'); + } + + if (currentPage < totalPages) { + headers['X-Next-Page'] = currentPage + 1; + + generateUrl(fullUrl, currentPage + 1, 'next'); + generateUrl(fullUrl, totalPages, 'last'); + } + + headers.Link = links.join(','); + + return headers; } module.exports = { @@ -328,4 +378,5 @@ module.exports = { getUsersInfoFromChallenge, filterChallengeUsers, modifyNotificationNode, + generateV5Header, }; diff --git a/src/controllers/NotificationController.js b/src/controllers/NotificationController.js index 4b7988b..5f91450 100644 --- a/src/controllers/NotificationController.js +++ b/src/controllers/NotificationController.js @@ -4,6 +4,7 @@ 'use strict'; const NotificationService = require('../services/NotificationService'); +const tcApiHelper = require('../common/tcApiHelper'); /** * List notifications. @@ -11,7 +12,39 @@ const NotificationService = require('../services/NotificationService'); * @param res the response */ function* listNotifications(req, res) { - res.json(yield NotificationService.listNotifications(req.query, req.user.userId)); + const { + items, + perPage, + currentPage, + total, + } = yield NotificationService.listNotifications(req.query, req.user.userId); + + const headers = tcApiHelper.generateV5Header({ + url: req.originalUrl, + perPage, + currentPage, + total, + }); + + res.set(headers); + + /** + * disabling v5 API feature temporarily for connect-app (backward compatibility) + */ + + //res.json(items); + + // TODO disable this and revert to original + res.json({ + items, + offset: currentPage, + limit: perPage, + totalCount: total + }) +} + +function* updateNotification(req, res) { + res.json(yield NotificationService.updateNotification(req.user.userId, req.params.id, req.body)); } /** @@ -71,4 +104,5 @@ module.exports = { markAsSeen, getSettings, updateSettings, + updateNotification, }; diff --git a/src/processors/challenge/AutoPilotHandler.js b/src/processors/challenge/AutoPilotHandler.js index 47d6e7e..b3592ec 100644 --- a/src/processors/challenge/AutoPilotHandler.js +++ b/src/processors/challenge/AutoPilotHandler.js @@ -8,7 +8,7 @@ const service = require('../../services/AutoPilotService'); * Handle Kafka JSON message of autopilot. * * @param {Object} message the Kafka JSON message - * @param {Object} ruleSets + * @param {Object} ruleSets * * @return {Promise} promise resolved to notifications */ diff --git a/src/processors/challenge/ChallengeHandler.js b/src/processors/challenge/ChallengeHandler.js index 39e60ec..f2056a2 100644 --- a/src/processors/challenge/ChallengeHandler.js +++ b/src/processors/challenge/ChallengeHandler.js @@ -8,7 +8,7 @@ const service = require('../../services/ChallengeService'); * Handle Kafka JSON message of challenge created. * * @param {Object} message the Kafka JSON message - * @param {Object} ruleSets + * @param {Object} ruleSets * * @return {Promise} promise resolved to notifications */ diff --git a/src/processors/challenge/SubmissionHandler.js b/src/processors/challenge/SubmissionHandler.js index 9850563..78b048c 100644 --- a/src/processors/challenge/SubmissionHandler.js +++ b/src/processors/challenge/SubmissionHandler.js @@ -8,7 +8,7 @@ const service = require('../../services/SubmissionService'); * Handle Kafka JSON message of autopilot. * * @param {Object} message the Kafka JSON message - * @param {Object} ruleSets + * @param {Object} ruleSets * * @return {Promise} promise resolved to notifications */ diff --git a/src/processors/index.js b/src/processors/index.js index af3844c..70d8c6a 100644 --- a/src/processors/index.js +++ b/src/processors/index.js @@ -6,8 +6,8 @@ const ChallengeCreatedHandler = require('./challenge/ChallengeCreatedHandler'); const ChallengePhaseWarningHandler = require('./challenge/ChallengePhaseWarningHandler'); const ChallengeHandler = require('./challenge/ChallengeHandler'); -const AutoPilotHandler = require('./challenge/AutoPilotHandler') -const SubmissionHandler = require('./challenge/SubmissionHandler') +const AutoPilotHandler = require('./challenge/AutoPilotHandler'); +const SubmissionHandler = require('./challenge/SubmissionHandler'); // Exports module.exports = { diff --git a/src/routes.js b/src/routes.js index cf1df2e..c3e2b97 100644 --- a/src/routes.js +++ b/src/routes.js @@ -7,6 +7,16 @@ module.exports = { method: 'listNotifications', }, }, + '/:id': { + patch: { + controller: 'NotificationController', + method: 'updateNotification', + }, + post: { + controller: 'NotificationController', + method: 'updateNotification', + }, + }, '/:id/read': { put: { controller: 'NotificationController', diff --git a/src/services/AutoPilotService.js b/src/services/AutoPilotService.js index 6561daa..cd0ee9a 100644 --- a/src/services/AutoPilotService.js +++ b/src/services/AutoPilotService.js @@ -5,32 +5,31 @@ 'use strict'; const joi = require('joi'); -const _ = require('lodash') +const _ = require('lodash'); const logger = require('../common/logger'); const tcApiHelper = require('../common/tcApiHelper'); /** * Handle autopilot message * @param {Object} message the Kafka message - * @param {Object} ruleSets + * @param {Object} ruleSets * @returns {Array} the notifications */ function* handle(message, ruleSets) { + if ((message.payload.phaseTypeName === _.get(ruleSets, 'phaseTypeName')) + && (message.payload.state === _.get(ruleSets, 'state'))) { + const challengeId = message.payload.projectId; + const filerOnRoles = _.get(ruleSets, 'roles'); - if ((message.payload.phaseTypeName === _.get(ruleSets, "phaseTypeName")) - && (message.payload.state === _.get(ruleSets, "state"))) { - const challengeId = message.payload.projectId - const filerOnRoles = _.get(ruleSets, "roles") + const notification = yield tcApiHelper.modifyNotificationNode(ruleSets, { id: challengeId }); + const usersInfo = yield tcApiHelper.getUsersInfoFromChallenge(challengeId); + const users = tcApiHelper.filterChallengeUsers(usersInfo, filerOnRoles); - const notification = yield tcApiHelper.modifyNotificationNode(ruleSets, { id: challengeId}) - const usersInfo = yield tcApiHelper.getUsersInfoFromChallenge(challengeId) - const users = tcApiHelper.filterChallengeUsers(usersInfo, filerOnRoles) - - logger.info(`Successfully filetered ${users.length} users on rulesets ${JSON.stringify(filerOnRoles)} `) + logger.info(`Successfully filetered ${users.length} users on rulesets ${JSON.stringify(filerOnRoles)} `); // notify users of message return yield tcApiHelper.notifyUsersOfMessage(users, notification); } - return {} + return {}; } handle.schema = { @@ -42,15 +41,15 @@ handle.schema = { payload: joi.object().keys({ phaseTypeName: joi.string().required(), state: joi.string().required(), - projectId: joi.number().integer().min(1) + projectId: joi.number().integer().min(1), }).unknown(true).required(), }).required(), - ruleSets: joi.object() -} + ruleSets: joi.object(), +}; // Exports module.exports = { handle, -} +}; logger.buildService(module.exports); diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 38d6e37..8c3dbb3 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -5,31 +5,30 @@ 'use strict'; const joi = require('joi'); -const _ = require('lodash') +const _ = require('lodash'); const logger = require('../common/logger'); const tcApiHelper = require('../common/tcApiHelper'); /** * Handle challenge message * @param {Object} message the Kafka message - * @param {Object} ruleSets + * @param {Object} ruleSets * @returns {Array} the notifications */ function* handle(message, ruleSets) { - - if (message.payload.type === _.get(ruleSets, "type")) { - const challengeId = message.payload.data.id - const filterOnRoles = _.get(ruleSets, "roles") - const challengeTitle = _.get(message.payload, "data.name") - - const notification = yield tcApiHelper.modifyNotificationNode(ruleSets, { id: challengeId, name: challengeTitle }) - const usersInfo = yield tcApiHelper.getUsersInfoFromChallenge(challengeId) - const users = tcApiHelper.filterChallengeUsers(usersInfo, filterOnRoles) - logger.info(`Successfully filetered ${users.length} users on rulesets ${JSON.stringify(filterOnRoles)} `) + if (message.payload.type === _.get(ruleSets, 'type')) { + const challengeId = message.payload.data.id; + const filterOnRoles = _.get(ruleSets, 'roles'); + const challengeTitle = _.get(message.payload, 'data.name'); + + const notification = yield tcApiHelper.modifyNotificationNode(ruleSets, { id: challengeId, name: challengeTitle }); + const usersInfo = yield tcApiHelper.getUsersInfoFromChallenge(challengeId); + const users = tcApiHelper.filterChallengeUsers(usersInfo, filterOnRoles); + logger.info(`Successfully filetered ${users.length} users on rulesets ${JSON.stringify(filterOnRoles)} `); // notify users of message return yield tcApiHelper.notifyUsersOfMessage(users, notification); } - return {} + return {}; } handle.schema = { @@ -40,15 +39,15 @@ handle.schema = { 'mime-type': joi.string().required(), payload: joi.object().keys({ type: joi.string().required(), - userId: joi.number().integer().min(1) + userId: joi.number().integer().min(1), }).unknown(true).required(), }).required(), - ruleSets: joi.object() -} + ruleSets: joi.object(), +}; // Exports module.exports = { handle, -} +}; logger.buildService(module.exports); diff --git a/src/services/NotificationService.js b/src/services/NotificationService.js index 027116d..9b5ea52 100644 --- a/src/services/NotificationService.js +++ b/src/services/NotificationService.js @@ -1,313 +1,360 @@ -/** - * Service for notification functinoalities. - */ - -'use strict'; - -const _ = require('lodash'); -const Joi = require('joi'); -const errors = require('../common/errors'); +/** + * Service for notification functinoalities. + */ + +'use strict'; + +const _ = require('lodash'); +const Joi = require('joi'); +const errors = require('../common/errors'); const logger = require('../common/logger'); -const models = require('../models'); - -const DEFAULT_LIMIT = 10; - -/** - * Get notification settings. - * @param {Number} userId the user id - * @returns {Object} the notification settings - */ -function* getSettings(userId) { - const notificationSettings = yield models.NotificationSetting.findAll({ where: { userId } }); - const serviceSettings = yield models.ServiceSettings.findAll({ where: { userId } }); - - // format settings per notification type - const notifications = {}; - _.each(notificationSettings, (setting) => { - if (!notifications[setting.topic]) { - notifications[setting.topic] = {}; - } - if (!notifications[setting.topic][setting.serviceId]) { - notifications[setting.topic][setting.serviceId] = {}; - } - notifications[setting.topic][setting.serviceId][setting.name] = setting.value; - }); - - // format settings per service - const services = {}; - _.each(serviceSettings, (setting) => { - if (!services[setting.serviceId]) { - services[setting.serviceId] = {}; - } - services[setting.serviceId][setting.name] = setting.value; - }); - return { - notifications, - services, - }; -} - -getSettings.schema = { - userId: Joi.number().required(), -}; - -/** - * Save notification setting entry. If the entry is not found, it will be created; otherwise it will be updated. - * @param {Object} entry the notification setting entry - * @param {Number} userId the user id - */ -function* saveNotificationSetting(entry, userId) { - const setting = yield models.NotificationSetting.findOne({ where: { - userId, topic: entry.topic, serviceId: entry.serviceId, name: entry.name } }); - if (setting) { - setting.value = entry.value; - yield setting.save(); - } else { - yield models.NotificationSetting.create({ - userId, - topic: entry.topic, - serviceId: entry.serviceId, - name: entry.name, - value: entry.value, - }); - } -} - -/** - * Save service setting entry. If the entry is not found, it will be created; otherwise it will be updated. - * @param {Object} entry the service setting entry - * @param {Number} userId the user id - */ -function* saveServiceSetting(entry, userId) { - const setting = yield models.ServiceSettings.findOne({ where: { - userId, serviceId: entry.serviceId, name: entry.name } }); - if (setting) { - setting.value = entry.value; - yield setting.save(); - } else { - yield models.ServiceSettings.create({ - userId, - serviceId: entry.serviceId, - name: entry.name, - value: entry.value, - }); - } -} - -/** - * Update notification settings. Un-specified settings are not changed. - * @param {Array} data the notification settings data - * @param {Number} userId the user id - */ -function* updateSettings(data, userId) { - // convert notification settings object to the list of entries - const notifications = []; - _.forOwn(data.notifications, (notification, topic) => { - _.forOwn(notification, (serviceSettings, serviceId) => { - _.forOwn(serviceSettings, (value, name) => { - notifications.push({ - topic, - serviceId, - name, - value, - }); - }); - }); - }); - - // validation - // there should be no duplicate (topic + serviceId + name) - const triples = {}; - notifications.forEach((entry) => { - const key = `${entry.topic} | ${entry.serviceId} | ${entry.name}`; - if (triples[key]) { - throw new errors.BadRequestError(`There are duplicate data for topic: ${ - entry.topic}, serviceId: ${entry.serviceId}, name: ${entry.name}`); - } - triples[key] = entry; - }); - - // save each entry in parallel - yield _.map(notifications, (entry) => saveNotificationSetting(entry, userId)); - - // convert services settings object the the list of entries - const services = []; - _.forOwn(data.services, (service, serviceId) => { - _.forOwn(service, (value, name) => { - services.push({ - serviceId, - name, - value, - }); - }); - }); - - // validation - // there should be no duplicate (serviceId + name) - const paris = {}; - services.forEach((entry) => { - const key = `${entry.serviceId} | ${entry.name}`; - if (paris[key]) { - throw new errors.BadRequestError('There are duplicate data for' - + ` serviceId: ${entry.serviceId}, name: ${entry.name}`); - } - paris[key] = entry; - }); - - yield _.map(services, (entry) => saveServiceSetting(entry, userId)); -} - -updateSettings.schema = { - data: Joi.object().keys({ - notifications: Joi.object(), - services: Joi.object(), - }).required(), - userId: Joi.number().required(), -}; - -/** - * List notifications. - * - * This method returns only notifications for 'web' - * Also this method filters notifications by the user and filters out notifications, - * which user disabled in his settings. - * - * @param {Object} query the query parameters - * @param {Number} userId the user id - * @returns {Object} the search result - */ -function* listNotifications(query, userId) { - const settings = yield getSettings(userId); - const notificationSettings = settings.notifications; - - const filter = { where: { - userId, - }, offset: query.offset, limit: query.limit, order: [['createdAt', 'DESC']] }; - if (_.keys(notificationSettings).length > 0) { - // only filter out notifications types which were explicitly set to 'no' - so we return notification by default - const notifications = _.keys(notificationSettings).filter((notificationType) => - !notificationSettings[notificationType] && - !notificationSettings[notificationType].web && - notificationSettings[notificationType].web.enabled === 'no' - ); - filter.where.type = { $notIn: notifications }; - } - if (query.type) { - filter.where.type = query.type; - } - if (query.read) { - filter.where.read = (query.read === 'true'); - } - const docs = yield models.Notification.findAndCountAll(filter); - const items = _.map(docs.rows, r => { - const item = r.toJSON(); - // id and userId are BIGINT in database, sequelize maps them to string values, - // convert them back to Number values - item.id = Number(item.id); - item.userId = Number(item.userId); - return item; - }); - return { - items, - offset: query.offset, - limit: query.limit, - totalCount: docs.count, - }; -} - -listNotifications.schema = { - query: Joi.object().keys({ - offset: Joi.number().integer().min(0).default(0), - limit: Joi.number().integer().min(1).default(DEFAULT_LIMIT), - type: Joi.string(), - // when it is true, return only read notifications - // when it is false, return only un-read notifications - // when it is no provided, no read flag filtering - read: Joi.string().valid('true', 'false'), - }).required(), - userId: Joi.number().required(), -}; - -/** - * Mark notification(s) as read. - * @param {Number} id the notification id or '-' separated ids - * @param {Number} userId the user id - */ -function* markAsRead(id, userId) { - const ids = _.map(id.split('-'), (str) => { - const idInt = Number(str); - if (!_.isInteger(idInt)) { - throw new errors.BadRequestError(`Notification id should be integer: ${str}`); - } - return idInt; - }); - const entities = yield models.Notification.findAll({ where: { id: { $in: ids }, read: false } }); - if (!entities || entities.length === 0) { - throw new errors.NotFoundError(`Cannot find un-read Notification where id = ${id}`); - } - _.each(entities, (entity) => { - if (Number(entity.userId) !== userId) { - throw new errors.ForbiddenError(`Cannot access Notification where id = ${entity.id}`); - } - }); - yield models.Notification.update({ read: true }, { where: { id: { $in: ids }, read: false } }); -} - -markAsRead.schema = { - id: Joi.string().required(), - userId: Joi.number().required(), -}; - -/** - * Mark all notifications as read. - * @param {Number} userId the user id - */ -function* markAllRead(userId) { - yield models.Notification.update({ read: true }, { where: { userId, read: false } }); -} - -markAllRead.schema = { - userId: Joi.number().required(), -}; - -/** - * Mark notification(s) as seen. - * @param {Number} id the notification id or '-' separated ids - * @param {Number} userId the user id - */ -function* markAsSeen(id, userId) { - const ids = _.map(id.split('-'), (str) => { - const idInt = Number(str); - if (!_.isInteger(idInt)) { - throw new errors.BadRequestError(`Notification id should be integer: ${str}`); - } - return idInt; - }); - const entities = yield models.Notification.findAll({ where: { id: { $in: ids }, seen: { $not: true } } }); - if (!entities || entities.length === 0) { - throw new errors.NotFoundError(`Cannot find un-seen Notification where id = ${id}`); - } - _.each(entities, (entity) => { - if (Number(entity.userId) !== userId) { - throw new errors.ForbiddenError(`Cannot access Notification where id = ${entity.id}`); - } - }); - yield models.Notification.update({ seen: true }, { where: { id: { $in: ids }, seen: { $not: true } } }); -} - -markAsSeen.schema = { - id: Joi.string().required(), - userId: Joi.number().required(), -}; - -// Exports -module.exports = { - listNotifications, - markAsRead, - markAllRead, - markAsSeen, - getSettings, - updateSettings, -}; +const models = require('../models'); + +const DEFAULT_LIMIT = 10; + +/** + * Get notification settings. + * @param {Number} userId the user id + * @returns {Object} the notification settings + */ +function* getSettings(userId) { + const notificationSettings = yield models.NotificationSetting.findAll({ where: { userId } }); + const serviceSettings = yield models.ServiceSettings.findAll({ where: { userId } }); + + // format settings per notification type + const notifications = {}; + _.each(notificationSettings, (setting) => { + if (!notifications[setting.topic]) { + notifications[setting.topic] = {}; + } + if (!notifications[setting.topic][setting.serviceId]) { + notifications[setting.topic][setting.serviceId] = {}; + } + notifications[setting.topic][setting.serviceId][setting.name] = setting.value; + }); + + // format settings per service + const services = {}; + _.each(serviceSettings, (setting) => { + if (!services[setting.serviceId]) { + services[setting.serviceId] = {}; + } + services[setting.serviceId][setting.name] = setting.value; + }); + return { + notifications, + services, + }; +} + +getSettings.schema = { + userId: Joi.number().required(), +}; + +/** + * Save notification setting entry. If the entry is not found, it will be created; otherwise it will be updated. + * @param {Object} entry the notification setting entry + * @param {Number} userId the user id + */ +function* saveNotificationSetting(entry, userId) { + const setting = yield models.NotificationSetting.findOne({ where: { + userId, topic: entry.topic, serviceId: entry.serviceId, name: entry.name } }); + if (setting) { + setting.value = entry.value; + yield setting.save(); + } else { + yield models.NotificationSetting.create({ + userId, + topic: entry.topic, + serviceId: entry.serviceId, + name: entry.name, + value: entry.value, + }); + } +} + +/** + * Save service setting entry. If the entry is not found, it will be created; otherwise it will be updated. + * @param {Object} entry the service setting entry + * @param {Number} userId the user id + */ +function* saveServiceSetting(entry, userId) { + const setting = yield models.ServiceSettings.findOne({ where: { + userId, serviceId: entry.serviceId, name: entry.name } }); + if (setting) { + setting.value = entry.value; + yield setting.save(); + } else { + yield models.ServiceSettings.create({ + userId, + serviceId: entry.serviceId, + name: entry.name, + value: entry.value, + }); + } +} + +/** + * Update notification settings. Un-specified settings are not changed. + * @param {Array} data the notification settings data + * @param {Number} userId the user id + */ +function* updateSettings(data, userId) { + // convert notification settings object to the list of entries + const notifications = []; + _.forOwn(data.notifications, (notification, topic) => { + _.forOwn(notification, (serviceSettings, serviceId) => { + _.forOwn(serviceSettings, (value, name) => { + notifications.push({ + topic, + serviceId, + name, + value, + }); + }); + }); + }); + + // validation + // there should be no duplicate (topic + serviceId + name) + const triples = {}; + notifications.forEach((entry) => { + const key = `${entry.topic} | ${entry.serviceId} | ${entry.name}`; + if (triples[key]) { + throw new errors.BadRequestError(`There are duplicate data for topic: ${ + entry.topic}, serviceId: ${entry.serviceId}, name: ${entry.name}`); + } + triples[key] = entry; + }); + + // save each entry in parallel + yield _.map(notifications, (entry) => saveNotificationSetting(entry, userId)); + + // convert services settings object the the list of entries + const services = []; + _.forOwn(data.services, (service, serviceId) => { + _.forOwn(service, (value, name) => { + services.push({ + serviceId, + name, + value, + }); + }); + }); + + // validation + // there should be no duplicate (serviceId + name) + const paris = {}; + services.forEach((entry) => { + const key = `${entry.serviceId} | ${entry.name}`; + if (paris[key]) { + throw new errors.BadRequestError('There are duplicate data for' + + ` serviceId: ${entry.serviceId}, name: ${entry.name}`); + } + paris[key] = entry; + }); + + yield _.map(services, (entry) => saveServiceSetting(entry, userId)); +} + +updateSettings.schema = { + data: Joi.object().keys({ + notifications: Joi.object(), + services: Joi.object(), + }).required(), + userId: Joi.number().required(), +}; + +/** + * List notifications. + * + * This method returns only notifications for 'web' + * Also this method filters notifications by the user and filters out notifications, + * which user disabled in his settings. + * + * @param {Object} query the query parameters + * @param {Number} userId the user id + * @returns {Object} the search result + */ +function* listNotifications(query, userId) { + const settings = yield getSettings(userId); + const notificationSettings = settings.notifications; + const limit = query.limit || query.per_page; + const offset = (query.page - 1) * limit; + const filter = { where: { + userId, + }, offset, limit, order: [['createdAt', 'DESC']] }; + if (query.platform) { + filter.where.type = { $like: `notifications\.${query.platform}\.%` }; + } + if (_.keys(notificationSettings).length > 0) { + // only filter out notifications types which were explicitly set to 'no' - so we return notification by default + const notifications = _.keys(notificationSettings).filter((notificationType) => + !notificationSettings[notificationType] && + !notificationSettings[notificationType].web && + notificationSettings[notificationType].web.enabled === 'no' + ); + filter.where.type = Object.assign(filter.where.type || {}, { $notIn: notifications }); + } + if (query.type) { + filter.where.type = Object.assign(filter.where.type || {}, { $eq: query.type }); + } + if (query.read) { + filter.where.read = (query.read === 'true'); + } + const docs = yield models.Notification.findAndCountAll(filter); + const items = _.map(docs.rows, r => { + const item = r.toJSON(); + // id and userId are BIGINT in database, sequelize maps them to string values, + // convert them back to Number values + item.id = Number(item.id); + item.userId = Number(item.userId); + return item; + }); + return { + items, + perPage: limit, + currentPage: query.page, + total: docs.count, + }; +} + +listNotifications.schema = { + query: Joi.object().keys({ + page: Joi.number().integer().min(1).default(1), + per_page: Joi.number().integer().min(1).default(DEFAULT_LIMIT), + // supporting limit field temporarily + limit: Joi.number().integer().min(1), + type: Joi.string(), + platform: Joi.string(), + // when it is true, return only read notifications + // when it is false, return only un-read notifications + // when it is no provided, no read flag filtering + read: Joi.string().valid('true', 'false'), + }).required(), + userId: Joi.number().required(), +}; + +/** + * Update notification. + * + * Update notification based on notification id + * + * @param {Number} userId the user id + * @param {Number} notificationId the notification id + * @param {Object} payload the update notification payload + * @returns {Object} the updated notification + */ +function* updateNotification(userId, notificationId, payload) { + if (payload.read === false) { + throw new errors.ValidationError('Cannot set notification to be unread'); + } + if (payload.seen === false) { + throw new errors.ValidationError('Cannot set notification to be unseen'); + } + + const entity = yield models.Notification.findOne({ where: { id: Number(notificationId) } }); + if (!entity) { + throw new errors.NotFoundError(`Cannot find Notification where id = ${notificationId}`); + } + if (Number(entity.userId) !== userId) { + throw new errors.ForbiddenError(`Cannot access Notification where id = ${entity.id}`); + } + yield models.Notification.update(payload, { where: { id: Number(notificationId), userId: Number(userId) } }); + + return Object.assign(entity, payload); +} + +updateNotification.schema = { + userId: Joi.number().required(), + notificationId: Joi.number().required(), + payload: Joi.object().keys({ + read: Joi.boolean(), + seen: Joi.boolean(), + }), +}; + +/** + * Mark notification(s) as read. + * @param {Number} id the notification id or '-' separated ids + * @param {Number} userId the user id + */ +function* markAsRead(id, userId) { + const ids = _.map(id.split('-'), (str) => { + const idInt = Number(str); + if (!_.isInteger(idInt)) { + throw new errors.BadRequestError(`Notification id should be integer: ${str}`); + } + return idInt; + }); + const entities = yield models.Notification.findAll({ where: { id: { $in: ids }, read: false } }); + if (!entities || entities.length === 0) { + throw new errors.NotFoundError(`Cannot find un-read Notification where id = ${id}`); + } + _.each(entities, (entity) => { + if (Number(entity.userId) !== userId) { + throw new errors.ForbiddenError(`Cannot access Notification where id = ${entity.id}`); + } + }); + yield models.Notification.update({ read: true }, { where: { id: { $in: ids }, read: false } }); +} + +markAsRead.schema = { + id: Joi.string().required(), + userId: Joi.number().required(), +}; + +/** + * Mark all notifications as read. + * @param {Number} userId the user id + */ +function* markAllRead(userId) { + yield models.Notification.update({ read: true }, { where: { userId, read: false } }); +} + +markAllRead.schema = { + userId: Joi.number().required(), +}; + +/** + * Mark notification(s) as seen. + * @param {Number} id the notification id or '-' separated ids + * @param {Number} userId the user id + */ +function* markAsSeen(id, userId) { + const ids = _.map(id.split('-'), (str) => { + const idInt = Number(str); + if (!_.isInteger(idInt)) { + throw new errors.BadRequestError(`Notification id should be integer: ${str}`); + } + return idInt; + }); + const entities = yield models.Notification.findAll({ where: { id: { $in: ids }, seen: { $not: true } } }); + if (!entities || entities.length === 0) { + throw new errors.NotFoundError(`Cannot find un-seen Notification where id = ${id}`); + } + _.each(entities, (entity) => { + if (Number(entity.userId) !== userId) { + throw new errors.ForbiddenError(`Cannot access Notification where id = ${entity.id}`); + } + }); + yield models.Notification.update({ seen: true }, { where: { id: { $in: ids }, seen: { $not: true } } }); +} + +markAsSeen.schema = { + id: Joi.string().required(), + userId: Joi.number().required(), +}; + +// Exports +module.exports = { + listNotifications, + markAsRead, + markAllRead, + markAsSeen, + getSettings, + updateSettings, + updateNotification, +}; logger.buildService(module.exports); diff --git a/src/services/SubmissionService.js b/src/services/SubmissionService.js index b3737dd..14f5e71 100644 --- a/src/services/SubmissionService.js +++ b/src/services/SubmissionService.js @@ -5,36 +5,35 @@ 'use strict'; const joi = require('joi'); -const _ = require('lodash') +const _ = require('lodash'); const logger = require('../common/logger'); const tcApiHelper = require('../common/tcApiHelper'); /** * Handle submission message * @param {Object} message the Kafka message - * @param {Object} ruleSets + * @param {Object} ruleSets * @returns {Array} the notifications */ function* handle(message, ruleSets) { + if (message.payload.resource === _.get(ruleSets, 'resource')) { + const challengeId = message.payload.challengeId; + const filterOnRoles = _.get(ruleSets, 'roles'); - if (message.payload.resource === _.get(ruleSets, "resource")) { - const challengeId = message.payload.challengeId - const filterOnRoles = _.get(ruleSets, "roles") - - const filterOnUsers = [] + const filterOnUsers = []; if (_.get(ruleSets, 'selfOnly')) { - const memberId = _.get(message.payload, "memberId") - filterOnUsers.push(memberId) + const memberId = _.get(message.payload, 'memberId'); + filterOnUsers.push(memberId); } - const usersInfo = yield tcApiHelper.getUsersInfoFromChallenge(challengeId) - const users = tcApiHelper.filterChallengeUsers(usersInfo, filterOnRoles, filterOnUsers) - const notification = yield tcApiHelper.modifyNotificationNode(ruleSets, { id: challengeId}) - logger.info(`Successfully filetered ${users.length} users on rulesets ${JSON.stringify(filterOnRoles)} `) + const usersInfo = yield tcApiHelper.getUsersInfoFromChallenge(challengeId); + const users = tcApiHelper.filterChallengeUsers(usersInfo, filterOnRoles, filterOnUsers); + const notification = yield tcApiHelper.modifyNotificationNode(ruleSets, { id: challengeId }); + logger.info(`Successfully filetered ${users.length} users on rulesets ${JSON.stringify(filterOnRoles)} `); // notify users of message return yield tcApiHelper.notifyUsersOfMessage(users, notification); } - return {} + return {}; } handle.schema = { @@ -44,15 +43,15 @@ handle.schema = { timestamp: joi.date().required(), 'mime-type': joi.string().required(), payload: joi.object().keys({ - resource: joi.string().required() + resource: joi.string().required(), }).unknown(true).required(), }).required(), - ruleSets: joi.object() -} + ruleSets: joi.object(), +}; // Exports module.exports = { handle, -} +}; logger.buildService(module.exports);
IMGIMG YOUR TOPCODER PROJECT UPDATES