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:
Tom Moor 2018-04-03 20:36:25 -07:00 committed by GitHub
parent 17900c6a11
commit 44cb509ebf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 665 additions and 105 deletions

View File

@ -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({

View File

@ -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>

View File

@ -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');

View File

@ -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
View 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;

View File

@ -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();
}

View File

@ -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;

View File

@ -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>&nbsp;</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);

View File

@ -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>
);
}

View File

@ -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());
}
}

View File

@ -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

View File

@ -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);
});
}
}

View 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;

View File

@ -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);
};

View File

@ -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;

View File

@ -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),
};

View File

@ -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);

View File

@ -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 = {

View File

@ -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.

View 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;

View File

@ -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);
}
});

View 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');
},
};

View File

@ -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);
});
});
});

View File

@ -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);
};

View 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;

View File

@ -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();

View File

@ -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,

View File

@ -3,6 +3,7 @@ import policy from './policy';
import './apiKey';
import './collection';
import './document';
import './integration';
import './user';
export default policy;

View 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();
});

View File

@ -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,
};

View 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;

View File

@ -1,5 +1,4 @@
// @flow
import _ from 'lodash';
import { Revision } from '../models';
function present(ctx: Object, revision: Revision) {

View 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;

View 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;

View File

@ -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,
};

View File

@ -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;