From 316cfe3b08e279dcc57ee456cbd88589d1deac53 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 14:56:09 +0300 Subject: [PATCH 01/22] add prefix to list test --- tests/index.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/index.test.js b/tests/index.test.js index de1f72a..9cf5ca4 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -35,7 +35,7 @@ describe('app', () => { }); }); - it('shows an empty list of pledges with the list command', () => { + it('list: shows an empty list of pledges with the list command', () => { return request(app).post('/slackCommand') .send('user_name=luca') .send('text=list') From 63e32e437fd8e51ce90b69d64483c54c1745cd16 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 14:57:24 +0300 Subject: [PATCH 02/22] complete create test to test for pledge to be in list too --- tests/index.test.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/index.test.js b/tests/index.test.js index 9cf5ca4..ef7ee4f 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -50,21 +50,31 @@ describe('app', () => { }); }); - it('notifies both requester and performer when a pledge is created', () => { - return request(app).post('/slackCommand') + it('create: notifies both immediately, appears in list', () => { + MockDate.set(0); + request(app).post('/slackCommand') .send('user_name=requester') - .send('text=@performer pledge content by tomorrow') + .send('text=@performer pledge content by tomorrow at 10am') .expect(200) .then((res) => { expect(typeof res.text).toBe('string'); // notification for requester sent as response to slack command - expect(res.text).toMatch('You asked @performer to \"pledge content\" by tomorrow'); + expect(res.text).toMatch('You asked @performer to \"pledge content\" by tomorrow at 10am'); // notification for performer expect(slack.postOnSlack.mock.calls[0][0].text) - .toMatch('@requester asked you to \"pledge content\" by tomorrow'); + .toMatch('@requester asked you to \"pledge content\" by tomorrow at 10am'); expect(slack.postOnSlack.mock.calls[0][0].channel) .toMatch('@performer'); }); + request(app).post('/slackCommand') + .send('user_name=requester') + .send('text=list') + .expect(200) + .then((res) => { + expect(typeof res.text).toBe('string'); + // pledge is present + expect(res.text).toMatch('_@performer_ pledged to pledge content *by 2 January at 10:00*'); + }); }); it('notifies both requester and performer when a pledge has expired', () => { From f26057b3c75e9001802edb642e4f25357bef2aa5 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 17:02:06 +0300 Subject: [PATCH 03/22] adding return to all res.send to avoid UnhandledPromiseRejectionWarning --- src/routes.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes.js b/src/routes.js index f2a5631..84e6437 100644 --- a/src/routes.js +++ b/src/routes.js @@ -70,7 +70,7 @@ router.post('/slackCommand', async ({ body: { text, user_name } }, res) => { const requester = `@${user_name}`; if (typeof text === 'undefined' || typeof user_name === 'undefined') { - res.status(422).send('command does not respect Slack POST format'); + return res.status(422).send('command does not respect Slack POST format'); } try { @@ -82,7 +82,7 @@ router.post('/slackCommand', async ({ body: { text, user_name } }, res) => { } } catch (err) { debug(err); - res.send(`Error: ${err.message}`); + return res.send(`Error: ${err.message}`); } }); @@ -100,9 +100,9 @@ router.get('/deletePledge/:pledgeId', async ({ params: { pledgeId } }, res) => { text: notificationMessage, channel: requester }); - res.send(`Successfully deleted pledge #${pledgeId}`); + return res.send(`Successfully deleted pledge #${pledgeId}`); } catch (e) { - res.send(`Error: ${e.message}`); + return res.send(`Error: ${e.message}`); } }); @@ -120,9 +120,9 @@ router.get('/completePledge/:pledgeId', async ({ params: { pledgeId } }, res) => text: notificationMessage, channel: requester }); - res.send(`Successfully completed pledge #${pledgeId}`); + return res.send(`Successfully completed pledge #${pledgeId}`); } catch (e) { - res.send(`Error: ${e.message}`); + return res.send(`Error: ${e.message}`); } }); From 60cf66b7688342ed14d51564e6882035a0b51205 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 17:06:06 +0300 Subject: [PATCH 04/22] completed test -> expired: notifies both at the right time, stays in list --- tests/index.test.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/index.test.js b/tests/index.test.js index ef7ee4f..2841ef2 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -77,18 +77,22 @@ describe('app', () => { }); }); - it('notifies both requester and performer when a pledge has expired', () => { + it('expired: notifies both at the right time, stays in list', () => { const timezoneOffset = new Date().getTimezoneOffset(); const interval = 10 * 60 * 60 * 1000; // 10 hours const adjInterval = interval + timezoneOffset * 60 * 1000; MockDate.set(0); return request(app).post('/slackCommand') .send('user_name=requester') - .send('text=@performer pledge content by today at 10am') + .send('text=@performer content by today at 10am') .expect(200) .then(async () => { + // number of notifications sent to both performer and requester const nExp = () => - slack.postOnSlackMultipleChannels.mock.calls.filter((c) => c[0].text.match(/expired/)).length; + slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { + return c[0].text.match(/expired/) && c[1].includes('@performer') + && c[1].includes('@requester') && c[1].length === 2; + }).length; // just before, no notifications MockDate.set(adjInterval - 1); await findNewNotifications(); @@ -101,6 +105,16 @@ describe('app', () => { MockDate.set(adjInterval + 999); await findNewNotifications(); expect(nExp()).toBe(1); + // check that it's still present in list + return request(app).post('/slackCommand') + .send('user_name=requester') + .send('text=list') + .expect(200) + .then((res) => { + expect(typeof res.text).toBe('string'); + // pledge is present + expect(res.text).toMatch('_@performer_ pledged to content *by 1 January at 10:00*'); + }); }); }); From e3ba4af4760e72215e3a72da967b260194141400 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 17:09:48 +0300 Subject: [PATCH 05/22] added test for expired: does not notify for completed pledges, also closes #7 --- src/db.js | 2 +- tests/index.test.js | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/db.js b/src/db.js index a6d874f..6f99621 100644 --- a/src/db.js +++ b/src/db.js @@ -106,7 +106,7 @@ export const findAllPledgesExpiredToNotify = () => { return db.all(` SELECT id, requester, performer, content, deadline FROM pledges - WHERE deadline < ? AND expiredNotificationSent = 0 + WHERE deadline < ? AND expiredNotificationSent = 0 AND completed = 0 `, Date.now() ); }; diff --git a/tests/index.test.js b/tests/index.test.js index 2841ef2..732b5d2 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -15,6 +15,8 @@ describe('app', () => { describe('slackCommand', () => { beforeEach(async () => { + slack.postOnSlackMultipleChannels.mockClear(); + slack.postOnSlack.mockClear(); await db.init(`db-${Math.random().toString(36).substr(2, 20)}`); }); @@ -118,6 +120,29 @@ describe('app', () => { }); }); - }); + it('expired: does not notify for completed pledges', () => { + const timezoneOffset = new Date().getTimezoneOffset(); + const interval = 10 * 60 * 60 * 1000; // 10 hours + const adjInterval = interval + timezoneOffset * 60 * 1000; + MockDate.set(0); + return request(app).post('/slackCommand') + .send('user_name=requester') + .send('text=@performer content by today at 10am') + .expect(200) + .then(async () => { + return request(app).get('/completePledge/1').expect(200).then(async () => { + const nExp = () => + slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { + return c[0].text.match(/expired/) && c[1].includes('@performer') + && c[1].includes('@requester') && c[1].length === 2; + }).length; + // after some time, no notification ever arrived + MockDate.set(adjInterval + 999); + await findNewNotifications(); + expect(nExp()).toBe(0); + }); + }); + }); + }); }); From 7c84ac253c50766a0268eaa4aa99d784420b3b07 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 17:15:32 +0300 Subject: [PATCH 06/22] completed test -> expired: does not notify for deleted pledges --- tests/index.test.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/index.test.js b/tests/index.test.js index 732b5d2..a0427fd 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -144,5 +144,29 @@ describe('app', () => { }); }); + it('expired: does not notify for deleted pledges', () => { + const timezoneOffset = new Date().getTimezoneOffset(); + const interval = 10 * 60 * 60 * 1000; // 10 hours + const adjInterval = interval + timezoneOffset * 60 * 1000; + MockDate.set(0); + return request(app).post('/slackCommand') + .send('user_name=requester') + .send('text=@performer content by today at 10am') + .expect(200) + .then(async () => { + return request(app).get('/deletePledge/1').expect(200).then(async () => { + const nExp = () => + slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { + return c[0].text.match(/expired/) && c[1].includes('@performer') + && c[1].includes('@requester') && c[1].length === 2; + }).length; + // after some time, no notification ever arrived + MockDate.set(adjInterval + 999); + await findNewNotifications(); + expect(nExp()).toBe(0); + }); + }); + }); + }); }); From ba35f7ca4349b42c54bf9d3272de2f9e3c22ff39 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 17:51:17 +0300 Subject: [PATCH 07/22] completed test -> delete: notifies both immediately, disappears form list --- src/routes.js | 11 +++-------- tests/index.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/routes.js b/src/routes.js index 84e6437..b1755b3 100644 --- a/src/routes.js +++ b/src/routes.js @@ -92,14 +92,9 @@ router.get('/deletePledge/:pledgeId', async ({ params: { pledgeId } }, res) => { await db.deletePledge(pledgeId); // notify on slack const notificationMessage = `pledge "${content}" has been deleted`; - await slack.postOnSlack({ - text: notificationMessage, - channel: performer - }); - await slack.postOnSlack({ - text: notificationMessage, - channel: requester - }); + await slack.postOnSlackMultipleChannels({ + text: notificationMessage + }, [requester, performer]); return res.send(`Successfully deleted pledge #${pledgeId}`); } catch (e) { return res.send(`Error: ${e.message}`); diff --git a/tests/index.test.js b/tests/index.test.js index a0427fd..6d70e81 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -10,6 +10,20 @@ import * as slack from '../src/slack'; slack.postOnSlack.mockImplementation(() => Promise.resolve()); slack.postOnSlackMultipleChannels.mockImplementation(() => Promise.resolve()); +const getList = () => { + return request(app).post('/slackCommand') + .send('user_name=requester') + .send('text=list') + .expect(200); +}; + +const createPledge = (by) => { + return request(app).post('/slackCommand') + .send('user_name=requester') + .send(`text=@performer content by ${by}`) + .expect(200); +}; + describe('app', () => { describe('slackCommand', () => { @@ -168,5 +182,29 @@ describe('app', () => { }); }); + it('delete: notifies both immediately, disappears from list', () => { + MockDate.set(0); + // create a pledge + return createPledge('tomorrow at 10am').then(() => { + return getList().then((res) => { + // pledge is present in list + expect(res.text).toMatch('_@performer_ pledged to content *by 2 January at 10:00*'); + // delete the pledge + return request(app).get('/deletePledge/1').expect(200).then((res) => { + expect(res.text).toEqual('Successfully deleted pledge #1'); + // notifications are sent + expect(slack.postOnSlackMultipleChannels.mock.calls[0]).toEqual([ + { text: 'pledge "content" has been deleted' }, + ['@requester', '@performer'] + ]); + return getList().then((res) => { + // pledge is not in list any more + expect(res.text).not.toMatch('_@performer_ pledged to content *by 2 January at 10:00*'); + }); + }); + }); + }); + }); + }); }); From d5081de4b263936fa6218717665e5fe57bea8568 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 18:04:32 +0300 Subject: [PATCH 08/22] tests for completed pledges --- src/routes.js | 11 +++-------- tests/index.test.js | 47 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/routes.js b/src/routes.js index b1755b3..9ff048b 100644 --- a/src/routes.js +++ b/src/routes.js @@ -107,14 +107,9 @@ router.get('/completePledge/:pledgeId', async ({ params: { pledgeId } }, res) => await db.completePledge(pledgeId); // notify on slack const notificationMessage = `pledge "${content}" has been completed !!!`; - await slack.postOnSlack({ - text: notificationMessage, - channel: performer - }); - await slack.postOnSlack({ - text: notificationMessage, - channel: requester - }); + await slack.postOnSlackMultipleChannels({ + text: notificationMessage + }, [requester, performer]); return res.send(`Successfully completed pledge #${pledgeId}`); } catch (e) { return res.send(`Error: ${e.message}`); diff --git a/tests/index.test.js b/tests/index.test.js index 6d70e81..64a4f48 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -206,5 +206,52 @@ describe('app', () => { }); }); + it('complete: notifies both immediately, disappears from list', () => { + MockDate.set(0); + // create a pledge + return createPledge('tomorrow at 10am').then(() => { + return getList().then((res) => { + // pledge is present in list + expect(res.text).toMatch('_@performer_ pledged to content *by 2 January at 10:00*'); + // delete the pledge + return request(app).get('/completePledge/1').expect(200).then((res) => { + expect(res.text).toEqual('Successfully completed pledge #1'); + // notifications are sent + expect(slack.postOnSlackMultipleChannels.mock.calls[0]).toEqual([ + { text: 'pledge "content" has been completed !!!' }, + ['@requester', '@performer'] + ]); + return getList().then((res) => { + // pledge is not in list any more + expect(res.text).not.toMatch('_@performer_ pledged to content *by 2 January at 10:00*'); + }); + }); + }); + }); + }); + + it('complete: notifies both immediately, even if expired', () => { + MockDate.set(0); + const timezoneOffset = new Date().getTimezoneOffset(); + const interval = 10 * 60 * 60 * 1000; // 10 hours + const adjInterval = interval + timezoneOffset * 60 * 1000; + // create a pledge + return createPledge('today at 10am').then(async () => { + // let it expire + MockDate.set(adjInterval + 999); + await findNewNotifications(); + // complete the pledge + return request(app).get('/completePledge/1').expect(200).then((res) => { + expect(res.text).toEqual('Successfully completed pledge #1'); + // notifications are sent + expect(slack.postOnSlackMultipleChannels.mock.calls + .filter((x) => x[0].text.match('has been completed'))[0]).toEqual([ + { text: 'pledge "content" has been completed !!!' }, + ['@requester', '@performer'] + ]); + }); + }); + }); + }); }); From 9787034516de1f0571d7c19a4178e25c24596a47 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sat, 4 Feb 2017 18:51:28 +0300 Subject: [PATCH 09/22] refactor --- tests/index.test.js | 183 +++++++++++++++++++------------------------- 1 file changed, 78 insertions(+), 105 deletions(-) diff --git a/tests/index.test.js b/tests/index.test.js index 64a4f48..d9b7fdf 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -52,45 +52,33 @@ describe('app', () => { }); it('list: shows an empty list of pledges with the list command', () => { - return request(app).post('/slackCommand') - .send('user_name=luca') - .send('text=list') - .expect(200) - .then((res) => { - expect(typeof res.text).toBe('string'); - // headers for lists are presents - expect(res.text).toMatch('My pledges'); - expect(res.text).toMatch('My requests'); - // no pledge is present (empty lists) - expect(res.text).not.toMatch('pledged to'); - }); + return getList().then((res) => { + expect(typeof res.text).toBe('string'); + // headers for lists are presents + expect(res.text).toMatch('My pledges'); + expect(res.text).toMatch('My requests'); + // no pledge is present (empty lists) + expect(res.text).not.toMatch('pledged to'); + }); }); it('create: notifies both immediately, appears in list', () => { MockDate.set(0); - request(app).post('/slackCommand') - .send('user_name=requester') - .send('text=@performer pledge content by tomorrow at 10am') - .expect(200) - .then((res) => { - expect(typeof res.text).toBe('string'); - // notification for requester sent as response to slack command - expect(res.text).toMatch('You asked @performer to \"pledge content\" by tomorrow at 10am'); - // notification for performer - expect(slack.postOnSlack.mock.calls[0][0].text) - .toMatch('@requester asked you to \"pledge content\" by tomorrow at 10am'); - expect(slack.postOnSlack.mock.calls[0][0].channel) - .toMatch('@performer'); - }); - request(app).post('/slackCommand') - .send('user_name=requester') - .send('text=list') - .expect(200) - .then((res) => { + return createPledge('tomorrow at 10am').then((res) => { + expect(typeof res.text).toBe('string'); + // notification for requester sent as response to slack command + expect(res.text).toMatch('You asked @performer to \"content\" by tomorrow at 10am'); + // notification for performer + expect(slack.postOnSlack.mock.calls[0][0].text) + .toMatch('@requester asked you to \"content\" by tomorrow at 10am'); + expect(slack.postOnSlack.mock.calls[0][0].channel) + .toMatch('@performer'); + return getList().then((res) => { expect(typeof res.text).toBe('string'); // pledge is present - expect(res.text).toMatch('_@performer_ pledged to pledge content *by 2 January at 10:00*'); + expect(res.text).toMatch('_@performer_ pledged to content *by 2 January at 10:00*'); }); + }); }); it('expired: notifies both at the right time, stays in list', () => { @@ -98,40 +86,31 @@ describe('app', () => { const interval = 10 * 60 * 60 * 1000; // 10 hours const adjInterval = interval + timezoneOffset * 60 * 1000; MockDate.set(0); - return request(app).post('/slackCommand') - .send('user_name=requester') - .send('text=@performer content by today at 10am') - .expect(200) - .then(async () => { - // number of notifications sent to both performer and requester - const nExp = () => - slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { - return c[0].text.match(/expired/) && c[1].includes('@performer') - && c[1].includes('@requester') && c[1].length === 2; - }).length; - // just before, no notifications - MockDate.set(adjInterval - 1); - await findNewNotifications(); - expect(nExp()).toBe(0); - // right after, it notifies - MockDate.set(adjInterval + 1); - await findNewNotifications(); - expect(nExp()).toBe(1); - // after some time, does not notify twice - MockDate.set(adjInterval + 999); - await findNewNotifications(); - expect(nExp()).toBe(1); - // check that it's still present in list - return request(app).post('/slackCommand') - .send('user_name=requester') - .send('text=list') - .expect(200) - .then((res) => { - expect(typeof res.text).toBe('string'); - // pledge is present - expect(res.text).toMatch('_@performer_ pledged to content *by 1 January at 10:00*'); - }); + return createPledge('today at 10am').then(async () => { + // number of notifications sent to both performer and requester + const nExp = () => + slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { + return c[0].text.match(/expired/) && c[1].includes('@performer') + && c[1].includes('@requester') && c[1].length === 2; + }).length; + // just before, no notifications + MockDate.set(adjInterval - 1); + await findNewNotifications(); + expect(nExp()).toBe(0); + // right after, it notifies + MockDate.set(adjInterval + 1); + await findNewNotifications(); + expect(nExp()).toBe(1); + // after some time, does not notify twice + MockDate.set(adjInterval + 999); + await findNewNotifications(); + expect(nExp()).toBe(1); + // check that it's still present in list + return getList().then((res) => { + // pledge is present + expect(res.text).toMatch('_@performer_ pledged to content *by 1 January at 10:00*'); }); + }); }); it('expired: does not notify for completed pledges', () => { @@ -139,23 +118,19 @@ describe('app', () => { const interval = 10 * 60 * 60 * 1000; // 10 hours const adjInterval = interval + timezoneOffset * 60 * 1000; MockDate.set(0); - return request(app).post('/slackCommand') - .send('user_name=requester') - .send('text=@performer content by today at 10am') - .expect(200) - .then(async () => { - return request(app).get('/completePledge/1').expect(200).then(async () => { - const nExp = () => - slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { - return c[0].text.match(/expired/) && c[1].includes('@performer') - && c[1].includes('@requester') && c[1].length === 2; - }).length; - // after some time, no notification ever arrived - MockDate.set(adjInterval + 999); - await findNewNotifications(); - expect(nExp()).toBe(0); - }); + return createPledge('today at 10am').then(async () => { + return request(app).get('/completePledge/1').expect(200).then(async () => { + const nExp = () => + slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { + return c[0].text.match(/expired/) && c[1].includes('@performer') + && c[1].includes('@requester') && c[1].length === 2; + }).length; + // after some time, no notification ever arrived + MockDate.set(adjInterval + 999); + await findNewNotifications(); + expect(nExp()).toBe(0); }); + }); }); it('expired: does not notify for deleted pledges', () => { @@ -163,23 +138,19 @@ describe('app', () => { const interval = 10 * 60 * 60 * 1000; // 10 hours const adjInterval = interval + timezoneOffset * 60 * 1000; MockDate.set(0); - return request(app).post('/slackCommand') - .send('user_name=requester') - .send('text=@performer content by today at 10am') - .expect(200) - .then(async () => { - return request(app).get('/deletePledge/1').expect(200).then(async () => { - const nExp = () => - slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { - return c[0].text.match(/expired/) && c[1].includes('@performer') - && c[1].includes('@requester') && c[1].length === 2; - }).length; - // after some time, no notification ever arrived - MockDate.set(adjInterval + 999); - await findNewNotifications(); - expect(nExp()).toBe(0); - }); + return createPledge('today at 10am').then(async () => { + return request(app).get('/deletePledge/1').expect(200).then(async () => { + const nExp = () => + slack.postOnSlackMultipleChannels.mock.calls.filter((c) => { + return c[0].text.match(/expired/) && c[1].includes('@performer') + && c[1].includes('@requester') && c[1].length === 2; + }).length; + // after some time, no notification ever arrived + MockDate.set(adjInterval + 999); + await findNewNotifications(); + expect(nExp()).toBe(0); }); + }); }); it('delete: notifies both immediately, disappears from list', () => { @@ -193,10 +164,11 @@ describe('app', () => { return request(app).get('/deletePledge/1').expect(200).then((res) => { expect(res.text).toEqual('Successfully deleted pledge #1'); // notifications are sent - expect(slack.postOnSlackMultipleChannels.mock.calls[0]).toEqual([ - { text: 'pledge "content" has been deleted' }, - ['@requester', '@performer'] - ]); + expect(slack.postOnSlackMultipleChannels.mock.calls + .filter((x) => x[0].text.match('has been deleted'))[0]).toEqual([ + { text: 'pledge "content" has been deleted' }, + ['@requester', '@performer'] + ]); return getList().then((res) => { // pledge is not in list any more expect(res.text).not.toMatch('_@performer_ pledged to content *by 2 January at 10:00*'); @@ -217,10 +189,11 @@ describe('app', () => { return request(app).get('/completePledge/1').expect(200).then((res) => { expect(res.text).toEqual('Successfully completed pledge #1'); // notifications are sent - expect(slack.postOnSlackMultipleChannels.mock.calls[0]).toEqual([ - { text: 'pledge "content" has been completed !!!' }, - ['@requester', '@performer'] - ]); + expect(slack.postOnSlackMultipleChannels.mock.calls + .filter((x) => x[0].text.match('has been completed'))[0]).toEqual([ + { text: 'pledge "content" has been completed !!!' }, + ['@requester', '@performer'] + ]); return getList().then((res) => { // pledge is not in list any more expect(res.text).not.toMatch('_@performer_ pledged to content *by 2 January at 10:00*'); From 8cffbcc2ed16336d3812f959822dffa86ff3b282 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 13:54:00 +0100 Subject: [PATCH 10/22] add restart command to package json using nodemon --- nodemon.json | 3 +++ package.json | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 nodemon.json diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..592252b --- /dev/null +++ b/nodemon.json @@ -0,0 +1,3 @@ +{ + "ignore": ["tests/*"] +} diff --git a/package.json b/package.json index 03adb36..b99d4cd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "npm run clean && babel src --out-dir lib && cp config.json lib || true", "watch": "npm run clean && cp config.json lib && babel src --out-dir lib --watch", "start": "DEBUG=pledge,http,express:* node lib/index.js", + "restart": "DEBUG=pledge,http,express:* nodemon --config nodemon.json lib/index.js", "test": "jest" }, "repository": { @@ -54,6 +55,7 @@ "babel-preset-stage-0": "^6.5.0", "eslint": "^2.4.0", "eslint-config-buildo": "github:buildo/eslint-config-buildo", - "jest": "^18.1.0" + "jest": "^18.1.0", + "nodemon": "^1.11.0" } } From c1ee030432607a211f57613074586a85e9263dfc Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 13:59:56 +0100 Subject: [PATCH 11/22] add slack keys to config files --- config-ci.json | 5 +++-- config-prod.json | 3 ++- config.json.example | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/config-ci.json b/config-ci.json index 133d97c..bb86ea8 100644 --- a/config-ci.json +++ b/config-ci.json @@ -1,9 +1,10 @@ { "db": "db-test", - "domain": "pledge.our.buildo.io", + "domain": "ci.pledge.com.fake", "interval": 1, "slack": { - "incomingWebhookURL": "https://hooks.slack.com/services/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" + "clientId": "123456789.123456789", + "clientSecret": "abcdef123456789" } } diff --git a/config-prod.json b/config-prod.json index 5613bc4..a277dc9 100644 --- a/config-prod.json +++ b/config-prod.json @@ -3,7 +3,8 @@ "domain": "pledge.our.buildo.io", "interval": 1, "slack": { - "incomingWebhookURL": "https://hooks.slack.com/services/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" + "clientId": "2194261799.137771655351", + "clientSecret": "37ca4f24926ff3b36f39f6f2f77faaff" } } diff --git a/config.json.example b/config.json.example index 379e9eb..d00e2ea 100644 --- a/config.json.example +++ b/config.json.example @@ -3,7 +3,8 @@ "domain": "my.deploy.url", "interval": 1, "slack": { - "incomingWebhookURL": "https://url/of/incoming/webhook" + "clientId": "123456789.123456789", + "clientSecret": "abcdef123456789" } } From a87df4ae6f35a45926e3433d489c1fe54f5fd186 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 14:04:24 +0100 Subject: [PATCH 12/22] write migration to schema v4 and add functions for teams --- src/db.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/db.js b/src/db.js index 6f99621..b4029ac 100644 --- a/src/db.js +++ b/src/db.js @@ -2,12 +2,21 @@ import db from 'sqlite'; import config from '../config.json'; import { formatDate } from './utils'; -const schemaVersion = 3; +const schemaVersion = 4; + +const teamsTableDDL = `CREATE TABLE teams ( + teamName TEXT NOT NULL, + teamId TEXT NOT NULL, + botUserId TEXT NOT NULL, + botAccessToken TEXT NOT NULL, + createdAt INTEGER NOT NULL +);`; const createTables = async () => { await db.run(` CREATE TABLE pledges ( id INTEGER PRIMARY KEY AUTOINCREMENT, + teamId TEXT, requester TEXT NOT NULL, performer TEXT NOT NULL, content TEXT NOT NULL, @@ -17,6 +26,7 @@ const createTables = async () => { created_at INTEGER NOT NULL ); `); + await db.run(teamsTableDDL); await db.run(` CREATE TABLE schemaVersions ( version INTEGER NOT NULL, @@ -67,44 +77,85 @@ const migrateIfNeeded = async () => { ); console.log('migrated DB to version 3'); // eslint-disable-line no-console } + + if (currentVersion < 4) { + // add version line with migrationDate = now + await db.run(` + INSERT INTO schemaVersions values (4, ?) + `, Date.now() + ); + // perform migration v3 => v4 + await db.run(teamsTableDDL); + await db.run(` + ALTER TABLE pledges + ADD COLUMN teamId TEXT` + ); + console.log('migrated DB to version 4'); // eslint-disable-line no-console + } +}; + +export const getTeam = (teamId) => { + return db.get(` + SELECT teamId, teamName, botUserId, botAccessToken, createdAt + FROM teams + WHERE teamId = ? + `, teamId + ); }; -export const getList = async requester => { +export const insertTeam = (teamId, teamName, botUserId, botAccessToken) => { + return db.run(` + INSERT INTO teams (teamId, teamName, botUserId, botAccessToken, createdAt) + VALUES (?, ?, ?, ?, ?) + `, teamId, teamName, botUserId, botAccessToken, Date.now() + ); +}; + +export const getList = async (requester, teamId) => { const requests = (await db.all(` SELECT id, requester, performer, content, deadline FROM pledges - WHERE requester = ? AND completed = 0 - `, requester)).map(x => ({ ...x, deadline: formatDate(new Date(x.deadline)) })); + WHERE requester = ? AND teamId = ? AND completed = 0 + `, requester, teamId)).map(x => ({ ...x, deadline: formatDate(new Date(x.deadline)) })); const pledges = (await db.all(` SELECT id, requester, performer, content, deadline FROM pledges - WHERE performer = ? AND completed = 0 - `, requester)).map(x => ({ ...x, deadline: formatDate(new Date(x.deadline)) })); + WHERE performer = ? AND teamId = ? AND completed = 0 + `, requester, teamId)).map(x => ({ ...x, deadline: formatDate(new Date(x.deadline)) })); return { requests, pledges }; }; export const getPledge = (pledgeId) => { return db.get(` - SELECT id, requester, performer, content, deadline + SELECT id, teamId, requester, performer, content, deadline FROM pledges WHERE id = ? `, pledgeId ); }; -export const insertPledge = ({ requester, performer, content, deadline }) => { +export const insertPledge = ({ teamId, requester, performer, content, deadline }) => { return db.run(` - INSERT INTO pledges (requester, performer, content, deadline, created_at) - VALUES (?, ?, ?, ?, ?) - `, requester, performer, content, deadline, Date.now() + INSERT INTO pledges (teamId, requester, performer, content, deadline, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, teamId, requester, performer, content, deadline, Date.now() + ); +}; + +export const getTeamByBotAccessToken = (botAccessToken) => { + return db.get(` + SELECT teamId, teamName, botUserId, botAccessToken, createdAt + FROM teams + WHERE botAccessToken = ? + `, botAccessToken ); }; export const findAllPledgesExpiredToNotify = () => { return db.all(` - SELECT id, requester, performer, content, deadline + SELECT id, teamId, requester, performer, content, deadline FROM pledges WHERE deadline < ? AND expiredNotificationSent = 0 AND completed = 0 `, Date.now() From 55e740dfe9a0131335bd85d7b5ac5902b14ef375 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 14:06:33 +0100 Subject: [PATCH 13/22] change slack functions to work with bots instead of incoming webhooks --- package.json | 1 + src/slack.js | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index b99d4cd..32454c9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "homepage": "https://github.com/buildo/pledge#readme", "dependencies": { + "@slack/client": "^3.8.1", "babel-core": "^6.4.5", "babel-polyfill": "^6.4.5", "babel-runtime": "^6.9.2", diff --git a/src/slack.js b/src/slack.js index 237a151..df3c73b 100644 --- a/src/slack.js +++ b/src/slack.js @@ -1,13 +1,15 @@ -import request from 'request-promise'; -import config from '../config.json'; +const WebClient = require('@slack/client').WebClient; -const INCOMING_WEBHOOK_URL = config.slack.incomingWebhookURL; - -export const postOnSlack = json => request({ - json: { username: 'pledge', icon_emoji: ':dog:', ...json }, - url: INCOMING_WEBHOOK_URL, - method: 'POST' -}); +export const postOnSlack = (json, botAccessToken) => { + console.log(' => => botAccessToken', botAccessToken); + const web = new WebClient(botAccessToken); + web.chat.postMessage(json.channel, json.text, json.opts, (err) => { + if (err) { + console.log('Error:', err); + } + }); +}; export const postOnSlackMultipleChannels = - (json, channels) => Promise.all(channels.map(c => postOnSlack({ channel: c, ...json }))); + (json, channels, botAccessToken) => Promise.all(channels.map(c => + postOnSlack({ channel: c, ...json }, botAccessToken))); From 8820d6d64491f0f25536f26207224ecae6634dac Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 14:19:47 +0100 Subject: [PATCH 14/22] adapted routes to work with bot and added oauth2 --- package.json | 1 + src/routes.js | 56 +++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 32454c9..2a0b611 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "mockdate": "^2.0.1", "request": "^2.75.0", "request-promise": "^2.0.1", + "simple-oauth2": "^1.0.3", "sqlite": "^2.0.2", "supertest": "^2.0.1" }, diff --git a/src/routes.js b/src/routes.js index 9ff048b..c4e2cf4 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,4 +1,5 @@ import express from 'express'; +import config from '../config.json'; import bodyParser from 'body-parser'; import _debug from 'debug'; import human2date from 'date.js'; @@ -8,7 +9,21 @@ import * as slack from './slack'; const debug = _debug('pledge'); -async function newPledge({ text, requester }) { +const credentials = { + client: { + id: config.slack.clientId, + secret: config.slack.clientSecret + }, + auth: { + tokenHost: 'https://slack.com', + tokenPath: '/api/oauth.access', + authorizePath: '/oauth/authorize' + } +}; + +const oauth2 = require('simple-oauth2').create(credentials); + +async function newPledge({ text, requester }, botAccessToken) { const [, performer, content, humanReadableDeadline] = /(@[a-zA-Z0-9]+) (.+) by (.+)/.exec(text.trim()) || []; if (!performer) { @@ -24,21 +39,22 @@ async function newPledge({ text, requester }) { if (deadline.getTime() < Date.now()) { throw new Error('"When" should be in the future'); } - - await db.insertPledge({ requester, performer, content, deadline }).then(debug); + const teamId = (await db.getTeamByBotAccessToken(botAccessToken)).teamId; + await db.insertPledge({ teamId, requester, performer, content, deadline }).then(debug); await slack.postOnSlack({ text: `${requester} asked you to "${content}" by ${humanReadableDeadline} (${formatDate(deadline)})`, channel: performer - }); + }, botAccessToken); return `You asked ${performer} to "${content}" by ${humanReadableDeadline} (${formatDate(deadline)})`; } -async function getPledgesList(requester) { - const { requests, pledges } = await db.getList(requester); +async function getPledgesList(requester, botAccessToken) { + const teamId = db.getTeamByBotAccessToken(botAccessToken).teamId; + const { requests, pledges } = await db.getList(requester, teamId); - const baseURL = 'https://pledge.our.buildo.io'; + const baseURL = config.domain; const myPledges = `*My pledges:*\n${pledges.map(p => `\n • ${p.content} _for ${p.requester}_ *by ${p.deadline}* <${baseURL}/deletePledge/${p.id}|delete> <${baseURL}/completePledge/${p.id}|complete>`)}`; const myRequests = `*My requests:*\n${requests.map(p => `\n • _${p.performer}_ pledged to ${p.content} *by ${p.deadline}* <${baseURL}/deletePledge/${p.id}|delete> <${baseURL}/completePledge/${p.id}|complete>`)}`; @@ -52,7 +68,7 @@ export async function findNewNotifications() { expiredPledges.map(async p => { await slack.postOnSlackMultipleChannels({ text: `pledge ${p.content} expired just now` - }, [p.requester, p.performer]); + }, [p.requester, p.performer], (await db.getTeam(p.teamId)).botAccessToken); await db.setExpiredNotificationAsSentOnPledge(p.id); }); } @@ -65,7 +81,24 @@ const router = express.Router(); router.use(bodyParser.urlencoded({ extended: false })); -router.post('/slackCommand', async ({ body: { text, user_name } }, res) => { +router.get('/callback', async (req, res) => { + const code = req.query.code; + const options = { code }; + + oauth2.authorizationCode.getToken(options, async (error, result) => { + if (error) { + console.error('Access Token Error', error.message); // eslint-disable-line no-console + return res.json('Authentication failed'); + } + await db.insertTeam(result.team_id, result.team_name, result.bot.bot_user_id, + result.bot.bot_access_token); + return res + .status(200) + .send('You have successfully installed pledge ;-)'); + }); +}); + +router.post('/slackCommand', async ({ body: { text, user_name, user_id, team_id } }, res) => { debug({ text, user_name }); const requester = `@${user_name}`; @@ -73,12 +106,13 @@ router.post('/slackCommand', async ({ body: { text, user_name } }, res) => { return res.status(422).send('command does not respect Slack POST format'); } + const team = await db.getTeam(team_id); try { switch (text.trim()) { case 'list': - return res.send(await getPledgesList(requester)); + return res.send(await getPledgesList(requester, team.botAccessToken)); default: - return res.send(await newPledge({ text, requester })); + return res.send(await newPledge({ text, requester }, team.botAccessToken)); } } catch (err) { debug(err); From 131b51c9192fae4e2803f68e026e8eee1eb05adb Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 14:21:34 +0100 Subject: [PATCH 15/22] added route add-to-slack which shows the add to slack button in a browser --- src/routes.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/routes.js b/src/routes.js index c4e2cf4..0f30a8e 100644 --- a/src/routes.js +++ b/src/routes.js @@ -150,4 +150,22 @@ router.get('/completePledge/:pledgeId', async ({ params: { pledgeId } }, res) => } }); +router.get('/add-to-slack', async (req, res) => { + res.setHeader('Content-Type', 'text/html'); + const html = ` + +
+
+ Install pledge in your Slack team +
+ + Add to Slack + +
+ `; + return res.send(html); +}); + export default router; From 7a6ace93d491e1f43db2562701bbe9a43b7dc5e2 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 14:21:54 +0100 Subject: [PATCH 16/22] yarn lock for all previous changes --- yarn.lock | 464 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 448 insertions(+), 16 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5d905d1..1ab72fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,23 @@ # yarn lockfile v1 +"@slack/client@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@slack/client/-/client-3.8.1.tgz#ac1744f3e87ebd52f60979ecc084c615544caeeb" + dependencies: + async "^1.5.0" + bluebird "^3.3.3" + eventemitter3 "^1.1.1" + https-proxy-agent "^1.0.0" + inherits "^2.0.1" + lodash "^4.13.1" + pkginfo "^0.4.0" + request "^2.64.0" + retry "^0.9.0" + url-join "0.0.1" + winston "^2.1.1" + ws "^1.0.1" + abab@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.3.tgz#b81de5f7274ec4e756d797cd834f303642724e5d" @@ -41,6 +58,13 @@ acorn@^4.0.1: version "4.0.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.4.tgz#17a8d6a7a6c4ef538b814ec9abac2779293bf30a" +agent-base@2: + version "2.0.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-2.0.1.tgz#bd8f9e86a8eb221fffa07bd14befd55df142815e" + dependencies: + extend "~3.0.0" + semver "~5.0.1" + ajv-keywords@^1.0.0: version "1.5.0" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.0.tgz#c11e6859eafff83e0dafc416929472eca946aa2c" @@ -160,7 +184,7 @@ async-each@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" -async@^1.4.0, async@^1.4.2, async@^1.5.2: +async@^1.4.0, async@^1.4.2, async@^1.5.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -174,6 +198,10 @@ async@~0.2.6: version "0.2.10" resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" +async@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async/-/async-1.0.0.tgz#f8fc04ca3a13784ade9e1641af98578cfbd647a9" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -888,10 +916,14 @@ block-stream@*: dependencies: inherits "~2.0.0" -bluebird@^2.3: +bluebird@^2.10.1, bluebird@^2.3: version "2.11.0" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" +bluebird@^3.3.3: + version "3.4.7" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" + body-parser@^1.15.0: version "1.15.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.15.2.tgz#d7578cf4f1d11d5f6ea804cef35dc7a7ff6dae67" @@ -1002,7 +1034,7 @@ chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chokidar@^1.0.0: +chokidar@^1.0.0, chokidar@^1.4.3: version "1.6.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.6.1.tgz#2f4447ab5e96e50fb3d789fd90d4c72e0e4c70c2" dependencies: @@ -1072,7 +1104,7 @@ code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" -colors@1.0.3: +colors@1.0.3, colors@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" @@ -1104,6 +1136,19 @@ concat-stream@^1.4.6: readable-stream "^2.2.2" typedarray "^0.0.6" +configstore@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-1.4.0.tgz#c35781d0501d268c25c54b8b17f6240e8a4fb021" + dependencies: + graceful-fs "^4.1.2" + mkdirp "^0.5.0" + object-assign "^4.0.1" + os-tmpdir "^1.0.0" + osenv "^0.1.0" + uuid "^2.0.1" + write-file-atomic "^1.1.2" + xdg-basedir "^2.0.0" + console-control-strings@^1.0.0, console-control-strings@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" @@ -1160,6 +1205,10 @@ cssom@0.3.x, "cssom@>= 0.3.0 < 0.4.0": dependencies: cssom "0.3.x" +cycle@1.0.x: + version "1.0.3" + resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" + d@^0.1.1, d@~0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" @@ -1172,6 +1221,10 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +date-fns@^1.3.0: + version "1.27.2" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.27.2.tgz#ce82f420bc028356cc661fc55c0494a56a990c9c" + date.js@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/date.js/-/date.js-0.3.1.tgz#39e7c7c77adc765d10becf496cacd391332d7cc8" @@ -1185,7 +1238,7 @@ date.js@^0.3.1: lodash.partition "^4.2.0" lodash.trim "^4.2.0" -debug@^2.1.1, debug@^2.2.0: +debug@2, debug@^2.1.1, debug@^2.2.0: version "2.6.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.0.tgz#bc596bcabe7617f11d9fa15361eded5608b8499b" dependencies: @@ -1258,6 +1311,19 @@ doctrine@^1.2.2: esutils "^2.0.2" isarray "^1.0.0" +duplexer@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" + +duplexify@^3.2.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.0.tgz#1aa773002e1578457e9d9d4a50b0ccaaebcbd604" + dependencies: + end-of-stream "1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + ecc-jsbn@~0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" @@ -1272,6 +1338,12 @@ encodeurl@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" +end-of-stream@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.0.0.tgz#d4596e702734a93e40e9af864319eabd99ff2f0e" + dependencies: + once "~1.3.0" + "errno@>=0.1.1 <0.2.0-0": version "0.1.4" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" @@ -1310,6 +1382,10 @@ es6-map@^0.1.3: es6-symbol "~3.1.0" event-emitter "~0.3.4" +es6-promise@^3.0.2: + version "3.3.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" + es6-set@~0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8" @@ -1474,6 +1550,22 @@ event-emitter@~0.3.4: d "~0.1.1" es5-ext "~0.10.7" +event-stream@~3.3.0: + version "3.3.4" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" + dependencies: + duplexer "~0.1.1" + from "~0" + map-stream "~0.1.0" + pause-stream "0.0.11" + split "0.3" + stream-combiner "~0.0.4" + through "~2.3.1" + +eventemitter3@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" + exec-sh@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.0.tgz#14f75de3f20d286ef933099b2ce50a90359cef10" @@ -1527,7 +1619,7 @@ express@^4.13.4: utils-merge "1.0.0" vary "~1.1.0" -extend@^3.0.0, extend@~3.0.0: +extend@3, extend@^3.0.0, extend@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" @@ -1541,6 +1633,10 @@ extsprintf@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550" +eyes@0.1.x: + version "0.1.8" + resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" + fast-levenshtein@~2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -1654,6 +1750,10 @@ fresh@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f" +from@~0: + version "0.1.3" + resolved "https://registry.yarnpkg.com/from/-/from-0.1.3.tgz#ef63ac2062ac32acf7862e0d40b44b896f22f3bc" + fs-readdir-recursive@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.0.0.tgz#8cd1745c8b4f8a29c8caec392476921ba195f560" @@ -1769,7 +1869,22 @@ globby@^5.0.0: pify "^2.0.0" pinkie-promise "^2.0.0" -graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6: +got@^3.2.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/got/-/got-3.3.1.tgz#e5d0ed4af55fc3eef4d56007769d98192bcb2eca" + dependencies: + duplexify "^3.2.0" + infinity-agent "^2.0.0" + is-redirect "^1.0.0" + is-stream "^1.0.0" + lowercase-keys "^1.0.0" + nested-error-stacks "^1.0.0" + object-assign "^3.0.0" + prepend-http "^1.0.0" + read-all-stream "^3.0.0" + timed-out "^2.0.0" + +graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -1827,6 +1942,10 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" +hoek@4.x.x: + version "4.1.0" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.1.0.tgz#4a4557460f69842ed463aa00628cc26d2683afa7" + home-or-tmp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" @@ -1860,10 +1979,22 @@ http-signature@~1.1.0: jsprim "^1.2.2" sshpk "^1.7.0" +https-proxy-agent@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz#35f7da6c48ce4ddbfa264891ac593ee5ff8671e6" + dependencies: + agent-base "2" + debug "2" + extend "3" + iconv-lite@0.4.13, iconv-lite@^0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" +ignore-by-default@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + ignore@^3.1.2: version "3.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.0.tgz#8d88f03c3002a0ac52114db25d2c673b0bf1e435" @@ -1872,6 +2003,10 @@ imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" +infinity-agent@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/infinity-agent/-/infinity-agent-2.0.3.tgz#45e0e2ff7a9eb030b27d62b74b3744b7a7ac4216" + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -1994,6 +2129,10 @@ is-my-json-valid@^2.10.0, is-my-json-valid@^2.12.4: jsonpointer "^4.0.0" xtend "^4.0.0" +is-npm@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" + is-number@^2.0.2, is-number@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" @@ -2028,12 +2167,20 @@ is-property@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" +is-redirect@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" + is-resolvable@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62" dependencies: tryit "^1.0.1" +is-stream@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -2046,6 +2193,10 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" +isemail@2.x.x: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-2.2.1.tgz#0353d3d9a62951080c262c2aa0a42b8ea8e9e2a6" + isexe@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0" @@ -2056,7 +2207,7 @@ isobject@^2.0.0: dependencies: isarray "1.0.0" -isstream@~0.1.2: +isstream@0.1.x, isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -2124,6 +2275,10 @@ istanbul-reports@^1.0.0: dependencies: handlebars "^4.0.3" +items@2.x.x: + version "2.1.1" + resolved "https://registry.yarnpkg.com/items/-/items-2.1.1.tgz#8bd16d9c83b19529de5aea321acaada78364a198" + jest-changed-files@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-17.0.2.tgz#f5657758736996f590a51b87e5c9369d904ba7b7" @@ -2312,6 +2467,16 @@ jodid25519@^1.0.0: dependencies: jsbn "~0.1.0" +joi@^9.0.4: + version "9.2.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-9.2.0.tgz#3385ac790192130cbe230e802ec02c9215bbfeda" + dependencies: + hoek "4.x.x" + isemail "2.x.x" + items "2.x.x" + moment "2.x.x" + topo "2.x.x" + js-tokens@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-2.0.0.tgz#79903f5563ee778cc1162e6dcf1a0027c97f9cb5" @@ -2407,6 +2572,12 @@ kind-of@^3.0.2: dependencies: is-buffer "^1.0.2" +latest-version@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-1.0.1.tgz#72cfc46e3e8d1be651e1ebb54ea9f6ea96f374bb" + dependencies: + package-json "^1.0.0" + lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" @@ -2472,10 +2643,30 @@ lodash._bindcallback@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" +lodash._createassigner@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11" + dependencies: + lodash._bindcallback "^3.0.0" + lodash._isiterateecall "^3.0.0" + lodash.restparam "^3.0.0" + lodash._getnative@^3.0.0: version "3.9.1" resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" +lodash._isiterateecall@^3.0.0: + version "3.0.9" + resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c" + +lodash.assign@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa" + dependencies: + lodash._baseassign "^3.0.0" + lodash._createassigner "^3.0.0" + lodash.keys "^3.0.0" + lodash.assign@^4.0.0, lodash.assign@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" @@ -2487,6 +2678,13 @@ lodash.clonedeep@^3.0.0: lodash._baseclone "^3.0.0" lodash._bindcallback "^3.0.0" +lodash.defaults@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-3.1.2.tgz#c7308b18dbf8bc9372d701a73493c61192bd2e2c" + dependencies: + lodash.assign "^3.0.0" + lodash.restparam "^3.0.0" + lodash.filter@^4.2.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace" @@ -2531,11 +2729,15 @@ lodash.pickby@^4.0.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" +lodash.restparam@^3.0.0: + version "3.6.1" + resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" + lodash.trim@^4.2.0: version "4.5.1" resolved "https://registry.yarnpkg.com/lodash.trim/-/lodash.trim-4.5.1.tgz#36425e7ee90be4aa5e27bcebb85b7d11ea47aa57" -lodash@^4.0.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.2.0, lodash@^4.3.0, lodash@^4.5.0: +lodash@^4.0.0, lodash@^4.11.1, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.2.0, lodash@^4.3.0, lodash@^4.5.0: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -2549,12 +2751,20 @@ loose-envify@^1.0.0: dependencies: js-tokens "^2.0.0" +lowercase-keys@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" + makeerror@1.0.x: version "1.0.11" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" dependencies: tmpl "1.0.x" +map-stream@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" + marked-terminal@^1.6.2: version "1.7.0" resolved "https://registry.yarnpkg.com/marked-terminal/-/marked-terminal-1.7.0.tgz#c8c460881c772c7604b64367007ee5f77f125904" @@ -2641,6 +2851,10 @@ mockdate@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/mockdate/-/mockdate-2.0.1.tgz#51bc309e2c4396600d56b6c23a6a0f4182943a36" +moment@2.x.x: + version "2.17.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82" + ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" @@ -2665,6 +2879,12 @@ negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" +nested-error-stacks@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-1.0.2.tgz#19f619591519f096769a5ba9a86e6eeec823c3cf" + dependencies: + inherits "~2.0.1" + node-emoji@^1.4.1: version "1.5.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.5.0.tgz#9a0d9fe03fd43afa357d6d8e439aa31e599959b7" @@ -2701,6 +2921,27 @@ node-pre-gyp@^0.6.29, node-pre-gyp@~0.6.31: tar "~2.2.1" tar-pack "~3.3.0" +nodemon@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.11.0.tgz#226c562bd2a7b13d3d7518b49ad4828a3623d06c" + dependencies: + chokidar "^1.4.3" + debug "^2.2.0" + es6-promise "^3.0.2" + ignore-by-default "^1.0.0" + lodash.defaults "^3.1.2" + minimatch "^3.0.0" + ps-tree "^1.0.1" + touch "1.0.0" + undefsafe "0.0.3" + update-notifier "0.5.0" + +nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + dependencies: + abbrev "1" + nopt@~3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" @@ -2741,6 +2982,10 @@ oauth-sign@~0.8.1: version "0.8.2" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" +object-assign@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" + object-assign@^4.0.1, object-assign@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" @@ -2764,7 +3009,7 @@ once@^1.3.0, once@^1.4.0: dependencies: wrappy "1" -once@~1.3.3: +once@~1.3.0, once@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20" dependencies: @@ -2792,6 +3037,10 @@ optionator@^0.8.1: type-check "~0.3.2" wordwrap "~1.0.0" +options@>=0.0.5: + version "0.0.6" + resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f" + os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" @@ -2802,10 +3051,17 @@ os-locale@^1.4.0: dependencies: lcid "^1.0.0" -os-tmpdir@^1.0.1: +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" +osenv@^0.1.0: + version "0.1.4" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + output-file-sync@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-1.1.2.tgz#d0a33eefe61a205facb90092e826598d5245ce76" @@ -2814,6 +3070,13 @@ output-file-sync@^1.1.0: mkdirp "^0.5.1" object-assign "^4.1.0" +package-json@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-1.2.0.tgz#c8ecac094227cdf76a316874ed05e27cc939a0e0" + dependencies: + got "^3.2.0" + registry-url "^3.0.0" + parse-glob@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" @@ -2867,6 +3130,12 @@ path-type@^1.0.0: pify "^2.0.0" pinkie-promise "^2.0.0" +pause-stream@0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + dependencies: + through "~2.3" + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -2881,6 +3150,10 @@ pinkie@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" +pkginfo@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.0.tgz#349dbb7ffd38081fcadc0853df687f0c7744cd65" + pluralize@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" @@ -2889,6 +3162,10 @@ prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" +prepend-http@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" @@ -2922,6 +3199,12 @@ prr@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" +ps-tree@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014" + dependencies: + event-stream "~3.3.0" + punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" @@ -2953,7 +3236,7 @@ raw-body@~2.1.7: iconv-lite "0.4.13" unpipe "1.0.0" -rc@~1.1.6: +rc@^1.0.1, rc@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/rc/-/rc-1.1.6.tgz#43651b76b6ae53b5c802f1151fa3fc3b059969c9" dependencies: @@ -2962,6 +3245,13 @@ rc@~1.1.6: minimist "^1.2.0" strip-json-comments "~1.0.4" +read-all-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa" + dependencies: + pinkie-promise "^2.0.0" + readable-stream "^2.0.0" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -2977,7 +3267,7 @@ read-pkg@^1.0.0: normalize-package-data "^2.3.2" path-type "^1.0.0" -"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.2.2: +readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.2.tgz#a9e6fec3c7dda85f8bb1b3ba7028604556fc825e" dependencies: @@ -3055,6 +3345,12 @@ regexpu-core@^2.0.0: regjsgen "^0.2.0" regjsparser "^0.1.4" +registry-url@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" + dependencies: + rc "^1.0.1" + regjsgen@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" @@ -3073,6 +3369,12 @@ repeat-string@^1.5.2: version "1.6.1" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" +repeating@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-1.1.3.tgz#3d4114218877537494f97f77f9785fab810fa4ac" + dependencies: + is-finite "^1.0.0" + repeating@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" @@ -3087,7 +3389,7 @@ request-promise@^2.0.1: lodash "^4.5.0" request "^2.34" -request@^2.34, request@^2.55.0, request@^2.75.0, request@^2.79.0: +request@^2.34, request@^2.55.0, request@^2.64.0, request@^2.67.0, request@^2.75.0, request@^2.79.0: version "2.79.0" resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de" dependencies: @@ -3146,6 +3448,10 @@ restore-cursor@^1.0.1: exit-hook "^1.0.0" onetime "^1.0.0" +retry@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.9.0.tgz#6f697e50a0e4ddc8c8f7fb547a9b60dead43678d" + right-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" @@ -3183,10 +3489,20 @@ sax@^1.1.4: version "1.2.1" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" -"semver@2 || 3 || 4 || 5", semver@^5.1.0, semver@^5.3.0, semver@~5.3.0: +semver-diff@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" + dependencies: + semver "^5.0.3" + +"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" +semver@~5.0.1: + version "5.0.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.0.3.tgz#77466de589cd5d3c95f138aa78bc569a3cb5d27a" + send@0.14.1: version "0.14.1" resolved "https://registry.yarnpkg.com/send/-/send-0.14.1.tgz#a954984325392f51532a7760760e459598c89f7a" @@ -3238,6 +3554,16 @@ signal-exit@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" +simple-oauth2@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/simple-oauth2/-/simple-oauth2-1.0.3.tgz#949e5bec0674904fde3eda5e7a87b1677dbe1ef8" + dependencies: + bluebird "^2.10.1" + date-fns "^1.3.0" + debug "^2.2.0" + joi "^9.0.4" + request "^2.67.0" + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -3246,6 +3572,10 @@ slice-ansi@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" +slide@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" + sntp@1.x.x: version "1.0.9" resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" @@ -3288,6 +3618,12 @@ spdx-license-ids@^1.0.2: version "1.2.2" resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" +split@0.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" + dependencies: + through "2" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -3320,10 +3656,30 @@ sshpk@^1.7.0: jsbn "~0.1.0" tweetnacl "~0.14.0" +stack-trace@0.0.x: + version "0.0.9" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.9.tgz#a8f6eaeca90674c333e7c43953f275b451510695" + "statuses@>= 1.3.1 < 2", statuses@~1.3.0: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" +stream-combiner@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14" + dependencies: + duplexer "~0.1.1" + +stream-shift@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" + +string-length@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-1.0.1.tgz#56970fb1c38558e9e70b728bf3de269ac45adfac" + dependencies: + strip-ansi "^3.0.0" + string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -3457,10 +3813,14 @@ throat@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/throat/-/throat-3.0.0.tgz#e7c64c867cbb3845f10877642f7b60055b8ec0d6" -through@^2.3.6: +through@2, through@^2.3.6, through@~2.3, through@~2.3.1: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" +timed-out@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-2.0.0.tgz#f38b0ae81d3747d628001f41dafc652ace671c0a" + tmpl@1.0.x: version "1.0.4" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" @@ -3469,6 +3829,18 @@ to-fast-properties@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.2.tgz#f3f5c0c3ba7299a7ef99427e44633257ade43320" +topo@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/topo/-/topo-2.0.2.tgz#cd5615752539057c0dc0491a621c3bc6fbe1d182" + dependencies: + hoek "4.x.x" + +touch@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/touch/-/touch-1.0.0.tgz#449cbe2dbae5a8c8038e30d71fa0ff464947c4de" + dependencies: + nopt "~1.0.10" + tough-cookie@^2.3.1, tough-cookie@~2.3.0: version "2.3.2" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" @@ -3525,10 +3897,34 @@ uid-number@~0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" +ultron@1.0.x: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" + +undefsafe@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-0.0.3.tgz#ecca3a03e56b9af17385baac812ac83b994a962f" + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" +update-notifier@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-0.5.0.tgz#07b5dc2066b3627ab3b4f530130f7eddda07a4cc" + dependencies: + chalk "^1.0.0" + configstore "^1.0.0" + is-npm "^1.0.0" + latest-version "^1.0.0" + repeating "^1.1.2" + semver-diff "^2.0.0" + string-length "^1.0.0" + +url-join@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8" + user-home@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" @@ -3547,6 +3943,10 @@ utils-merge@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" +uuid@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" + uuid@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" @@ -3621,6 +4021,17 @@ window-size@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" +winston@^2.1.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/winston/-/winston-2.3.1.tgz#0b48420d978c01804cf0230b648861598225a119" + dependencies: + async "~1.0.0" + colors "1.0.x" + cycle "1.0.x" + eyes "0.1.x" + isstream "0.1.x" + stack-trace "0.0.x" + wordwrap@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" @@ -3651,12 +4062,33 @@ wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" +write-file-atomic@^1.1.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.1.tgz#7d45ba32316328dd1ec7d90f60ebc0d845bb759a" + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + slide "^1.1.5" + write@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" dependencies: mkdirp "^0.5.1" +ws@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.1.tgz#082ddb6c641e85d4bb451f03d52f06eabdb1f018" + dependencies: + options ">=0.0.5" + ultron "1.0.x" + +xdg-basedir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2" + dependencies: + os-homedir "^1.0.0" + "xml-name-validator@>= 2.0.1 < 3.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635" From f2d4c9777a8f9b966f5880feb06280f3a1f4ea3c Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 14:22:31 +0100 Subject: [PATCH 17/22] added pledge and pledge-test icons for slack app --- assets/pledge-test.png | Bin 0 -> 30780 bytes assets/pledge.png | Bin 0 -> 14994 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/pledge-test.png create mode 100644 assets/pledge.png diff --git a/assets/pledge-test.png b/assets/pledge-test.png new file mode 100644 index 0000000000000000000000000000000000000000..455b06df536f4acc2a38b0ce2e48dfe705e9604a GIT binary patch literal 30780 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 aU1h?4KWn54e%%lPF+Xo@(q!Zv|Nj75g`NKZ literal 0 HcmV?d00001 diff --git a/assets/pledge.png b/assets/pledge.png new file mode 100644 index 0000000000000000000000000000000000000000..bee6b0705b9b4d9b8a7e38779b7950155823bec8 GIT binary patch literal 14994 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 Date: Sun, 5 Feb 2017 15:56:30 +0100 Subject: [PATCH 18/22] remove migrations as impossible to migrate to slack app, and not much useful for a single deploy --- src/db.js | 86 ++++++------------------------------------------------- 1 file changed, 8 insertions(+), 78 deletions(-) diff --git a/src/db.js b/src/db.js index b4029ac..11fe833 100644 --- a/src/db.js +++ b/src/db.js @@ -2,21 +2,11 @@ import db from 'sqlite'; import config from '../config.json'; import { formatDate } from './utils'; -const schemaVersion = 4; - -const teamsTableDDL = `CREATE TABLE teams ( - teamName TEXT NOT NULL, - teamId TEXT NOT NULL, - botUserId TEXT NOT NULL, - botAccessToken TEXT NOT NULL, - createdAt INTEGER NOT NULL -);`; - const createTables = async () => { await db.run(` CREATE TABLE pledges ( id INTEGER PRIMARY KEY AUTOINCREMENT, - teamId TEXT, + teamId TEXT NOT NULL, requester TEXT NOT NULL, performer TEXT NOT NULL, content TEXT NOT NULL, @@ -26,72 +16,15 @@ const createTables = async () => { created_at INTEGER NOT NULL ); `); - await db.run(teamsTableDDL); await db.run(` - CREATE TABLE schemaVersions ( - version INTEGER NOT NULL, - migrationDate INTEGER NOT NULL + CREATE TABLE teams ( + teamName TEXT NOT NULL, + teamId TEXT NOT NULL, + botUserId TEXT NOT NULL, + botAccessToken TEXT NOT NULL, + createdAt INTEGER NOT NULL ); `); - await db.run('INSERT INTO schemaVersions values (?, ?)', schemaVersion, Date.now()); -}; - -const migrateIfNeeded = async () => { - const hasSchemaVersionsTable = !!(await db.get(` - SELECT 1 FROM sqlite_master WHERE name ='schemaVersions' and type='table'; - `)); - const currentVersion = hasSchemaVersionsTable ? - (await db.get('SELECT max(version) as v FROM schemaVersions')).v : 1; - console.log(`starting pledge, current DB version is ${currentVersion}`); // eslint-disable-line no-console - if (currentVersion < 2) { - // version 1 did not have a schemaVersions table, let's create it - await db.run(` - CREATE TABLE if not exists schemaVersions ( - version INTEGER NOT NULL, - migrationDate INTEGER NOT NULL - );` - ); - // add first version line with migrationDate = now - await db.run(` - INSERT INTO schemaVersions values (2, ?) - `, Date.now() - ); - // perform migration v1 => v2 - await db.run(` - ALTER TABLE pledges - ADD COLUMN expiredNotificationSent BOOLEAN NOT NULL DEFAULT 0` - ); - console.log('migrated DB to version 2'); // eslint-disable-line no-console - } - - if (currentVersion < 3) { - // add version line with migrationDate = now - await db.run(` - INSERT INTO schemaVersions values (3, ?) - `, Date.now() - ); - // perform migration v2 => v3 - await db.run(` - ALTER TABLE pledges - ADD COLUMN completed BOOLEAN NOT NULL DEFAULT 0` - ); - console.log('migrated DB to version 3'); // eslint-disable-line no-console - } - - if (currentVersion < 4) { - // add version line with migrationDate = now - await db.run(` - INSERT INTO schemaVersions values (4, ?) - `, Date.now() - ); - // perform migration v3 => v4 - await db.run(teamsTableDDL); - await db.run(` - ALTER TABLE pledges - ADD COLUMN teamId TEXT` - ); - console.log('migrated DB to version 4'); // eslint-disable-line no-console - } }; export const getTeam = (teamId) => { @@ -197,10 +130,7 @@ export const init = async (dbFilename = config.db) => { const hasPledgesTable = !!(await db.get(` SELECT 1 FROM sqlite_master WHERE name ='pledges' and type='table'; `)); - if (hasPledgesTable) { - await migrateIfNeeded(); - } else { - console.log('creating tables'); // eslint-disable-line no-console + if (!hasPledgesTable) { createTables(); } }; From 04b3c9f33a5138c18b52e5b21d3a740cd4a93217 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 16:59:11 +0100 Subject: [PATCH 19/22] make tests pass, fixing tests and a few bugs :) --- src/db.js | 6 +++--- src/routes.js | 11 ++++++----- src/slack.js | 1 - tests/index.test.js | 3 +++ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/db.js b/src/db.js index 11fe833..bc0baf2 100644 --- a/src/db.js +++ b/src/db.js @@ -46,13 +46,13 @@ export const insertTeam = (teamId, teamName, botUserId, botAccessToken) => { export const getList = async (requester, teamId) => { const requests = (await db.all(` - SELECT id, requester, performer, content, deadline + SELECT id, teamId, requester, performer, content, deadline FROM pledges WHERE requester = ? AND teamId = ? AND completed = 0 `, requester, teamId)).map(x => ({ ...x, deadline: formatDate(new Date(x.deadline)) })); const pledges = (await db.all(` - SELECT id, requester, performer, content, deadline + SELECT id, teamId, requester, performer, content, deadline FROM pledges WHERE performer = ? AND teamId = ? AND completed = 0 `, requester, teamId)).map(x => ({ ...x, deadline: formatDate(new Date(x.deadline)) })); @@ -131,7 +131,7 @@ export const init = async (dbFilename = config.db) => { SELECT 1 FROM sqlite_master WHERE name ='pledges' and type='table'; `)); if (!hasPledgesTable) { - createTables(); + await createTables(); } }; diff --git a/src/routes.js b/src/routes.js index 0f30a8e..b28e80e 100644 --- a/src/routes.js +++ b/src/routes.js @@ -51,7 +51,7 @@ async function newPledge({ text, requester }, botAccessToken) { } async function getPledgesList(requester, botAccessToken) { - const teamId = db.getTeamByBotAccessToken(botAccessToken).teamId; + const teamId = (await db.getTeamByBotAccessToken(botAccessToken)).teamId; const { requests, pledges } = await db.getList(requester, teamId); const baseURL = config.domain; @@ -65,12 +65,13 @@ async function getPledgesList(requester, botAccessToken) { export async function findNewNotifications() { // notify for pledges that have expired const expiredPledges = await db.findAllPledgesExpiredToNotify(); - expiredPledges.map(async p => { + await Promise.all(expiredPledges.map(async p => { + const botAccessToken = (await db.getTeam(p.teamId)).botAccessToken; await slack.postOnSlackMultipleChannels({ text: `pledge ${p.content} expired just now` - }, [p.requester, p.performer], (await db.getTeam(p.teamId)).botAccessToken); + }, [p.requester, p.performer], botAccessToken); await db.setExpiredNotificationAsSentOnPledge(p.id); - }); + })); } setInterval(findNewNotifications, 60 * 1000); @@ -98,7 +99,7 @@ router.get('/callback', async (req, res) => { }); }); -router.post('/slackCommand', async ({ body: { text, user_name, user_id, team_id } }, res) => { +router.post('/slackCommand', async ({ body: { text, user_name, team_id } }, res) => { debug({ text, user_name }); const requester = `@${user_name}`; diff --git a/src/slack.js b/src/slack.js index df3c73b..21af3e1 100644 --- a/src/slack.js +++ b/src/slack.js @@ -1,7 +1,6 @@ const WebClient = require('@slack/client').WebClient; export const postOnSlack = (json, botAccessToken) => { - console.log(' => => botAccessToken', botAccessToken); const web = new WebClient(botAccessToken); web.chat.postMessage(json.channel, json.text, json.opts, (err) => { if (err) { diff --git a/tests/index.test.js b/tests/index.test.js index d9b7fdf..2cd0d3c 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -14,6 +14,7 @@ const getList = () => { return request(app).post('/slackCommand') .send('user_name=requester') .send('text=list') + .send('team_id=TEAM_ID') .expect(200); }; @@ -21,6 +22,7 @@ const createPledge = (by) => { return request(app).post('/slackCommand') .send('user_name=requester') .send(`text=@performer content by ${by}`) + .send('team_id=TEAM_ID') .expect(200); }; @@ -32,6 +34,7 @@ describe('app', () => { slack.postOnSlackMultipleChannels.mockClear(); slack.postOnSlack.mockClear(); await db.init(`db-${Math.random().toString(36).substr(2, 20)}`); + await db.insertTeam('TEAM_ID', 'TEAM_NAME', 'BOT_USER_ID', 'BOT_ACCESS_TOKEN'); }); afterEach(async () => { From c54b21b4129683dc7b5339c27e174d95b9aceb30 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 18:00:33 +0100 Subject: [PATCH 20/22] add https --- config-prod.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-prod.json b/config-prod.json index a277dc9..7b2504d 100644 --- a/config-prod.json +++ b/config-prod.json @@ -1,6 +1,6 @@ { "db": "/var/lib/pledge/db", - "domain": "pledge.our.buildo.io", + "domain": "https://pledge.our.buildo.io", "interval": 1, "slack": { "clientId": "2194261799.137771655351", From 1064bf000a322bbd955d7dcb172ab4a83b971ac7 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 18:19:24 +0100 Subject: [PATCH 21/22] avoid inserting duplicate teams --- src/db.js | 8 ++++++++ src/routes.js | 1 + 2 files changed, 9 insertions(+) diff --git a/src/db.js b/src/db.js index bc0baf2..3328241 100644 --- a/src/db.js +++ b/src/db.js @@ -36,6 +36,14 @@ export const getTeam = (teamId) => { ); }; +export const deleteTeam = (teamId) => { + return db.run(` + DELETE FROM teams + WHERE teamId = ? + `, teamId + ); +}; + export const insertTeam = (teamId, teamName, botUserId, botAccessToken) => { return db.run(` INSERT INTO teams (teamId, teamName, botUserId, botAccessToken, createdAt) diff --git a/src/routes.js b/src/routes.js index b28e80e..50bf38d 100644 --- a/src/routes.js +++ b/src/routes.js @@ -91,6 +91,7 @@ router.get('/callback', async (req, res) => { console.error('Access Token Error', error.message); // eslint-disable-line no-console return res.json('Authentication failed'); } + await db.deleteTeam(result.team_id); await db.insertTeam(result.team_id, result.team_name, result.bot.bot_user_id, result.bot.bot_access_token); return res From 0742eaa1ce273a20c9eb38053d3ebce1f448f409 Mon Sep 17 00:00:00 2001 From: Luca Cioria Date: Sun, 5 Feb 2017 19:20:39 +0100 Subject: [PATCH 22/22] added logic to send notification as bot instead as @slackbot --- src/db.js | 13 +++++++------ src/routes.js | 9 +++++---- src/slack.js | 13 ++++++++++--- tests/index.test.js | 4 ++-- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/db.js b/src/db.js index 3328241..10c8fa1 100644 --- a/src/db.js +++ b/src/db.js @@ -9,6 +9,7 @@ const createTables = async () => { teamId TEXT NOT NULL, requester TEXT NOT NULL, performer TEXT NOT NULL, + performerId TEXT NOT NULL, content TEXT NOT NULL, deadline INTEGER NOT NULL, completed BOOLEAN NOT NULL DEFAULT 0, @@ -70,18 +71,18 @@ export const getList = async (requester, teamId) => { export const getPledge = (pledgeId) => { return db.get(` - SELECT id, teamId, requester, performer, content, deadline + SELECT id, teamId, requester, performer, performerId, content, deadline FROM pledges WHERE id = ? `, pledgeId ); }; -export const insertPledge = ({ teamId, requester, performer, content, deadline }) => { +export const insertPledge = ({ teamId, requester, performer, performerId, content, deadline }) => { return db.run(` - INSERT INTO pledges (teamId, requester, performer, content, deadline, created_at) - VALUES (?, ?, ?, ?, ?, ?) - `, teamId, requester, performer, content, deadline, Date.now() + INSERT INTO pledges (teamId, requester, performer, performerId, content, deadline, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, teamId, requester, performer, performerId, content, deadline, Date.now() ); }; @@ -96,7 +97,7 @@ export const getTeamByBotAccessToken = (botAccessToken) => { export const findAllPledgesExpiredToNotify = () => { return db.all(` - SELECT id, teamId, requester, performer, content, deadline + SELECT id, teamId, requester, performer, performerId, content, deadline FROM pledges WHERE deadline < ? AND expiredNotificationSent = 0 AND completed = 0 `, Date.now() diff --git a/src/routes.js b/src/routes.js index 50bf38d..158f39f 100644 --- a/src/routes.js +++ b/src/routes.js @@ -24,8 +24,8 @@ const credentials = { const oauth2 = require('simple-oauth2').create(credentials); async function newPledge({ text, requester }, botAccessToken) { - const [, performer, content, humanReadableDeadline] = /(@[a-zA-Z0-9]+) (.+) by (.+)/.exec(text.trim()) || []; - + const [, , performerId, _performer, content, humanReadableDeadline] = /(\<@([a-zA-Z0-9]+)\|([a-zA-Z0-9]+)\>) (.+) by (.+)/.exec(text.trim()) || []; + const performer = `@${_performer}`; if (!performer) { throw new Error('"Username" is missing. (@username [what] by [when])'); } else if (!content) { @@ -40,11 +40,12 @@ async function newPledge({ text, requester }, botAccessToken) { throw new Error('"When" should be in the future'); } const teamId = (await db.getTeamByBotAccessToken(botAccessToken)).teamId; - await db.insertPledge({ teamId, requester, performer, content, deadline }).then(debug); + await db.insertPledge({ teamId, requester, performer, performerId, content, deadline }).then(debug); await slack.postOnSlack({ text: `${requester} asked you to "${content}" by ${humanReadableDeadline} (${formatDate(deadline)})`, - channel: performer + channel: performerId + }, botAccessToken); return `You asked ${performer} to "${content}" by ${humanReadableDeadline} (${formatDate(deadline)})`; diff --git a/src/slack.js b/src/slack.js index 21af3e1..f908cdb 100644 --- a/src/slack.js +++ b/src/slack.js @@ -1,10 +1,17 @@ const WebClient = require('@slack/client').WebClient; -export const postOnSlack = (json, botAccessToken) => { +export const postOnSlack = async (json, botAccessToken) => { const web = new WebClient(botAccessToken); - web.chat.postMessage(json.channel, json.text, json.opts, (err) => { + web.im.list((err, result) => { if (err) { - console.log('Error:', err); + return console.log(err); + } else { + const imChannel = result.ims.find((im) => im.user === json.channel).id; + return web.chat.postMessage(imChannel, json.text, json.opts, (err) => { + if (err) { + console.log('Error:', err); + } + }); } }); }; diff --git a/tests/index.test.js b/tests/index.test.js index 2cd0d3c..e3b2211 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -21,7 +21,7 @@ const getList = () => { const createPledge = (by) => { return request(app).post('/slackCommand') .send('user_name=requester') - .send(`text=@performer content by ${by}`) + .send(`text=<@U123456789|performer> content by ${by}`) .send('team_id=TEAM_ID') .expect(200); }; @@ -75,7 +75,7 @@ describe('app', () => { expect(slack.postOnSlack.mock.calls[0][0].text) .toMatch('@requester asked you to \"content\" by tomorrow at 10am'); expect(slack.postOnSlack.mock.calls[0][0].channel) - .toMatch('@performer'); + .toMatch('U123456789'); return getList().then((res) => { expect(typeof res.text).toBe('string'); // pledge is present