From 430d7536fab5883b69aca151c611595675db4f71 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Sun, 12 May 2019 02:02:14 +0530 Subject: [PATCH 01/27] updated image link to point to tc-connect static resorces in email templates --- emails/src/partials/invites.html | 2 +- emails/src/partials/project-files.html | 2 +- emails/src/partials/project-links.html | 2 +- emails/src/partials/project-plan.html | 2 +- emails/src/partials/project-specification.html | 2 +- emails/src/partials/project-status.html | 2 +- emails/src/partials/project-team.html | 2 +- emails/src/partials/topics_and_posts.html | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) 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}}. From fe5d3b06a61618b1f7963da8cfacab7c4990cde8 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Sun, 12 May 2019 11:21:09 +0530 Subject: [PATCH 02/27] update template.html --- emails/src/template.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ - + From cd5b5356896204b8b7faa8b4ff59521b7a00b46d Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 13 May 2019 10:03:18 +0800 Subject: [PATCH 03/27] part of the winning submission from challenge 30090377 - Topcoder Notifications Service - Skip unnecessary notifications this part contains changes to implement new functionality --- connect/connectNotificationServer.js | 85 +++++++++++++++++++++++++++- connect/events-config.js | 25 ++++++++ connect/helpers.js | 16 ++++++ connect/service.js | 36 ++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index d581e09..0b42233 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; @@ -246,6 +247,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 * @@ -272,12 +350,17 @@ const excludeNotifications = (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(eventConfig, message.postContent), + getNotificationsForMentionedUser(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) => { 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/service.js b/connect/service.js index 10a416c..d4ade02 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, }; From 44a54b56d832968b4d29c16f7c1d26ad061eff11 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 13 May 2019 10:08:05 +0800 Subject: [PATCH 04/27] part of the winning submission from challenge 30090377 - Topcoder Notifications Service - Skip unnecessary notifications this part contains fixes for lint errors in the existent code --- config/default.js | 33 +++---- connect/notificationServices/email.js | 72 ++++++++------- consumer.js | 34 ++++---- src/app.js | 20 ++--- src/common/tcApiHelper.js | 87 ++++++++++--------- src/processors/challenge/AutoPilotHandler.js | 2 +- src/processors/challenge/ChallengeHandler.js | 2 +- src/processors/challenge/SubmissionHandler.js | 2 +- src/processors/index.js | 4 +- src/services/AutoPilotService.js | 31 ++++--- src/services/ChallengeService.js | 33 ++++--- src/services/SubmissionService.js | 35 ++++---- 12 files changed, 180 insertions(+), 175 deletions(-) diff --git a/config/default.js b/config/default.js index 750802c..d2f85b6 100644 --- a/config/default.js +++ b/config/default.js @@ -45,22 +45,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 +69,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/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/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/src/app.js b/src/app.js index 3ee3e4a..12826a1 100644 --- a/src/app.js +++ b/src/app.js @@ -15,7 +15,7 @@ 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'); /** * Start Kafka consumer for event bus events. @@ -77,22 +77,22 @@ function startKafkaConsumer(handlers, notificationServiceHandlers) { 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; + }; consumer .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 d0bc56f..d83e25f 100644 --- a/src/common/tcApiHelper.js +++ b/src/common/tcApiHelper.js @@ -188,8 +188,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 +214,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 +226,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 +237,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 +249,73 @@ 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; } module.exports = { 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/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/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); From 0cb0027bb51c9d442099214bb794af03973da08d Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 13 May 2019 10:08:54 +0800 Subject: [PATCH 05/27] enable lint validation during deploying --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7e802da..bff9422 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" From febce840b0099982c08420382d9730d7f14d68e7 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 15 May 2019 15:55:21 +0530 Subject: [PATCH 06/27] =?UTF-8?q?Github=20issue#115,=20Missing=20notificat?= =?UTF-8?q?ions=20=E2=80=94=20Potential=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- connect/connectNotificationServer.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index d581e09..dbb9d93 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -56,12 +56,13 @@ const getTopCoderMembersNotifications = (eventConfig) => { /** * Get notifications for mentioned users * + * @param {Object} logger object used to log in parent thread * @param {Object} eventConfig event configuration * @param {Object} message content * * @return {Promise} resolves to a list of notifications */ -const getNotificationsForMentionedUser = (eventConfig, content) => { +const getNotificationsForMentionedUser = (logger, eventConfig, content) => { if (!eventConfig.toMentionedUsers || !content) { return Promise.resolve([]); } @@ -93,6 +94,11 @@ const getNotificationsForMentionedUser = (eventConfig, content) => { notification.userId = mentionedUser ? mentionedUser.userId.toString() : notification.userHandle; }); resolve(notifications); + }).catch((error) => { + if (logger) { + logger.error(error); + } + reject(new Error('Unable to fetch details for mentioned user in the message.')); }); } else { resolve([]); @@ -249,6 +255,7 @@ const getNotificationsForTopicStarter = (eventConfig, topicId) => { /** * Exclude notifications using exclude rules of the event config * + * @param {Object} logger object used to log in parent thread * @param {Array} notifications notifications list * @param {Object} eventConfig event configuration * @param {Object} message message @@ -256,7 +263,7 @@ const getNotificationsForTopicStarter = (eventConfig, topicId) => { * * @returns {Promise} resolves to the list of filtered notifications */ -const excludeNotifications = (notifications, eventConfig, message, data) => { +const excludeNotifications = (logger, notifications, eventConfig, message, data) => { // if there are no rules to exclude notifications, just return all of them untouched if (!eventConfig.exclude) { return Promise.resolve(notifications); @@ -275,7 +282,7 @@ const excludeNotifications = (notifications, eventConfig, message, data) => { return Promise.all([ getNotificationsForTopicStarter(excludeEventConfig, message.topicId), getNotificationsForUserId(excludeEventConfig, message.userId), - getNotificationsForMentionedUser(eventConfig, message.postContent), + getNotificationsForMentionedUser(logger, excludeEventConfig, message.postContent), getProjectMembersNotifications(excludeEventConfig, project), getTopCoderMembersNotifications(excludeEventConfig), ]).then((notificationsPerSource) => ( @@ -335,7 +342,7 @@ const handler = (topic, message, logger, callback) => { getNotificationsForTopicStarter(eventConfig, message.topicId), getNotificationsForUserId(eventConfig, message.userId), getNotificationsForOriginator(eventConfig, message.originator), - getNotificationsForMentionedUser(eventConfig, message.postContent), + getNotificationsForMentionedUser(logger, eventConfig, message.postContent), getProjectMembersNotifications(eventConfig, project), getTopCoderMembersNotifications(eventConfig), ]).then((notificationsPerSource) => { @@ -344,7 +351,7 @@ const handler = (topic, message, logger, callback) => { logger.debug('all notifications: ', notificationsPerSource); return _.uniqBy(_.flatten(notificationsPerSource), 'userId'); }).then((notifications) => ( - excludeNotifications(notifications, eventConfig, message, { + excludeNotifications(logger, notifications, eventConfig, message, { project, }) )).then((notifications) => { From 89bf9198ec7a91a4782d36daeb7982f9d0c01347 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 15 May 2019 17:20:08 +0530 Subject: [PATCH 07/27] Error logging --- connect/connectNotificationServer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index dbb9d93..f24e4b1 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -95,6 +95,7 @@ const getNotificationsForMentionedUser = (logger, eventConfig, content) => { }); resolve(notifications); }).catch((error) => { + console.log(error, 'error'); if (logger) { logger.error(error); } From 6583bbce072b2d2d6e5ce5f2caf702f88721f49d Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 15 May 2019 17:36:32 +0530 Subject: [PATCH 08/27] Resolve the promise in case of error for fetching details for mentioned user to avoid skipping of notifications to other users --- connect/connectNotificationServer.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index a219c53..2bb28c2 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -96,11 +96,12 @@ const getNotificationsForMentionedUser = (logger, eventConfig, content) => { }); resolve(notifications); }).catch((error) => { - console.log(error, 'error'); if (logger) { logger.error(error); + logger.info('Unable to send notification to mentioned user') } - reject(new Error('Unable to fetch details for mentioned user in the message.')); + //resolves with empty notification which essentially means we are unable to send notification to mentioned user + resolve([]); }); } else { resolve([]); From 20024e3098a81ce9f2dcc14d20ff07ac09c5a272 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 16 May 2019 14:10:21 +0530 Subject: [PATCH 09/27] Logging in health endpoint --- src/app.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app.js b/src/app.js index 12826a1..ccdbbb3 100644 --- a/src/app.js +++ b/src/app.js @@ -76,7 +76,9 @@ function startKafkaConsumer(handlers, notificationServiceHandlers) { }); const check = function () { + logger.debug('Checking Health...') ; if (!consumer.client.initialBrokers && !consumer.client.initialBrokers.length) { + logger.debug('Found unhealthy Kafka Brokers...'); return false; } let connected = true; @@ -84,6 +86,7 @@ function startKafkaConsumer(handlers, notificationServiceHandlers) { logger.debug(`url ${conn.server()} - connected=${conn.connected}`); connected = conn.connected & connected; }); + logger.debug('Found all Kafka Brokers healthy...'); return connected; }; From 6321083362df1e40352c31354cd67ad8a33fbeda Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 16 May 2019 14:14:19 +0530 Subject: [PATCH 10/27] Temporary disabling the fix to reproduce the error on dev --- connect/connectNotificationServer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 2bb28c2..00107ea 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -95,14 +95,14 @@ const getNotificationsForMentionedUser = (logger, eventConfig, content) => { notification.userId = mentionedUser ? mentionedUser.userId.toString() : notification.userHandle; }); resolve(notifications); - }).catch((error) => { + })/*.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 resolve([]); - }); + })*/; } else { resolve([]); } From 0680ba789e82a7dd4412cb0828be2a0255e8167a Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Thu, 16 May 2019 18:29:21 +0800 Subject: [PATCH 11/27] properly quote and escape query to member service --- connect/service.js | 2 +- src/common/tcApiHelper.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/connect/service.js b/connect/service.js index d4ade02..aae9f38 100644 --- a/connect/service.js +++ b/connect/service.js @@ -166,7 +166,7 @@ const getUsersById = (ids) => { * @return {Promise} resolves to the list of user details */ const getUsersByHandle = (handles) => { - const query = _.map(handles, (handle) => 'handle:' + handle).join(' OR '); + const query = _.map(handles, (handle) => 'handle:"' + handle.trim().replace('"', '\\"') + '"').join(' OR '); return M2m.getMachineToken(config.AUTH0_CLIENT_ID, config.AUTH0_CLIENT_SECRET) .catch((err) => { err.message = 'Error generating m2m token: ' + err.message; diff --git a/src/common/tcApiHelper.js b/src/common/tcApiHelper.js index d83e25f..4d84987 100644 --- a/src/common/tcApiHelper.js +++ b/src/common/tcApiHelper.js @@ -83,7 +83,7 @@ function* getUsersByHandles(handles) { return []; } // use 'OR' to link the handle matches - const query = _.map(handles, (h) => 'handle:"' + h.trim() + '"').join(' OR '); + const query = _.map(handles, (h) => 'handle:"' + h.trim().replace('"', '\\"') + '"').join(' OR '); return yield searchUsersByQuery(query); } From 6819b5c07b5afe75715b64c2d2f56e20c58c4c76 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 12:55:40 +0530 Subject: [PATCH 12/27] Testing new improved health check --- connect/connectNotificationServer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 2bb28c2..7ee6e87 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -89,20 +89,20 @@ const getNotificationsForMentionedUser = (logger, eventConfig, content) => { 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) => { + return 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) => { + })/*.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 resolve([]); - }); + })*/; } else { resolve([]); } From 0da69cacbc709903073f80d20ab7d3e753283cd1 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 12:56:08 +0530 Subject: [PATCH 13/27] Deployable feature branch --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8b2a88c..6e10c3f 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, 'feature/general-purpose-notifications-usage', 'feature/test_health_check'] - "build-prod": context : org-global filters: From 7c1bc9fb2fb80bf8ca631bb093cefe0c27d777be Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 12:58:47 +0530 Subject: [PATCH 14/27] Testing new improved health check --- connect/service.js | 2 +- src/app.js | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/connect/service.js b/connect/service.js index aae9f38..302fdb8 100644 --- a/connect/service.js +++ b/connect/service.js @@ -166,7 +166,7 @@ const getUsersById = (ids) => { * @return {Promise} resolves to the list of user details */ const getUsersByHandle = (handles) => { - const query = _.map(handles, (handle) => 'handle:"' + handle.trim().replace('"', '\\"') + '"').join(' OR '); + const query = _.map(handles, (handle) => 'handle:' + handle.trim()).join(' OR '); return M2m.getMachineToken(config.AUTH0_CLIENT_ID, config.AUTH0_CLIENT_SECRET) .catch((err) => { err.message = 'Error generating m2m token: ' + err.message; diff --git a/src/app.js b/src/app.js index ccdbbb3..5c7944d 100644 --- a/src/app.js +++ b/src/app.js @@ -75,20 +75,37 @@ function startKafkaConsumer(handlers, notificationServiceHandlers) { }); }); + var latestSubscriptions = null; + const check = function () { - logger.debug('Checking Health...') ; + logger.debug("Checking health"); if (!consumer.client.initialBrokers && !consumer.client.initialBrokers.length) { logger.debug('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; + } + } + // 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; + logger.debug(`url ${conn.server()} - connected=${conn.connected}`) + connected = conn.connected & connected }); - logger.debug('Found all Kafka Brokers healthy...'); - return connected; - }; + return connected + } consumer .init() From 029c70023150b96c9a26deb6d0d41b16657be211 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 15:36:25 +0530 Subject: [PATCH 15/27] Removed using explicit constructor anti pattern which was breaking the promise chain to leave a error throw by an API method to turn in to an unhandled rejection of promise --- connect/connectNotificationServer.js | 40 +++++++++++++--------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 7ee6e87..f1e94e6 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -86,27 +86,25 @@ 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) { - return 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') - } - //resolves with empty notification which essentially means we are unable to send notification to mentioned user - resolve([]); - })*/; - } else { - resolve([]); - } - }); + 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() : notification.userHandle; + }); + 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([]); + } }; /** From 667441e488b50773c874bf9002e5de65f82d46e1 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 15:37:29 +0530 Subject: [PATCH 16/27] Better to not break promise chain --- connect/connectNotificationServer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index f1e94e6..90286a6 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -413,10 +413,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: From 566fe4fde6c727e02af823caedfab90547276583 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 15:39:08 +0530 Subject: [PATCH 17/27] Aborting the process on any unhandled rejection to let the container know about this and restart the task --- connect/connectNotificationServer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 90286a6..70b1e85 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -504,5 +504,12 @@ if (config.ENABLE_EMAILS) { // notificationServer.logger.error('Notification server errored out'); // }); + +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(); +}); + // if no need to init database, then directly start the server: notificationServer.startKafkaConsumers(); From 230cf6d7da3e98ba807a8d81e1cb0565f4fb816d Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 15:40:27 +0530 Subject: [PATCH 18/27] Avoiding usage of explicit constructor anti pattern and instead use library method which takes care of most of the things with better fault tolerance and less chances of breaking the promise chain. --- connect/connectNotificationServer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 70b1e85..41184bb 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -149,7 +149,7 @@ const getProjectMembersNotifications = (eventConfig, project) => { return Promise.resolve([]); } - return new Promise((resolve) => { + return Promise.promisify((callback) => { let notifications = []; const projectMembers = _.get(project, 'members', []); @@ -184,8 +184,8 @@ const getProjectMembersNotifications = (eventConfig, project) => { // only one per userId notifications = _.uniqBy(notifications, 'userId'); - resolve(notifications); - }); + callback(null, notifications); + })(); }; /** From 2c1843152c958e3c00d86d614eaf4c594325c54b Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 16:04:15 +0530 Subject: [PATCH 19/27] Adding back the fix for escaping the reserved keywords of elastic search --- connect/service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connect/service.js b/connect/service.js index 302fdb8..aae9f38 100644 --- a/connect/service.js +++ b/connect/service.js @@ -166,7 +166,7 @@ const getUsersById = (ids) => { * @return {Promise} resolves to the list of user details */ const getUsersByHandle = (handles) => { - const query = _.map(handles, (handle) => 'handle:' + handle.trim()).join(' OR '); + const query = _.map(handles, (handle) => 'handle:"' + handle.trim().replace('"', '\\"') + '"').join(' OR '); return M2m.getMachineToken(config.AUTH0_CLIENT_ID, config.AUTH0_CLIENT_SECRET) .catch((err) => { err.message = 'Error generating m2m token: ' + err.message; From 7213c7d9f0be23619810dd8956b5c5011420413f Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 20 May 2019 16:05:23 +0530 Subject: [PATCH 20/27] Removed feature branch from deployable branches list --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e10c3f..8b2a88c 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', 'feature/test_health_check'] + only: [dev, 'feature/general-purpose-notifications-usage'] - "build-prod": context : org-global filters: From 67b76fee8f0a14fb3f30639f4846df631c815807 Mon Sep 17 00:00:00 2001 From: Muhamad Fikri Alhawarizmi Date: Tue, 21 May 2019 12:50:46 +0700 Subject: [PATCH 21/27] implement v5 API --- config/default.js | 1 + docs/swagger_api.yaml | 625 ++++++++-------- ...ication-server-api.postman_collection.json | 619 ++++++++-------- package.json | 5 +- src/common/tcApiHelper.js | 50 ++ src/controllers/NotificationController.js | 23 +- src/routes.js | 10 + src/services/NotificationService.js | 665 ++++++++++-------- 8 files changed, 1119 insertions(+), 879 deletions(-) diff --git a/config/default.js b/config/default.js index d2f85b6..5ec1698 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', + API_BASE_URL: process.env.API_BASE_URL || 'http://api.topcoder-dev.com', // Configuration for generating machine to machine auth0 token. // The token will be used for calling another internal API. 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/package.json b/package.json index bff9422..3789de8 100644 --- a/package.json +++ b/package.json @@ -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/common/tcApiHelper.js b/src/common/tcApiHelper.js index 4d84987..f039904 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; @@ -318,6 +319,54 @@ function* modifyNotificationNode(ruleSet, data) { 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.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 = { getM2MToken, getUsersBySkills, @@ -329,4 +378,5 @@ module.exports = { getUsersInfoFromChallenge, filterChallengeUsers, modifyNotificationNode, + generateV5Header, }; diff --git a/src/controllers/NotificationController.js b/src/controllers/NotificationController.js index 4b7988b..d4ce321 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,26 @@ 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); + res.json(items); +} + +function* updateNotification(req, res) { + res.json(yield NotificationService.updateNotification(req.user.userId, req.params.id, req.body)); } /** @@ -71,4 +91,5 @@ module.exports = { markAsSeen, getSettings, updateSettings, + updateNotification, }; 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/NotificationService.js b/src/services/NotificationService.js index 027116d..7e85ad5 100644 --- a/src/services/NotificationService.js +++ b/src/services/NotificationService.js @@ -1,313 +1,358 @@ -/** - * 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.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: query.per_page, + 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), + 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); From 0d01c6088345c5684dcbbd8b94ab4cb6ec2ffd5f Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 23 May 2019 15:06:20 +0530 Subject: [PATCH 22/27] Fixing one more Unhandled promise rejection because of wrong data type of userId being passed further for missing users. --- connect/connectNotificationServer.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 41184bb..4bb0993 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -91,13 +91,16 @@ const getNotificationsForMentionedUser = (logger, eventConfig, content) => { return service.getUsersByHandle(handles).then((users) => { _.forEach(notifications, (notification) => { const mentionedUser = _.find(users, { handle: notification.userHandle }); - notification.userId = mentionedUser ? mentionedUser.userId.toString() : 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}`); + } }); return Promise.resolve(notifications); }).catch((error) => { if (logger) { logger.error(error); - logger.info('Unable to send notification to mentioned user') + 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([]); @@ -438,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) { From 28161f7a626c3380664a433cc3990a5afe27c80c Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 23 May 2019 16:29:14 +0530 Subject: [PATCH 23/27] Instead of aborting the process we now let health check to convey the message for more graceful process termination. --- connect/connectNotificationServer.js | 7 ------- src/app.js | 24 +++++++++++++++++++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/connect/connectNotificationServer.js b/connect/connectNotificationServer.js index 4bb0993..4cc98be 100644 --- a/connect/connectNotificationServer.js +++ b/connect/connectNotificationServer.js @@ -507,12 +507,5 @@ if (config.ENABLE_EMAILS) { // notificationServer.logger.error('Notification server errored out'); // }); - -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(); -}); - // if no need to init database, then directly start the server: notificationServer.startKafkaConsumers(); diff --git a/src/app.js b/src/app.js index 5c7944d..1d7d4bc 100644 --- a/src/app.js +++ b/src/app.js @@ -17,6 +17,24 @@ const models = require('./models'); const Kafka = require('no-kafka'); 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. * @param {Object} handlers the handlers @@ -79,8 +97,12 @@ function startKafkaConsumer(handlers, notificationServiceHandlers) { 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) { - logger.debug('Found unhealthy Kafka Brokers...'); + logger.error('Found unhealthy Kafka Brokers...'); return false; } let connected = true; From d53dfedf0d41efd098750639794d42be32f946a1 Mon Sep 17 00:00:00 2001 From: sachin-maheshwari Date: Mon, 27 May 2019 17:51:04 +0530 Subject: [PATCH 24/27] Revert "V5 API standards" --- config/default.js | 1 - docs/swagger_api.yaml | 625 ++++++++-------- ...ication-server-api.postman_collection.json | 619 ++++++++-------- package.json | 5 +- src/common/tcApiHelper.js | 50 -- src/controllers/NotificationController.js | 23 +- src/routes.js | 10 - src/services/NotificationService.js | 665 ++++++++---------- 8 files changed, 879 insertions(+), 1119 deletions(-) diff --git a/config/default.js b/config/default.js index 5ec1698..d2f85b6 100644 --- a/config/default.js +++ b/config/default.js @@ -32,7 +32,6 @@ 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', - API_BASE_URL: process.env.API_BASE_URL || 'http://api.topcoder-dev.com', // Configuration for generating machine to machine auth0 token. // The token will be used for calling another internal API. diff --git a/docs/swagger_api.yaml b/docs/swagger_api.yaml index 1e77b61..1afa80f 100644 --- a/docs/swagger_api.yaml +++ b/docs/swagger_api.yaml @@ -1,350 +1,275 @@ -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 +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 diff --git a/docs/tc-notification-server-api.postman_collection.json b/docs/tc-notification-server-api.postman_collection.json index 7267bf8..f7c65c0 100644 --- a/docs/tc-notification-server-api.postman_collection.json +++ b/docs/tc-notification-server-api.postman_collection.json @@ -1,108 +1,83 @@ { - "id": "8fe3fe1d-744f-4c0e-ac34-e2029e6308e9", + "id": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", "name": "tc-notification-server-api", "description": "", "auth": null, "events": null, - "variables": [], + "variables": null, "order": [ - "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" + "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" ], "folders_order": [ - "060feceb-1658-4fee-9d71-0fedc75effe9" + "dbebd550-6c33-4778-b467-d56decf16c91" ], "folders": [ { - "id": "060feceb-1658-4fee-9d71-0fedc75effe9", + "id": "dbebd550-6c33-4778-b467-d56decf16c91", "name": "failure", "description": "", "auth": null, "events": null, - "collection": "8fe3fe1d-744f-4c0e-ac34-e2029e6308e9", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", "folder": null, "order": [ - "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" + "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" ], "folders_order": [] } ], "requests": [ { - "id": "0012295a-9139-47bb-91b3-1f53d94bd928", - "name": "updateNotification", - "url": "{{URL}}/1", + "id": "19332a51-03e8-4f5c-8f85-4d28d6dfe6f4", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "name": "getSettings", + "url": "{{URL}}/settings", "description": "", "data": [], "dataMode": "raw", "headerData": [ { - "description": "", - "enabled": true, "key": "Content-Type", - "value": "application/json" + "value": "application/json", + "description": "", + "enabled": true }, { - "description": "", - "enabled": true, "key": "Authorization", - "value": "Bearer {{TOKEN}}" + "value": "Bearer {{TOKEN}}", + "description": "", + "enabled": true } ], - "method": "PATCH", + "method": "GET", "pathVariableData": [], "queryParams": [], "auth": null, "events": null, "folder": null, - "rawModeData": "{\n\t\"read\": true\n}", + "rawModeData": "", "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "021b87b0-caca-41e9-85ea-16a9d09bab22", - "name": "markAllRead - missing token", - "url": "{{URL}}/read", + "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", "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", @@ -129,7 +104,7 @@ }, { "key": "limit", - "value": "abc", + "value": "20", "equals": true, "description": "", "enabled": true @@ -143,22 +118,24 @@ }, { "key": "read", - "value": "false", + "value": "yes", "equals": true, "description": "", - "enabled": false + "enabled": true } ], "auth": null, "events": null, - "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "folder": "dbebd550-6c33-4778-b467-d56decf16c91", + "rawModeData": "", "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "334aae9e-38fc-425e-9649-e09c6188ddc2", - "name": "updateSettings - invalid body", - "url": "{{URL}}/settings", + "id": "543cab06-2c7d-4aed-8cf3-0808463254d5", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "name": "markAllRead", + "url": "{{URL}}/read", "description": "", "data": [], "dataMode": "raw", @@ -167,7 +144,7 @@ "key": "Content-Type", "value": "application/json", "description": "", - "enabled": true + "enabled": false }, { "key": "Authorization", @@ -181,18 +158,19 @@ "queryParams": [], "auth": null, "events": null, - "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", + "folder": null, + "rawModeData": "", + "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "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", + "id": "59fc9f2b-28c5-4cff-b21b-11ab51bf67d8", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "name": "getSettings - invalid token", + "url": "{{URL}}/settings", "description": "", - "data": null, - "dataMode": null, + "data": [], + "dataMode": "raw", "headerData": [ { "key": "Content-Type", @@ -202,51 +180,24 @@ }, { "key": "Authorization", - "value": "Bearer {{TOKEN}}", + "value": "Bearer invalid", "description": "", "enabled": true } ], "method": "GET", "pathVariableData": [], - "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 - } - ], + "queryParams": [], "auth": null, "events": null, - "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", - "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "folder": "dbebd550-6c33-4778-b467-d56decf16c91", + "rawModeData": "", + "headers": "Content-Type: application/json\nAuthorization: Bearer invalid\n", "pathVariables": {} }, { - "id": "4dc587b5-2da8-4055-8317-88f7a677eb34", + "id": "76779830-a8a4-4636-8c03-1801b3d1863d", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", "name": "markAsRead", "url": "{{URL}}/1/read", "description": "", @@ -277,12 +228,13 @@ "pathVariables": {} }, { - "id": "68de66b4-c2c6-4b3b-9df4-42f7cf4c4fa3", - "name": "getSettings", - "url": "{{URL}}/settings", + "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", "description": "", - "data": null, - "dataMode": null, + "data": [], + "dataMode": "raw", "headerData": [ { "key": "Content-Type", @@ -291,8 +243,8 @@ "enabled": true }, { - "key": "Authorization", - "value": "Bearer {{TOKEN}}", + "key": "authorization", + "value": "Bearer {{TC_ADMIN_TOKEN}}", "description": "", "enabled": true } @@ -303,47 +255,143 @@ "auth": null, "events": null, "folder": null, - "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "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", "pathVariables": {} }, { - "id": "88038b95-0a16-4a36-8835-0713c731e433", - "name": "updateNotification - invalid action", - "url": "{{URL}}/1", + "id": "cbc03cb1-6dfe-43fd-8e99-8c56923c2978", + "name": "markAsRead - not found", + "url": "{{URL}}/1111111/read", "description": "", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", "data": [], "dataMode": "raw", "headerData": [ { - "description": "", - "enabled": true, "key": "Content-Type", - "value": "application/json" + "value": "application/json", + "description": "", + "enabled": false }, { - "description": "", - "enabled": true, "key": "Authorization", - "value": "Bearer {{TOKEN}}" + "value": "Bearer {{TOKEN}}", + "description": "", + "enabled": true } ], - "method": "PATCH", + "method": "PUT", "pathVariableData": [], "queryParams": [], "auth": null, "events": null, - "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", + "folder": "dbebd550-6c33-4778-b467-d56decf16c91", + "rawModeData": "", + "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "8981f01c-fd95-4d19-abcf-36265682a610", - "name": "listNotifications", - "url": "{{URL}}?page=1&per_page=20&platform=connect", + "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", "description": "", - "data": null, - "dataMode": null, + "data": [], + "dataMode": "raw", "headerData": [ { "key": "Content-Type", @@ -362,74 +410,22 @@ "pathVariableData": [], "queryParams": [ { - "key": "page", - "value": "1", - "equals": true, - "description": "", - "enabled": true - }, - { - "key": "per_page", - "value": "20", - "equals": true, - "description": "", - "enabled": true - }, - { - "key": "platform", - "value": "connect", + "key": "offset", + "value": "0", "equals": true, "description": "", "enabled": true }, { - "key": "read", - "value": "false", - "equals": true, - "description": "", - "enabled": false - } - ], - "auth": null, - "events": null, - "folder": null, - "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", - "pathVariables": {} - }, - { - "id": "9445564c-74bb-4599-8eca-ae292d5b37fc", - "name": "listNotifications - invalid page", - "url": "{{URL}}/list?page=-1&per_page=20", - "description": "", - "data": null, - "dataMode": null, - "headerData": [ - { - "description": "", - "enabled": true, - "key": "Content-Type", - "value": "application/json" - }, - { - "description": "", - "enabled": true, - "key": "Authorization", - "value": "Bearer {{TOKEN}}" - } - ], - "method": "GET", - "pathVariableData": [], - "queryParams": [ - { - "key": "page", - "value": "-1", + "key": "limit", + "value": "abc", "equals": true, "description": "", "enabled": true }, { - "key": "per_page", - "value": "20", + "key": "type", + "value": "notifications.connect.project.updated", "equals": true, "description": "", "enabled": true @@ -444,14 +440,16 @@ ], "auth": null, "events": null, - "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "folder": "dbebd550-6c33-4778-b467-d56decf16c91", + "rawModeData": "", "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} }, { - "id": "af4744ee-ceba-4a35-a14a-eb38290139fb", + "id": "d57ba947-a5e7-410a-b978-76882f33c86e", "name": "updateSettings", "url": "{{URL}}/settings", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", "description": "", "data": [], "dataMode": "raw", @@ -480,39 +478,36 @@ "pathVariables": {} }, { - "id": "c2d28896-3208-4326-a65e-1718c1f891c5", - "name": "getSettings - invalid token", - "url": "{{URL}}/settings", + "id": "da23d550-55b3-4f7d-9131-735956d62f6d", + "name": "markAllRead - missing token", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "url": "{{URL}}/read", "description": "", - "data": null, - "dataMode": null, + "data": [], + "dataMode": "raw", "headerData": [ { "key": "Content-Type", "value": "application/json", "description": "", - "enabled": true - }, - { - "key": "Authorization", - "value": "Bearer invalid", - "description": "", - "enabled": true + "enabled": false } ], - "method": "GET", + "method": "PUT", "pathVariableData": [], "queryParams": [], "auth": null, "events": null, - "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", - "headers": "Content-Type: application/json\nAuthorization: Bearer invalid\n", + "folder": "dbebd550-6c33-4778-b467-d56decf16c91", + "rawModeData": "", + "headers": "//Content-Type: application/json\n", "pathVariables": {} }, { - "id": "c31aa2cc-e377-4b59-a4ee-9e5181449996", - "name": "markAllRead", - "url": "{{URL}}/read", + "id": "f2246cf7-7aae-4ea0-9d92-1d932d340302", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "name": "updateSettings - invalid body", + "url": "{{URL}}/settings", "description": "", "data": [], "dataMode": "raw", @@ -521,7 +516,7 @@ "key": "Content-Type", "value": "application/json", "description": "", - "enabled": false + "enabled": true }, { "key": "Authorization", @@ -535,15 +530,16 @@ "queryParams": [], "auth": null, "events": null, - "folder": null, - "rawModeData": "", - "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "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", "pathVariables": {} }, { - "id": "c8bb53de-91fc-443c-a8fc-d13f503d0e5f", - "name": "markAsRead - not found", - "url": "{{URL}}/1111111/read", + "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", "description": "", "data": [], "dataMode": "raw", @@ -552,7 +548,7 @@ "key": "Content-Type", "value": "application/json", "description": "", - "enabled": false + "enabled": true }, { "key": "Authorization", @@ -561,23 +557,53 @@ "enabled": true } ], - "method": "PUT", + "method": "GET", "pathVariableData": [], - "queryParams": [], + "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 + } + ], "auth": null, "events": null, - "folder": "060feceb-1658-4fee-9d71-0fedc75effe9", + "folder": "dbebd550-6c33-4778-b467-d56decf16c91", "rawModeData": "", - "headers": "//Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", + "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", + "id": "fce69847-5bf8-4b07-bcaf-6352db4ba923", + "collectionId": "3f30c4e3-3b7a-491b-bdb2-6629d081a452", + "name": "listNotifications", + "url": "{{URL}}/list?offset=0&limit=20", "description": "", - "data": null, - "dataMode": null, + "data": [], + "dataMode": "raw", "headerData": [ { "key": "Content-Type", @@ -586,113 +612,50 @@ "enabled": true }, { - "key": "authorization", - "value": "Bearer {{TC_ADMIN_TOKEN}}", + "key": "Authorization", + "value": "Bearer {{TOKEN}}", "description": "", "enabled": true } ], "method": "GET", "pathVariableData": [], - "queryParams": [], - "auth": null, - "events": null, - "folder": null, - "responses": [ + "queryParams": [ { - "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" + "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": false + }, + { + "key": "read", + "value": "false", + "equals": true, + "description": "", + "enabled": false } ], - "headers": "Content-Type: application/json\nauthorization: Bearer {{TC_ADMIN_TOKEN}}\n", + "auth": null, + "events": null, + "folder": null, + "rawModeData": "", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{TOKEN}}\n", "pathVariables": {} } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 3789de8..bff9422 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,8 @@ "sequelize": "^4.21.0", "superagent": "^3.8.0", "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v2.6", - "topcoder-healthcheck-dropin": "^1.0.3", - "urijs": "^1.19.1", - "winston": "^2.2.0" + "winston": "^2.2.0", + "topcoder-healthcheck-dropin": "^1.0.3" }, "engines": { "node": "6.x" diff --git a/src/common/tcApiHelper.js b/src/common/tcApiHelper.js index f039904..4d84987 100644 --- a/src/common/tcApiHelper.js +++ b/src/common/tcApiHelper.js @@ -2,7 +2,6 @@ * 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; @@ -319,54 +318,6 @@ function* modifyNotificationNode(ruleSet, data) { 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.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 = { getM2MToken, getUsersBySkills, @@ -378,5 +329,4 @@ module.exports = { getUsersInfoFromChallenge, filterChallengeUsers, modifyNotificationNode, - generateV5Header, }; diff --git a/src/controllers/NotificationController.js b/src/controllers/NotificationController.js index d4ce321..4b7988b 100644 --- a/src/controllers/NotificationController.js +++ b/src/controllers/NotificationController.js @@ -4,7 +4,6 @@ 'use strict'; const NotificationService = require('../services/NotificationService'); -const tcApiHelper = require('../common/tcApiHelper'); /** * List notifications. @@ -12,26 +11,7 @@ const tcApiHelper = require('../common/tcApiHelper'); * @param res the response */ function* listNotifications(req, res) { - 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); - res.json(items); -} - -function* updateNotification(req, res) { - res.json(yield NotificationService.updateNotification(req.user.userId, req.params.id, req.body)); + res.json(yield NotificationService.listNotifications(req.query, req.user.userId)); } /** @@ -91,5 +71,4 @@ module.exports = { markAsSeen, getSettings, updateSettings, - updateNotification, }; diff --git a/src/routes.js b/src/routes.js index c3e2b97..cf1df2e 100644 --- a/src/routes.js +++ b/src/routes.js @@ -7,16 +7,6 @@ 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/NotificationService.js b/src/services/NotificationService.js index 7e85ad5..027116d 100644 --- a/src/services/NotificationService.js +++ b/src/services/NotificationService.js @@ -1,358 +1,313 @@ -/** - * 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 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: query.per_page, - 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), - 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, -}; +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, +}; logger.buildService(module.exports); From b1fad0f077b415469ccc6f1b97580b3f97750534 Mon Sep 17 00:00:00 2001 From: Sachin Maheshwari Date: Mon, 27 May 2019 18:45:16 +0530 Subject: [PATCH 25/27] supporting 'limit' filed in query string temporarily. --- src/services/NotificationService.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/NotificationService.js b/src/services/NotificationService.js index 7e85ad5..9b5ea52 100644 --- a/src/services/NotificationService.js +++ b/src/services/NotificationService.js @@ -179,7 +179,7 @@ updateSettings.schema = { function* listNotifications(query, userId) { const settings = yield getSettings(userId); const notificationSettings = settings.notifications; - const limit = query.per_page; + const limit = query.limit || query.per_page; const offset = (query.page - 1) * limit; const filter = { where: { userId, @@ -213,7 +213,7 @@ function* listNotifications(query, userId) { }); return { items, - perPage: query.per_page, + perPage: limit, currentPage: query.page, total: docs.count, }; @@ -223,6 +223,8 @@ 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 From 9632135a3fa565ea90959b936a4af113c35ebcc1 Mon Sep 17 00:00:00 2001 From: Sachin Maheshwari Date: Wed, 29 May 2019 19:32:50 +0530 Subject: [PATCH 26/27] patch for v5 api standards --- .circleci/config.yml | 2 +- config/default.js | 1 + docs/swagger_api.yaml | 625 ++++++++-------- ...ication-server-api.postman_collection.json | 619 ++++++++-------- package.json | 5 +- src/common/tcApiHelper.js | 50 ++ src/controllers/NotificationController.js | 23 +- src/routes.js | 10 + src/services/NotificationService.js | 667 ++++++++++-------- 9 files changed, 1122 insertions(+), 880 deletions(-) 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..d82296f 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. 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/package.json b/package.json index 7e802da..b2f5e4c 100644 --- a/package.json +++ b/package.json @@ -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/common/tcApiHelper.js b/src/common/tcApiHelper.js index 37ef659..2fd5a7e 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; @@ -317,6 +318,54 @@ function* modifyNotificationNode(ruleSet, data) { 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 = { getM2MToken, getUsersBySkills, @@ -328,4 +377,5 @@ module.exports = { getUsersInfoFromChallenge, filterChallengeUsers, modifyNotificationNode, + generateV5Header, }; diff --git a/src/controllers/NotificationController.js b/src/controllers/NotificationController.js index 4b7988b..d4ce321 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,26 @@ 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); + res.json(items); +} + +function* updateNotification(req, res) { + res.json(yield NotificationService.updateNotification(req.user.userId, req.params.id, req.body)); } /** @@ -71,4 +91,5 @@ module.exports = { markAsSeen, getSettings, updateSettings, + updateNotification, }; 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/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); From 9be19286ae0326cbafec82f324e1a1fb6f986404 Mon Sep 17 00:00:00 2001 From: Sachin Maheshwari Date: Thu, 30 May 2019 15:21:32 +0530 Subject: [PATCH 27/27] setting old response format --- src/controllers/NotificationController.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/controllers/NotificationController.js b/src/controllers/NotificationController.js index d4ce321..5f91450 100644 --- a/src/controllers/NotificationController.js +++ b/src/controllers/NotificationController.js @@ -27,7 +27,20 @@ function* listNotifications(req, res) { }); res.set(headers); - res.json(items); + + /** + * 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) {
IMGIMG YOUR TOPCODER PROJECT UPDATES