From 44cb509ebf180bc230298410b3154ee80563b63a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 3 Apr 2018 20:36:25 -0700 Subject: [PATCH] Post to Slack (#603) * Migrations * WIP: Integration model, slack perms / hooks * So so rough it pains me. Building this new model is revealing just how much needs to be refactored * Working connect and post * Cleanup UI, upating documents * Show when slack command is connected * stash * :green_heart: * Add documents.update trigger * Authorization, tidying * Fixed integration policy * pick integration presenter keys --- app/components/Auth.js | 2 + app/index.js | 1 + app/models/Collection.js | 4 +- app/models/Document.js | 4 +- app/models/Integration.js | 57 +++++++++++ .../CollectionDelete/CollectionDelete.js | 1 - app/scenes/Document/Document.js | 2 +- app/scenes/Settings/Slack.js | 99 +++++++++++++++++-- app/scenes/Settings/components/SlackButton.js | 16 ++- app/scenes/SlackAuth/SlackAuth.js | 19 +++- app/stores/AuthStore.js | 8 +- app/stores/CollectionsStore.js | 12 ++- app/stores/IntegrationsStore.js | 76 ++++++++++++++ app/utils/importFile.js | 2 +- server/api/auth.js | 48 ++++++++- server/api/documents.js | 31 +++--- server/api/documents.test.js | 7 +- server/api/hooks.js | 10 +- server/api/index.js | 2 + server/api/integrations.js | 48 +++++++++ server/events.js | 8 +- .../20180212033504-add-integrations.js | 67 +++++++++++++ server/models/Collection.test.js | 30 +++--- server/models/Document.js | 53 ++++++++-- server/models/Integration.js | 35 +++++++ server/models/User.test.js | 5 +- server/models/index.js | 3 + server/policies/index.js | 1 + server/policies/integration.js | 21 ++++ server/presenters/index.js | 4 + server/presenters/integration.js | 20 ++++ server/presenters/revision.js | 1 - server/presenters/slackAttachment.js | 15 +++ {services => server/services}/index.js | 0 server/services/slack/index.js | 43 ++++++++ .../services}/slack/package.json | 0 server/test/support.js | 5 +- services/slack/index.js | 10 -- 38 files changed, 665 insertions(+), 105 deletions(-) create mode 100644 app/models/Integration.js create mode 100644 app/stores/IntegrationsStore.js create mode 100644 server/api/integrations.js create mode 100644 server/migrations/20180212033504-add-integrations.js create mode 100644 server/models/Integration.js create mode 100644 server/policies/integration.js create mode 100644 server/presenters/integration.js create mode 100644 server/presenters/slackAttachment.js rename {services => server/services}/index.js (100%) create mode 100644 server/services/slack/index.js rename {services => server/services}/slack/package.json (100%) delete mode 100644 services/slack/index.js diff --git a/app/components/Auth.js b/app/components/Auth.js index 93a5dcd6..64070996 100644 --- a/app/components/Auth.js +++ b/app/components/Auth.js @@ -6,6 +6,7 @@ import ApiKeysStore from 'stores/ApiKeysStore'; import UsersStore from 'stores/UsersStore'; import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; +import IntegrationsStore from 'stores/IntegrationsStore'; import CacheStore from 'stores/CacheStore'; type Props = { @@ -23,6 +24,7 @@ const Auth = ({ children }: Props) => { const { user, team } = stores.auth; const cache = new CacheStore(user.id); authenticatedStores = { + integrations: new IntegrationsStore(), apiKeys: new ApiKeysStore(), users: new UsersStore(), documents: new DocumentsStore({ diff --git a/app/index.js b/app/index.js index 7b89d8bf..5ec2dd5a 100644 --- a/app/index.js +++ b/app/index.js @@ -59,6 +59,7 @@ render( + diff --git a/app/models/Collection.js b/app/models/Collection.js index 30c09f38..6184c8e0 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -109,9 +109,7 @@ class Collection extends BaseModel { delete = async () => { try { await client.post('/collections.delete', { id: this.id }); - this.emit('collections.delete', { - id: this.id, - }); + this.emit('collections.delete', { id: this.id }); return true; } catch (e) { this.errors.add('Collection failed to delete'); diff --git a/app/models/Document.js b/app/models/Document.js index 0237b4f2..50d08f82 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -168,7 +168,7 @@ class Document extends BaseModel { }; @action - save = async (publish: boolean = false) => { + save = async (publish: boolean = false, done: boolean = false) => { if (this.isSaving) return this; this.isSaving = true; @@ -181,6 +181,7 @@ class Document extends BaseModel { text: this.text, lastRevision: this.revision, publish, + done, }); } else { const data = { @@ -189,6 +190,7 @@ class Document extends BaseModel { title: this.title, text: this.text, publish, + done, }; if (this.parentDocument) { data.parentDocument = this.parentDocument; diff --git a/app/models/Integration.js b/app/models/Integration.js new file mode 100644 index 00000000..415e2f62 --- /dev/null +++ b/app/models/Integration.js @@ -0,0 +1,57 @@ +// @flow +import { extendObservable, action } from 'mobx'; + +import BaseModel from 'models/BaseModel'; +import { client } from 'utils/ApiClient'; +import stores from 'stores'; +import ErrorsStore from 'stores/ErrorsStore'; + +type Settings = { + url: string, + channel: string, + channelId: string, +}; + +type Events = 'documents.create' | 'collections.create'; + +class Integration extends BaseModel { + errors: ErrorsStore; + + id: string; + serviceId: string; + collectionId: string; + events: Events; + settings: Settings; + + @action + update = async (data: Object) => { + try { + await client.post('/integrations.update', { id: this.id, ...data }); + extendObservable(this, data); + } catch (e) { + this.errors.add('Integration failed to update'); + } + return false; + }; + + @action + delete = async () => { + try { + await client.post('/integrations.delete', { id: this.id }); + this.emit('integrations.delete', { id: this.id }); + return true; + } catch (e) { + this.errors.add('Integration failed to delete'); + } + return false; + }; + + constructor(data?: Object = {}) { + super(); + + extendObservable(this, data); + this.errors = stores.errors; + } +} + +export default Integration; diff --git a/app/scenes/CollectionDelete/CollectionDelete.js b/app/scenes/CollectionDelete/CollectionDelete.js index 553a84ed..1e06afa6 100644 --- a/app/scenes/CollectionDelete/CollectionDelete.js +++ b/app/scenes/CollectionDelete/CollectionDelete.js @@ -28,7 +28,6 @@ class CollectionDelete extends Component { const success = await this.props.collection.delete(); if (success) { - this.props.collections.remove(this.props.collection.id); this.props.history.push(homeUrl()); this.props.onSubmit(); } diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index b0ff12d4..790a2264 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -161,7 +161,7 @@ class DocumentScene extends React.Component { this.editCache = null; this.isSaving = true; this.isPublishing = publish; - document = await document.save(publish); + document = await document.save(publish, redirect); this.isSaving = false; this.isPublishing = false; diff --git a/app/scenes/Settings/Slack.js b/app/scenes/Settings/Slack.js index 894bc2d9..2e3eb8ab 100644 --- a/app/scenes/Settings/Slack.js +++ b/app/scenes/Settings/Slack.js @@ -1,34 +1,117 @@ // @flow import React, { Component } from 'react'; -import { observer } from 'mobx-react'; +import { inject, observer } from 'mobx-react'; +import _ from 'lodash'; import styled from 'styled-components'; +import Button from 'components/Button'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import HelpText from 'components/HelpText'; import SlackButton from './components/SlackButton'; +import CollectionsStore from 'stores/CollectionsStore'; +import IntegrationsStore from 'stores/IntegrationsStore'; + +type Props = { + collections: CollectionsStore, + integrations: IntegrationsStore, +}; @observer class Slack extends Component { + props: Props; + + componentDidMount() { + this.props.integrations.fetchPage(); + } + + get commandIntegration() { + return _.find(this.props.integrations.slackIntegrations, { + type: 'command', + }); + } + render() { + const { collections, integrations } = this.props; + return (

Slack

- Connect Outline to your Slack team to instantly search for documents - using the /outline command and preview Outline links. + Preview Outline links your team mates share and use the{' '} + /outline slash command in Slack to search for documents + in your teams wiki. + +

+ {this.commandIntegration ? ( + + ) : ( + + )} +

+

 

+ +

Collections

+ + Connect Outline collections to Slack channels and messages will be + posted in Slack when documents are published or updated. - + + {collections.orderedData.map(collection => { + const integration = _.find(integrations.slackIntegrations, { + collectionId: collection.id, + }); + + if (integration) { + return ( + + + {collection.name} posting activity to the{' '} + {integration.settings.channel} Slack + channel + + + + ); + } + + return ( + + {collection.name} + + + ); + })} +
); } } +const List = styled.ol` + list-style: none; + margin: 8px 0; + padding: 0; +`; + +const ListItem = styled.li` + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #eaebea; +`; + const Code = styled.code` padding: 4px 6px; margin: 0 2px; @@ -36,4 +119,4 @@ const Code = styled.code` border-radius: 4px; `; -export default Slack; +export default inject('collections', 'integrations')(Slack); diff --git a/app/scenes/Settings/components/SlackButton.js b/app/scenes/Settings/components/SlackButton.js index 9dcb63c5..241e6ed6 100644 --- a/app/scenes/Settings/components/SlackButton.js +++ b/app/scenes/Settings/components/SlackButton.js @@ -1,5 +1,5 @@ // @flow -import React from 'react'; +import * as React from 'react'; import styled from 'styled-components'; import { inject } from 'mobx-react'; import { slackAuth } from 'shared/utils/routeHelpers'; @@ -11,19 +11,27 @@ type Props = { auth: AuthStore, scopes?: string[], redirectUri?: string, + state?: string, + label?: string, }; -function SlackButton({ auth, scopes, redirectUri }: Props) { +function SlackButton({ auth, state, label, scopes, redirectUri }: Props) { const handleClick = () => (window.location.href = slackAuth( - auth.getOauthState(), + state ? auth.saveOauthState(state) : auth.genOauthState(), scopes, redirectUri )); return ( ); } diff --git a/app/scenes/SlackAuth/SlackAuth.js b/app/scenes/SlackAuth/SlackAuth.js index b4a45ec6..86194bcc 100644 --- a/app/scenes/SlackAuth/SlackAuth.js +++ b/app/scenes/SlackAuth/SlackAuth.js @@ -38,10 +38,21 @@ class SlackAuth extends React.Component { } } else if (code) { if (this.props.location.pathname === '/auth/slack/commands') { - // User adding webhook integrations + // incoming webhooks from Slack try { await client.post('/auth.slackCommands', { code }); - this.redirectTo = '/dashboard'; + this.redirectTo = '/settings/integrations/slack'; + } catch (e) { + this.redirectTo = '/auth/error'; + } + } else if (this.props.location.pathname === '/auth/slack/post') { + // outgoing webhooks to Slack + try { + await client.post('/auth.slackPost', { + code, + collectionId: this.props.auth.oauthState, + }); + this.redirectTo = '/settings/integrations/slack'; } catch (e) { this.redirectTo = '/auth/error'; } @@ -56,8 +67,8 @@ class SlackAuth extends React.Component { : (this.redirectTo = '/auth/error'); } } else { - // Sign In - window.location.href = slackAuth(this.props.auth.getOauthState()); + // signing in + window.location.href = slackAuth(this.props.auth.genOauthState()); } } diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js index 318e939c..da873043 100644 --- a/app/stores/AuthStore.js +++ b/app/stores/AuthStore.js @@ -63,7 +63,7 @@ class AuthStore { }; @action - getOauthState = () => { + genOauthState = () => { const state = Math.random() .toString(36) .substring(7); @@ -71,6 +71,12 @@ class AuthStore { return this.oauthState; }; + @action + saveOauthState = (state: string) => { + this.oauthState = state; + return this.oauthState; + }; + @action authWithSlack = async (code: string, state: string) => { // in the case of direct install from the Slack app store the state is diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index 919e96db..50b95445 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -5,9 +5,10 @@ import _ from 'lodash'; import invariant from 'invariant'; import stores from 'stores'; +import BaseStore from './BaseStore'; +import ErrorsStore from './ErrorsStore'; +import UiStore from './UiStore'; import Collection from 'models/Collection'; -import ErrorsStore from 'stores/ErrorsStore'; -import UiStore from 'stores/UiStore'; import naturalSort from 'shared/utils/naturalSort'; import type { PaginationParams } from 'types'; @@ -26,7 +27,7 @@ export type DocumentPath = DocumentPathItem & { path: DocumentPathItem[], }; -class CollectionsStore { +class CollectionsStore extends BaseStore { @observable data: Map = new ObservableMap([]); @observable isLoaded: boolean = false; @observable isFetching: boolean = false; @@ -154,8 +155,13 @@ class CollectionsStore { }; constructor(options: Options) { + super(); this.errors = stores.errors; this.ui = options.ui; + + this.on('collections.delete', (data: { id: string }) => { + this.remove(data.id); + }); } } diff --git a/app/stores/IntegrationsStore.js b/app/stores/IntegrationsStore.js new file mode 100644 index 00000000..02a1fd22 --- /dev/null +++ b/app/stores/IntegrationsStore.js @@ -0,0 +1,76 @@ +// @flow +import { observable, computed, action, runInAction, ObservableMap } from 'mobx'; +import { client } from 'utils/ApiClient'; +import _ from 'lodash'; +import invariant from 'invariant'; +import stores from './'; +import ErrorsStore from './ErrorsStore'; +import BaseStore from './BaseStore'; + +import Integration from 'models/Integration'; +import type { PaginationParams } from 'types'; + +class IntegrationsStore extends BaseStore { + @observable data: Map = new ObservableMap([]); + @observable isLoaded: boolean = false; + @observable isFetching: boolean = false; + + errors: ErrorsStore; + + @computed + get orderedData(): Integration[] { + return _.sortBy(this.data.values(), 'name'); + } + + @computed + get slackIntegrations(): Integration[] { + return _.filter(this.orderedData, { serviceId: 'slack' }); + } + + @action + fetchPage = async (options: ?PaginationParams): Promise<*> => { + this.isFetching = true; + + try { + const res = await client.post('/integrations.list', options); + invariant(res && res.data, 'Integrations list not available'); + const { data } = res; + runInAction('IntegrationsStore#fetchPage', () => { + data.forEach(integration => { + this.data.set(integration.id, new Integration(integration)); + }); + this.isLoaded = true; + }); + return res; + } catch (e) { + this.errors.add('Failed to load integrations'); + } finally { + this.isFetching = false; + } + }; + + @action + add = (data: Integration): void => { + this.data.set(data.id, data); + }; + + @action + remove = (id: string): void => { + this.data.delete(id); + }; + + getById = (id: string): ?Integration => { + return this.data.get(id); + }; + + constructor() { + super(); + this.errors = stores.errors; + + this.on('integrations.delete', (data: { id: string }) => { + this.remove(data.id); + }); + } +} + +export default IntegrationsStore; diff --git a/app/utils/importFile.js b/app/utils/importFile.js index 05b2118b..ccb1340c 100644 --- a/app/utils/importFile.js +++ b/app/utils/importFile.js @@ -29,7 +29,7 @@ const importFile = async ({ if (documentId) data.parentDocument = documentId; let document = new Document(data); - document = await document.save(); + document = await document.save(true); documents.add(document); resolve(document); }; diff --git a/server/api/auth.js b/server/api/auth.js index 7ad43a51..e520cb7d 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -2,7 +2,7 @@ import Router from 'koa-router'; import auth from './middlewares/authentication'; import { presentUser, presentTeam } from '../presenters'; -import { Authentication, User, Team } from '../models'; +import { Authentication, Integration, User, Team } from '../models'; import * as Slack from '../slack'; const router = new Router(); @@ -89,14 +89,56 @@ router.post('auth.slackCommands', auth(), async ctx => { const user = ctx.state.user; const endpoint = `${process.env.URL || ''}/auth/slack/commands`; const data = await Slack.oauthAccess(code, endpoint); + const serviceId = 'slack'; - await Authentication.create({ - serviceId: 'slack', + const authentication = await Authentication.create({ + serviceId, userId: user.id, teamId: user.teamId, token: data.access_token, scopes: data.scope.split(','), }); + + await Integration.create({ + serviceId, + type: 'command', + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + }); +}); + +router.post('auth.slackPost', auth(), async ctx => { + const { code, collectionId } = ctx.body; + ctx.assertPresent(code, 'code is required'); + + const user = ctx.state.user; + const endpoint = `${process.env.URL || ''}/auth/slack/post`; + const data = await Slack.oauthAccess(code, endpoint); + const serviceId = 'slack'; + + const authentication = await Authentication.create({ + serviceId, + userId: user.id, + teamId: user.teamId, + token: data.access_token, + scopes: data.scope.split(','), + }); + + await Integration.create({ + serviceId, + type: 'post', + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + collectionId, + events: [], + settings: { + url: data.incoming_webhook.url, + channel: data.incoming_webhook.channel, + channelId: data.incoming_webhook.channel_id, + }, + }); }); export default router; diff --git a/server/api/documents.js b/server/api/documents.js index 1a9c20df..8c9c9a44 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -5,7 +5,8 @@ import auth from './middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentDocument, presentRevision } from '../presenters'; import { Document, Collection, Star, View, Revision } from '../models'; -import { ValidationError, InvalidRequestError } from '../errors'; +import { InvalidRequestError } from '../errors'; +import events from '../events'; import policy from '../policies'; const { authorize } = policy; @@ -302,7 +303,6 @@ router.post('documents.create', auth(), async ctx => { authorize(user, 'read', parentDocumentObj); } - const publishedAt = publish === false ? null : new Date(); let document = await Document.create({ parentDocumentId: parentDocumentObj.id, atlasId: collection.id, @@ -310,20 +310,19 @@ router.post('documents.create', auth(), async ctx => { userId: user.id, lastModifiedById: user.id, createdById: user.id, - publishedAt, title, text, }); - if (publishedAt && collection.type === 'atlas') { - await collection.addDocumentToStructure(document, index); + if (publish) { + await document.publish(); } // reload to get all of the data needed to present (user, collection etc) // we need to specify publishedAt to bypass default scope that only returns // published documents document = await Document.find({ - where: { id: document.id, publishedAt }, + where: { id: document.id, publishedAt: document.publishedAt }, }); ctx.body = { @@ -332,7 +331,7 @@ router.post('documents.create', auth(), async ctx => { }); router.post('documents.update', auth(), async ctx => { - const { id, title, text, publish, lastRevision } = ctx.body; + const { id, title, text, publish, done, lastRevision } = ctx.body; ctx.assertPresent(id, 'id is required'); ctx.assertPresent(title || text, 'title or text is required'); @@ -346,24 +345,20 @@ router.post('documents.update', auth(), async ctx => { } // Update document - const previouslyPublished = !!document.publishedAt; - if (publish) document.publishedAt = new Date(); if (title) document.title = title; if (text) document.text = text; document.lastModifiedById = user.id; - await document.save(); - const collection = document.collection; - if (collection.type === 'atlas') { - if (previouslyPublished) { - await collection.updateDocument(document); - } else if (publish) { - await collection.addDocumentToStructure(document); + if (publish) { + await document.publish(); + } else { + await document.save(); + + if (document.publishedAt && done) { + events.add({ name: 'documents.update', model: document }); } } - document.collection = collection; - ctx.body = { data: await presentDocument(ctx, document), }; diff --git a/server/api/documents.test.js b/server/api/documents.test.js index bcd5abb6..8c8eb45d 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -385,12 +385,7 @@ describe('#documents.create', async () => { }, }); const body = await res.json(); - const newDocument = await Document.findOne({ - where: { - id: body.data.id, - }, - }); - + const newDocument = await Document.findById(body.data.id); expect(res.status).toEqual(200); expect(newDocument.parentDocumentId).toBe(null); expect(newDocument.collection.id).toBe(collection.id); diff --git a/server/api/hooks.js b/server/api/hooks.js index 962080e1..c59017d5 100644 --- a/server/api/hooks.js +++ b/server/api/hooks.js @@ -2,6 +2,7 @@ import Router from 'koa-router'; import { AuthenticationError, InvalidRequestError } from '../errors'; import { Authentication, Document, User } from '../models'; +import { presentSlackAttachment } from '../presenters'; import * as Slack from '../slack'; const router = new Router(); @@ -67,14 +68,7 @@ router.post('hooks.slack', async ctx => { if (documents.length) { const attachments = []; for (const document of documents) { - attachments.push({ - color: document.collection.color, - title: document.title, - title_link: `${process.env.URL}${document.getUrl()}`, - footer: document.collection.name, - text: document.getSummary(), - ts: document.getTimestamp(), - }); + attachments.push(presentSlackAttachment(document)); } ctx.body = { diff --git a/server/api/index.js b/server/api/index.js index da90ff11..45ae7c19 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -13,6 +13,7 @@ import views from './views'; import hooks from './hooks'; import apiKeys from './apiKeys'; import team from './team'; +import integrations from './integrations'; import validation from './middlewares/validation'; import methodOverride from '../middlewares/methodOverride'; @@ -74,6 +75,7 @@ router.use('/', views.routes()); router.use('/', hooks.routes()); router.use('/', apiKeys.routes()); router.use('/', team.routes()); +router.use('/', integrations.routes()); // Router is embedded in a Koa application wrapper, because koa-router does not // allow middleware to catch any routes which were not explicitly defined. diff --git a/server/api/integrations.js b/server/api/integrations.js new file mode 100644 index 00000000..56998aeb --- /dev/null +++ b/server/api/integrations.js @@ -0,0 +1,48 @@ +// @flow +import Router from 'koa-router'; +import Integration from '../models/Integration'; +import pagination from './middlewares/pagination'; +import auth from './middlewares/authentication'; +import { presentIntegration } from '../presenters'; +import policy from '../policies'; + +const { authorize } = policy; +const router = new Router(); + +router.post('integrations.list', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const integrations = await Integration.findAll({ + where: { teamId: user.teamId }, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const data = await Promise.all( + integrations.map(integration => presentIntegration(ctx, integration)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + }; +}); + +router.post('integrations.delete', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const integration = await Integration.findById(id); + authorize(ctx.state.user, 'delete', integration); + + await integration.destroy(); + + ctx.body = { + success: true, + }; +}); + +export default router; diff --git a/server/events.js b/server/events.js index d2a28d60..d66b3cce 100644 --- a/server/events.js +++ b/server/events.js @@ -1,22 +1,21 @@ // @flow import Queue from 'bull'; import debug from 'debug'; -import services from '../services'; +import services from './services'; import { Collection, Document } from './models'; type DocumentEvent = { - name: 'documents.create', + name: 'documents.create' | 'documents.update' | 'documents.publish', model: Document, }; type CollectionEvent = { - name: 'collections.create', + name: 'collections.create' | 'collections.update', model: Collection, }; export type Event = DocumentEvent | CollectionEvent; -const log = debug('events'); const globalEventsQueue = new Queue('global events', process.env.REDIS_URL); const serviceEventsQueue = new Queue('service events', process.env.REDIS_URL); @@ -37,7 +36,6 @@ serviceEventsQueue.process(async function(job) { const service = services[event.service]; if (service.on) { - log(`Triggering ${event.name} for ${service.name}`); service.on(event); } }); diff --git a/server/migrations/20180212033504-add-integrations.js b/server/migrations/20180212033504-add-integrations.js new file mode 100644 index 00000000..2a4ad2a5 --- /dev/null +++ b/server/migrations/20180212033504-add-integrations.js @@ -0,0 +1,67 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('integrations', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + type: { + type: Sequelize.STRING, + allowNull: true, + }, + userId: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + }, + }, + teamId: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'teams', + }, + }, + serviceId: { + type: Sequelize.STRING, + allowNull: false, + }, + collectionId: { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'collections', + }, + }, + authenticationId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'authentications', + }, + }, + events: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + }, + settings: { + type: Sequelize.JSONB, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('integrations'); + }, +}; diff --git a/server/models/Collection.test.js b/server/models/Collection.test.js index 715c6014..c70b6b16 100644 --- a/server/models/Collection.test.js +++ b/server/models/Collection.test.js @@ -1,6 +1,7 @@ /* eslint-disable flowtype/require-valid-file-annotation */ import { flushdb, seed } from '../test/support'; import { Collection, Document } from '../models'; +import uuid from 'uuid'; beforeEach(flushdb); beforeEach(jest.resetAllMocks); @@ -15,34 +16,37 @@ describe('#getUrl', () => { describe('#addDocumentToStructure', async () => { test('should add as last element without index', async () => { const { collection } = await seed(); + const id = uuid.v4(); const newDocument = new Document({ - id: '5', + id, title: 'New end node', parentDocumentId: null, }); await collection.addDocumentToStructure(newDocument); expect(collection.documentStructure.length).toBe(3); - expect(collection.documentStructure[2].id).toBe('5'); + expect(collection.documentStructure[2].id).toBe(id); }); test('should add with an index', async () => { const { collection } = await seed(); + const id = uuid.v4(); const newDocument = new Document({ - id: '5', + id, title: 'New end node', parentDocumentId: null, }); await collection.addDocumentToStructure(newDocument, 1); expect(collection.documentStructure.length).toBe(3); - expect(collection.documentStructure[1].id).toBe('5'); + expect(collection.documentStructure[1].id).toBe(id); }); test('should add as a child if with parent', async () => { const { collection, document } = await seed(); + const id = uuid.v4(); const newDocument = new Document({ - id: '5', + id, title: 'New end node', parentDocumentId: document.id, }); @@ -51,18 +55,19 @@ describe('#addDocumentToStructure', async () => { expect(collection.documentStructure.length).toBe(2); expect(collection.documentStructure[1].id).toBe(document.id); expect(collection.documentStructure[1].children.length).toBe(1); - expect(collection.documentStructure[1].children[0].id).toBe('5'); + expect(collection.documentStructure[1].children[0].id).toBe(id); }); test('should add as a child if with parent with index', async () => { const { collection, document } = await seed(); const newDocument = new Document({ - id: '5', + id: uuid.v4(), title: 'node', parentDocumentId: document.id, }); + const id = uuid.v4(); const secondDocument = new Document({ - id: '6', + id, title: 'New start node', parentDocumentId: document.id, }); @@ -72,14 +77,15 @@ describe('#addDocumentToStructure', async () => { expect(collection.documentStructure.length).toBe(2); expect(collection.documentStructure[1].id).toBe(document.id); expect(collection.documentStructure[1].children.length).toBe(2); - expect(collection.documentStructure[1].children[0].id).toBe('6'); + expect(collection.documentStructure[1].children[0].id).toBe(id); }); describe('options: documentJson', async () => { test("should append supplied json over document's own", async () => { const { collection } = await seed(); + const id = uuid.v4(); const newDocument = new Document({ - id: '5', + id: uuid.v4(), title: 'New end node', parentDocumentId: null, }); @@ -88,7 +94,7 @@ describe('#addDocumentToStructure', async () => { documentJson: { children: [ { - id: '7', + id, title: 'Totally fake', children: [], }, @@ -96,7 +102,7 @@ describe('#addDocumentToStructure', async () => { }, }); expect(collection.documentStructure[2].children.length).toBe(1); - expect(collection.documentStructure[2].children[0].id).toBe('7'); + expect(collection.documentStructure[2].children[0].id).toBe(id); }); }); }); diff --git a/server/models/Document.js b/server/models/Document.js index 878ac1d1..653f3666 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -7,6 +7,7 @@ import Plain from 'slate-plain-serializer'; import { Op } from 'sequelize'; import isUUID from 'validator/lib/isUUID'; +import { Collection } from '../models'; import { DataTypes, sequelize } from '../sequelize'; import events from '../events'; import parseTitle from '../../shared/utils/parseTitle'; @@ -107,6 +108,10 @@ Document.associate = models => { foreignKey: 'atlasId', onDelete: 'cascade', }); + Document.belongsTo(models.Team, { + as: 'team', + foreignKey: 'teamId', + }); Document.belongsTo(models.User, { as: 'createdBy', foreignKey: 'createdById', @@ -223,23 +228,51 @@ Document.searchForUser = async ( // Hooks -Document.addHook('afterCreate', model => - events.add({ name: 'documents.create', model }) -); +Document.addHook('beforeSave', async model => { + if (!model.publishedAt) return; + + const collection = await Collection.findById(model.atlasId); + if (collection.type !== 'atlas') return; + + await collection.updateDocument(model); + model.collection = collection; +}); + +Document.addHook('afterCreate', async model => { + if (!model.publishedAt) return; + + const collection = await Collection.findById(model.atlasId); + if (collection.type !== 'atlas') return; + + await collection.addDocumentToStructure(model); + model.collection = collection; + + events.add({ name: 'documents.create', model }); + return model; +}); Document.addHook('afterDestroy', model => events.add({ name: 'documents.delete', model }) ); -Document.addHook('afterUpdate', model => { - if (!model.previous('publishedAt') && model.publishedAt) { - events.add({ name: 'documents.publish', model }); - } - events.add({ name: 'documents.update', model }); -}); - // Instance methods +Document.prototype.publish = async function() { + if (this.publishedAt) return this.save(); + + const collection = await Collection.findById(this.atlasId); + if (collection.type !== 'atlas') return this.save(); + + await collection.addDocumentToStructure(this); + + this.publishedAt = new Date(); + await this.save(); + this.collection = collection; + + events.add({ name: 'documents.publish', model: this }); + return this; +}; + Document.prototype.getTimestamp = function() { return Math.round(new Date(this.updatedAt).getTime() / 1000); }; diff --git a/server/models/Integration.js b/server/models/Integration.js new file mode 100644 index 00000000..99b9aa32 --- /dev/null +++ b/server/models/Integration.js @@ -0,0 +1,35 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const Integration = sequelize.define('integration', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + type: DataTypes.STRING, + serviceId: DataTypes.STRING, + settings: DataTypes.JSONB, + events: DataTypes.ARRAY(DataTypes.STRING), +}); + +Integration.associate = models => { + Integration.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + }); + Integration.belongsTo(models.Team, { + as: 'team', + foreignKey: 'teamId', + }); + Integration.belongsTo(models.Collection, { + as: 'collection', + foreignKey: 'collectionId', + }); + Integration.belongsTo(models.Authentication, { + as: 'authentication', + foreignKey: 'authenticationId', + }); +}; + +export default Integration; diff --git a/server/models/User.test.js b/server/models/User.test.js index 8db8dd1c..9bdc13b6 100644 --- a/server/models/User.test.js +++ b/server/models/User.test.js @@ -1,10 +1,11 @@ /* eslint-disable flowtype/require-valid-file-annotation */ -import { flushdb, seed } from '../test/support'; +import { flushdb } from '../test/support'; +import { buildUser } from '../test/factories'; beforeEach(flushdb); it('should set JWT secret and password digest', async () => { - const { user } = await seed(); + const user = await buildUser({ password: 'test123!' }); expect(user.passwordDigest).toBeTruthy(); expect(user.getJwtToken()).toBeTruthy(); diff --git a/server/models/index.js b/server/models/index.js index 11c6f237..8c50e595 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,5 +1,6 @@ // @flow import Authentication from './Authentication'; +import Integration from './Integration'; import Event from './Event'; import User from './User'; import Team from './Team'; @@ -12,6 +13,7 @@ import Star from './Star'; const models = { Authentication, + Integration, Event, User, Team, @@ -32,6 +34,7 @@ Object.keys(models).forEach(modelName => { export { Authentication, + Integration, Event, User, Team, diff --git a/server/policies/index.js b/server/policies/index.js index 94289a00..b9ac4f5a 100644 --- a/server/policies/index.js +++ b/server/policies/index.js @@ -3,6 +3,7 @@ import policy from './policy'; import './apiKey'; import './collection'; import './document'; +import './integration'; import './user'; export default policy; diff --git a/server/policies/integration.js b/server/policies/integration.js new file mode 100644 index 00000000..5a94ed89 --- /dev/null +++ b/server/policies/integration.js @@ -0,0 +1,21 @@ +// @flow +import policy from './policy'; +import { Integration, User } from '../models'; +import { AdminRequiredError } from '../errors'; + +const { allow } = policy; + +allow(User, 'create', Integration); + +allow( + User, + 'read', + Integration, + (user, integration) => user.teamId === integration.teamId +); + +allow(User, ['update', 'delete'], Integration, (user, integration) => { + if (!integration || user.teamId !== integration.teamId) return false; + if (user.isAdmin) return true; + throw new AdminRequiredError(); +}); diff --git a/server/presenters/index.js b/server/presenters/index.js index 8eab4228..3ec608df 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -6,6 +6,8 @@ import presentRevision from './revision'; import presentCollection from './collection'; import presentApiKey from './apiKey'; import presentTeam from './team'; +import presentIntegration from './integration'; +import presentSlackAttachment from './slackAttachment'; export { presentUser, @@ -15,4 +17,6 @@ export { presentCollection, presentApiKey, presentTeam, + presentIntegration, + presentSlackAttachment, }; diff --git a/server/presenters/integration.js b/server/presenters/integration.js new file mode 100644 index 00000000..eb0099f3 --- /dev/null +++ b/server/presenters/integration.js @@ -0,0 +1,20 @@ +// @flow +import { Integration } from '../models'; + +function present(ctx: Object, integration: Integration) { + return { + id: integration.id, + type: integration.type, + userId: integration.userId, + teamId: integration.teamId, + serviceId: integration.serviceId, + collectionId: integration.collectionId, + authenticationId: integration.authenticationId, + events: integration.events, + settings: integration.settings, + createdAt: integration.createdAt, + updatedAt: integration.updatedAt, + }; +} + +export default present; diff --git a/server/presenters/revision.js b/server/presenters/revision.js index ef137767..b03eda18 100644 --- a/server/presenters/revision.js +++ b/server/presenters/revision.js @@ -1,5 +1,4 @@ // @flow -import _ from 'lodash'; import { Revision } from '../models'; function present(ctx: Object, revision: Revision) { diff --git a/server/presenters/slackAttachment.js b/server/presenters/slackAttachment.js new file mode 100644 index 00000000..d1f7c9f0 --- /dev/null +++ b/server/presenters/slackAttachment.js @@ -0,0 +1,15 @@ +// @flow +import { Document } from '../models'; + +function present(document: Document) { + return { + color: document.collection.color, + title: document.title, + title_link: `${process.env.URL}${document.getUrl()}`, + footer: document.collection.name, + text: document.getSummary(), + ts: document.getTimestamp(), + }; +} + +export default present; diff --git a/services/index.js b/server/services/index.js similarity index 100% rename from services/index.js rename to server/services/index.js diff --git a/server/services/slack/index.js b/server/services/slack/index.js new file mode 100644 index 00000000..d40f10b0 --- /dev/null +++ b/server/services/slack/index.js @@ -0,0 +1,43 @@ +// @flow +import type { Event } from '../../events'; +import { Document, Integration } from '../../models'; +import { presentSlackAttachment } from '../../presenters'; + +const Slack = { + on: async (event: Event) => { + if (event.name !== 'documents.publish' && event.name !== 'documents.update') + return; + + const document = await Document.findById(event.model.id); + if (!document) return; + + const integration = await Integration.findOne({ + where: { + teamId: document.teamId, + serviceId: 'slack', + collectionId: document.atlasId, + type: 'post', + }, + }); + if (!integration) return; + + let text = `${document.createdBy.name} published a new document`; + + if (event.name === 'documents.update') { + text = `${document.createdBy.name} updated a document`; + } + + await fetch(integration.settings.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text, + attachments: [presentSlackAttachment(document)], + }), + }); + }, +}; + +export default Slack; diff --git a/services/slack/package.json b/server/services/slack/package.json similarity index 100% rename from services/slack/package.json rename to server/services/slack/package.json diff --git a/server/test/support.js b/server/test/support.js index 4d6a5ee4..45ecf673 100644 --- a/server/test/support.js +++ b/server/test/support.js @@ -51,7 +51,7 @@ const seed = async () => { }, }); - let collection = await Collection.create({ + const collection = await Collection.create({ id: '26fde1d4-0050-428f-9f0b-0bf77f8bdf62', name: 'Collection', urlId: 'collection', @@ -71,12 +71,11 @@ const seed = async () => { title: 'Second document', text: '# Much guidance', }); - collection = await collection.addDocumentToStructure(document); return { user, admin, - collection, + collection: document.collection, document, team, }; diff --git a/services/slack/index.js b/services/slack/index.js deleted file mode 100644 index 60633117..00000000 --- a/services/slack/index.js +++ /dev/null @@ -1,10 +0,0 @@ -// @flow -import type { Event } from '../../server/events'; - -const Slack = { - on: (event: Event) => { - // console.log(`Slack service received ${event.name}, id: ${event.model.id}`); - }, -}; - -export default Slack;