Merge branch 'master' of github.com:outline/outline into refactor-editor

This commit is contained in:
Tom Moor
2018-04-28 16:12:26 -07:00
39 changed files with 677 additions and 120 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

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

@@ -55,6 +55,15 @@ function Home() {
</Feature>
</Features>
<Highlights id="features">
<Feature size={{ desktop: 1 / 3 }}>
<h2>Slack integration</h2>
<p>
Keep your team up to date and informed with Slack notifications
about newly published documents. You can also search Outline
directly within Slack using <code>/outline &lt;keyword&gt;</code>{' '}
command.
</p>
</Feature>
<Feature size={{ desktop: 1 / 3 }}>
<h2>Open Source</h2>
<p>
@@ -69,26 +78,14 @@ function Home() {
<Feature size={{ desktop: 1 / 3 }}>
<h2>Integrations &amp; API</h2>
<p>
All of Outlines functionality is available through the API. The
editor itself is built on React and were working on making it
pluggable and extensible.
All of Outlines functionality is available through the API.
Migrating Markdown documents or setting up automations is a breeze
with a few lines of code.
</p>
<p>
<a href={developers()}>Documentation</a>
</p>
</Feature>
<Feature size={{ desktop: 1 / 3 }}>
<h2>Powerful Search</h2>
<p>
Outline includes a super fast search thats the best way to find
what youre looking for once your knowledge base starts to grow in
size.
</p>
<p>
Search directly within Slack using{' '}
<code>/outline &lt;keyword&gt;</code> command.
</p>
</Feature>
</Highlights>
<Footer>
<h2>Create an account</h2>

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;