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
* 💚
* Add documents.update trigger
* Authorization, tidying
* Fixed integration policy
* pick integration presenter keys
This commit is contained in:
parent
17900c6a11
commit
44cb509ebf
@ -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({
|
||||
|
@ -59,6 +59,7 @@ render(
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/auth/slack" component={SlackAuth} />
|
||||
<Route exact path="/auth/slack/commands" component={SlackAuth} />
|
||||
<Route exact path="/auth/slack/post" component={SlackAuth} />
|
||||
<Route exact path="/auth/error" component={ErrorAuth} />
|
||||
|
||||
<Auth>
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
|
57
app/models/Integration.js
Normal file
57
app/models/Integration.js
Normal file
@ -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;
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Slack" />
|
||||
<h1>Slack</h1>
|
||||
<HelpText>
|
||||
Connect Outline to your Slack team to instantly search for documents
|
||||
using the <Code>/outline</Code> command and preview Outline links.
|
||||
Preview Outline links your team mates share and use the{' '}
|
||||
<Code>/outline</Code> slash command in Slack to search for documents
|
||||
in your teams wiki.
|
||||
</HelpText>
|
||||
<p>
|
||||
{this.commandIntegration ? (
|
||||
<Button onClick={this.commandIntegration.delete}>Disconnect</Button>
|
||||
) : (
|
||||
<SlackButton
|
||||
scopes={['commands', 'links:read', 'links:write']}
|
||||
redirectUri={`${BASE_URL}/auth/slack/commands`}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
<h2>Collections</h2>
|
||||
<HelpText>
|
||||
Connect Outline collections to Slack channels and messages will be
|
||||
posted in Slack when documents are published or updated.
|
||||
</HelpText>
|
||||
|
||||
<SlackButton
|
||||
scopes={['commands', 'links:read', 'links:write']}
|
||||
redirectUri={`${BASE_URL}/auth/slack/commands`}
|
||||
/>
|
||||
<List>
|
||||
{collections.orderedData.map(collection => {
|
||||
const integration = _.find(integrations.slackIntegrations, {
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
if (integration) {
|
||||
return (
|
||||
<ListItem key={integration.id}>
|
||||
<span>
|
||||
<strong>{collection.name}</strong> posting activity to the{' '}
|
||||
<strong>{integration.settings.channel}</strong> Slack
|
||||
channel
|
||||
</span>
|
||||
<Button onClick={integration.delete}>Disconnect</Button>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem key={collection.id}>
|
||||
<strong>{collection.name}</strong>
|
||||
<SlackButton
|
||||
scopes={['incoming-webhook']}
|
||||
redirectUri={`${BASE_URL}/auth/slack/post`}
|
||||
state={collection.id}
|
||||
label="Connect"
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -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 (
|
||||
<Button onClick={handleClick} icon={<SpacedSlackLogo size={24} />} neutral>
|
||||
Add to <strong>Slack</strong>
|
||||
{label ? (
|
||||
label
|
||||
) : (
|
||||
<span>
|
||||
Add to <strong>Slack</strong>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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<string, Collection> = 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
76
app/stores/IntegrationsStore.js
Normal file
76
app/stores/IntegrationsStore.js
Normal file
@ -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<string, Integration> = 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;
|
@ -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);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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),
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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 = {
|
||||
|
@ -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.
|
||||
|
48
server/api/integrations.js
Normal file
48
server/api/integrations.js
Normal file
@ -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;
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
67
server/migrations/20180212033504-add-integrations.js
Normal file
67
server/migrations/20180212033504-add-integrations.js
Normal file
@ -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');
|
||||
},
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
};
|
||||
|
35
server/models/Integration.js
Normal file
35
server/models/Integration.js
Normal file
@ -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;
|
@ -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();
|
||||
|
||||
|
@ -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,
|
||||
|
@ -3,6 +3,7 @@ import policy from './policy';
|
||||
import './apiKey';
|
||||
import './collection';
|
||||
import './document';
|
||||
import './integration';
|
||||
import './user';
|
||||
|
||||
export default policy;
|
||||
|
21
server/policies/integration.js
Normal file
21
server/policies/integration.js
Normal file
@ -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();
|
||||
});
|
@ -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,
|
||||
};
|
||||
|
20
server/presenters/integration.js
Normal file
20
server/presenters/integration.js
Normal file
@ -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;
|
@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import _ from 'lodash';
|
||||
import { Revision } from '../models';
|
||||
|
||||
function present(ctx: Object, revision: Revision) {
|
||||
|
15
server/presenters/slackAttachment.js
Normal file
15
server/presenters/slackAttachment.js
Normal file
@ -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;
|
43
server/services/slack/index.js
Normal file
43
server/services/slack/index.js
Normal file
@ -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;
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
Reference in New Issue
Block a user