diff --git a/src/common/constants.js b/src/common/constants.js index 4633ff1..9ad3e4f 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -10,6 +10,19 @@ const _ = require('lodash'); const config = require('config'); +const projectTypes = { + app_dev: 'Full App', + generic: 'Work Project', + visual_prototype: 'Design & Prototype', + visual_design: 'Design', +}; +const icons = { + slack: { + CoderBotIcon: 'https://emoji.slack-edge.com/T03R80JP7/coder-the-bot/85ae574c0c7063ef.png', + CoderErrorIcon: 'https://emoji.slack-edge.com/T03R80JP7/coder-error/cd2633216e7fd385.png', + CoderGrinningIcon: 'https://emoji.slack-edge.com/T03R80JP7/coder-grinning/a3b7f3fe9e838377.png', + }, +}; module.exports = { // The event types to be consumed from the source RabbitMQ events: { @@ -30,7 +43,7 @@ module.exports = { fallback: 'A project is ready to be reviewed.', title: _.get(data, 'project.name', ''), title_link: `https://connect.${config.get('AUTH_DOMAIN')}/projects/${data.project.id}/`, - text: _.truncate(_.get(data, 'project.description', ''), {length: 200, separator: /,? +.,/ }), + text: _.truncate(_.get(data, 'project.description', ''), { length: 200, separator: /,? +.,/ }), ts: (new Date(_.get(data, 'project.updatedAt', null))).getTime() / 1000, fields: [ { @@ -40,7 +53,12 @@ module.exports = { }, { title: 'Owner', - value: `${_.get(data, 'owner.firstName', '')} ${_.get(data, 'owner.lastName', '')}` , + value: `${_.get(data, 'owner.firstName', '')} ${_.get(data, 'owner.lastName', '')}`, + short: false, + }, + { + title: 'Project Type', + value: projectTypes[data.project.type], short: false, }, ], @@ -48,16 +66,61 @@ module.exports = { }, projectUnclaimed: (data) => { return { + icon_url: icons.slack.CoderBotIcon, channel: `${config.get('SLACK_CHANNEL_COPILOTS')}`, pretext: 'A project has been reviewed and needs a copilot. Please check it out and claim it.', fallback: 'A project has been reviewed and needs a copilot. Please check it out and claim it.', title: _.get(data, 'project.name', ''), title_link: `https://connect.${config.get('AUTH_DOMAIN')}/projects/${data.project.id}/`, - text: _.truncate(_.get(data, 'project.description', ''), {length: 200, separator: /,? +.,/ }), + text: _.truncate(_.get(data, 'project.description', ''), { length: 200, separator: /,? +.,/ }), + ts: (new Date(_.get(data, 'project.updatedAt', null))).getTime() / 1000, + fields: [ + { + title: 'Project Type', + value: projectTypes[data.project.type], + short: false, + }, + ] + } + }, + projectUnclaimedReposted: (data) => { + return { + icon_url: icons.slack.CoderErrorIcon, + channel: `${config.get('SLACK_CHANNEL_COPILOTS')}`, + pretext: 'We\'re still looking for a copilot for a reviewed project. Please check it out and claim it.', + fallback: 'We\'re still looking for a copilot for a reviewed project. Please check it out and claim it.', + title: _.get(data, 'project.name', ''), + title_link: `https://connect.${config.get('AUTH_DOMAIN')}/projects/${data.project.id}/`, + text: _.truncate(_.get(data, 'project.description', ''), { length: 200, separator: /,? +.,/ }), ts: (new Date(_.get(data, 'project.updatedAt', null))).getTime() / 1000, - fields: [] + fields: [ + { + title: 'Project Type', + value: projectTypes[data.project.type], + short: false, + }, + ] } - } + }, + projectClaimed: (data) => { + return { + icon_url: icons.slack.CoderGrinningIcon, + channel: `${config.get('SLACK_CHANNEL_COPILOTS')}`, + pretext: `${data.firstName} ${data.lastName} has claimed a project. Welcome to the team!`, + fallback: `${data.firstName} ${data.lastName} has claimed a project. Welcome to the team!`, + title: _.get(data, 'project.name', ''), + title_link: `https://connect.${config.get('AUTH_DOMAIN')}/projects/${data.project.id}/`, + text: _.truncate(_.get(data, 'project.description', ''), { length: 200, separator: /,? +.,/ }), + ts: (new Date(_.get(data, 'project.updatedAt', null))).getTime() / 1000, + fields: [ + { + title: 'Project Type', + value: projectTypes[data.project.type], + short: false, + }, + ] + } + }, }, discourse: { project: { @@ -107,6 +170,10 @@ module.exports = { title: 'Your project has a new owner', content: (data) => `${data.firstName} ${data.lastName} is now responsible for project ${data.projectName}. Good luck ${data.firstName}.`, }, + ownerAdded: { + title: 'Ownership changed', + content: (data) => `Your project has a new owner ${data.firstName} ${data.lastName} is now responsible for project Project title. Good luck ${data.firstName}!`, + }, }, }, project: { @@ -138,4 +205,6 @@ module.exports = { customer: 'customer', copilot: 'copilot', }, + projectTypes, + icons, }; diff --git a/src/handlers/memberEvents.js b/src/handlers/memberEvents.js index 234eee0..e5ec2dc 100644 --- a/src/handlers/memberEvents.js +++ b/src/handlers/memberEvents.js @@ -10,6 +10,7 @@ const config = require('config'); const constants = require('../common/constants'); const util = require('./util'); +const _ = require('lodash'); /** * Create notifications from project.member.added events @@ -23,12 +24,32 @@ function* memberAdded(logger, data) { ]; let topic; - if (data.role === constants.memberRoles.customer) { + const notifications = { + slack: { + copilot: [], + }, + }; + if (data.role === constants.memberRoles.customer && data.isPrimary) { + topic = constants.notifications.discourse.teamMembers.ownerAdded; + } else if (data.role === constants.memberRoles.customer) { topic = constants.notifications.discourse.teamMembers.added; } else if (data.role === constants.memberRoles.manager) { topic = constants.notifications.discourse.teamMembers.managerJoined; } else if (data.role === constants.memberRoles.copilot) { topic = constants.notifications.discourse.teamMembers.copilotJoined; + // Notify project claimed + if ((project.status === constants.projectStatuses.active || + project.status === constants.projectStatuses.reviewed) + && _.filter(project.members, ['role', 'copilot']).length < 2) { + const slackNotification = util.buildSlackNotification( + { + project, + firstName: addedMember.firstName, + lastName: addedMember.lastName, + }, + constants.notifications.slack.projectClaimed); + notifications.slack.copilot.push(slackNotification); + } } const topicData = { @@ -38,13 +59,11 @@ function* memberAdded(logger, data) { lastName: addedMember.lastName, }; - const notifications = { - discourse: [{ - projectId: project.id, - title: topic.title, - content: topic.content(topicData), - }], - }; + notifications.discourse = [{ + projectId: project.id, + title: topic.title, + content: topic.content(topicData), + }]; return notifications; } diff --git a/src/handlers/projectEvents.js b/src/handlers/projectEvents.js index 6e65c1d..b7ffbf6 100644 --- a/src/handlers/projectEvents.js +++ b/src/handlers/projectEvents.js @@ -129,9 +129,9 @@ function* projectUnclaimedNotifications(logger, data) { projectCopilotIds.length === 0) { notifications.delayed = data; const slackNotification = util.buildSlackNotification( - { project, }, - constants.notifications.slack.projectUnclaimed - ) + { project }, + constants.notifications.slack.projectUnclaimedReposted + ); notifications.slack.copilot.push(slackNotification); } return notifications; diff --git a/src/handlers/util.js b/src/handlers/util.js index bb2b8df..c992503 100644 --- a/src/handlers/util.js +++ b/src/handlers/util.js @@ -200,7 +200,7 @@ function buildSlackNotification(data, slackDataGenerator) { const slackData = slackDataGenerator(data); return { username: config.get('SLACK_USERNAME'), - icon_url: config.get('SLACK_ICON_URL'), + icon_url: slackData.url || config.get('SLACK_ICON_URL'), channel: slackData.channel, attachments: [{ color: "#36a64f", diff --git a/src/test/app.test.js b/src/test/app.test.js index d4f503c..894e59a 100644 --- a/src/test/app.test.js +++ b/src/test/app.test.js @@ -27,6 +27,7 @@ const sampleEvents = { updatedReviewedAnotherStatus: require('./data/events.updated.reviewed.anotherStatus.json'), updatedReviewedSameStatus: require('./data/events.updated.reviewed.sameStatus.json'), memberAddedTeamMember: require('./data/events.memberAdded.teamMember.json'), + memberAddedOwner: require('./data/events.memberAdded.owner.json'), memberAddedManager: require('./data/events.memberAdded.manager.json'), memberAddedCopilot: require('./data/events.memberAdded.copilot.json'), memberRemovedLeft: require('./data/events.memberRemoved.left.json'), @@ -55,7 +56,13 @@ const expectedSlackNotficationBase = { title: "test", title_link: "https://connect.topcoder-dev.com/projects/1/", text: "test", - fields: [], + fields: [ + { + short: false, + title: 'Project Type', + value: 'Design', + }, + ], footer: "Topcoder", footer_icon: "https://emoji.slack-edge.com/T03R80JP7/topcoder/7c68acd90a6b6d77.png", ts: 1478304000, @@ -66,7 +73,21 @@ const expectedSlackCopilotNotification = _.cloneDeep(expectedSlackNotficationBas _.extend(expectedSlackCopilotNotification.attachments[0], { pretext: 'A project has been reviewed and needs a copilot. Please check it out and claim it.', fallback: 'A project has been reviewed and needs a copilot. Please check it out and claim it.', -}) +}); + +const expectedRepostedSlackCopilotNotification = _.cloneDeep(expectedSlackNotficationBase); +_.extend(expectedRepostedSlackCopilotNotification.attachments[0], { + pretext: 'We\'re still looking for a copilot for a reviewed project. Please check it out and claim it.', + fallback: 'We\'re still looking for a copilot for a reviewed project. Please check it out and claim it.', +}); +const expectedClaimedSlackCopilotNotification = _.cloneDeep(expectedSlackNotficationBase); +_.extend(expectedClaimedSlackCopilotNotification.attachments[0], { + pretext: 'F_user L_user has claimed a project. Welcome to the team!', + fallback: 'F_user L_user has claimed a project. Welcome to the team!', + text: 'Project description 1', + title: 'Project name 1', + ts: '1477671612', +}); const expectedManagerSlackNotification = _.cloneDeep(expectedSlackNotficationBase); _.extend(expectedManagerSlackNotification.attachments[0], { @@ -75,6 +96,11 @@ _.extend(expectedManagerSlackNotification.attachments[0], { fields: [ { title: 'Ref Code', value: '', short: false }, { title: 'Owner', value: 'F_user L_user', short: false }, + { + short: false, + title: 'Project Type', + value: 'Design', + }, ] }) @@ -278,7 +304,7 @@ describe('app', () => { assertCount += 1; sinon.assert.notCalled(spy); const params = slackSpy.lastCall.args; - assert.deepEqual(params[1], expectedSlackCopilotNotification); + assert.deepEqual(params[1], expectedRepostedSlackCopilotNotification); // console.log('assert#', assertCount) // console.log('callbackCount#', callbackCount) // checkAssert(assertCount, callbackCount, done); @@ -316,6 +342,18 @@ describe('app', () => { }); describe('`project.member.added` event', () => { + it('should create `Project.Member.ownerAdded` notification', (done) => { + sendTestEvent(sampleEvents.memberAddedOwner, 'project.member.added'); + setTimeout(() => { + const expectedTitle = 'Ownership changed'; + const expectedBody = 'Your project has a new owner F_user L_user is now responsible for project Project title. Good luck F_user!'; + const params = spy.lastCall.args; + assert.equal(params[2], expectedTitle); + assert.equal(params[3], expectedBody); + done(); + }, testTimeout); + }); + it('should create `Project.Member.TeamMemberAdded` notification', (done) => { sendTestEvent(sampleEvents.memberAddedTeamMember, 'project.member.added'); setTimeout(() => { @@ -351,6 +389,32 @@ describe('app', () => { done(); }, testTimeout); }); + it('should create `Project.Member.CopilotJoined` notification and slack copilot joined notification', (done) => { + request.get.restore(); + stub = sinon.stub(request, 'get'); + stub.withArgs(sinon.match.has('url', `${config.API_BASE_URL}/v4/projects/1`)) + .yields(null, { statusCode: 200 }, sampleProjects.projectTest); + stub.withArgs(sinon.match.has('url', `${config.API_BASE_URL}/v3/members/_search/?query=userId:40051331`)) + .yields(null, { statusCode: 200 }, sampleUsers.user1); + + sendTestEvent(sampleEvents.memberAddedCopilot, 'project.member.added'); + setTimeout(() => { + const expectedTitle = 'A Topcoder copilot has joined your project'; + const expectedBody = 'F_user L_user has joined your project test as a copilot.'; + const params = spy.lastCall.args; + assert.equal(params[2], expectedTitle); + assert.equal(params[3], expectedBody); + const slackParams = slackSpy.lastCall.args; + const expectedTestCopilotNotificaton = _.cloneDeep(expectedClaimedSlackCopilotNotification); + _.extend(expectedTestCopilotNotificaton.attachments[0], { + text: 'test', + title: 'test', + ts: 1478304000, + }); + assert.deepEqual(slackParams[1], expectedTestCopilotNotificaton); + done(); + }, testTimeout); + }); }); describe('`project.member.removed` event', () => { diff --git a/src/test/data/events.memberAdded.owner.json b/src/test/data/events.memberAdded.owner.json new file mode 100644 index 0000000..e57eb32 --- /dev/null +++ b/src/test/data/events.memberAdded.owner.json @@ -0,0 +1,11 @@ +{ + "id": 1185, + "userId": 40051331, + "role": "customer", + "isPrimary": true, + "createdAt": "2016-11-04T03:57:57.000Z", + "updatedAt": "2016-11-04T03:57:57.000Z", + "createdBy": 40152856, + "updatedBy": 40152856, + "projectId": 1 +} diff --git a/src/test/data/events.memberAdded.teamMember.json b/src/test/data/events.memberAdded.teamMember.json index e57eb32..c5c3085 100644 --- a/src/test/data/events.memberAdded.teamMember.json +++ b/src/test/data/events.memberAdded.teamMember.json @@ -2,7 +2,7 @@ "id": 1185, "userId": 40051331, "role": "customer", - "isPrimary": true, + "isPrimary": false, "createdAt": "2016-11-04T03:57:57.000Z", "updatedAt": "2016-11-04T03:57:57.000Z", "createdBy": 40152856,