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:
@ -6,6 +6,7 @@ import ApiKeysStore from 'stores/ApiKeysStore';
|
|||||||
import UsersStore from 'stores/UsersStore';
|
import UsersStore from 'stores/UsersStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import IntegrationsStore from 'stores/IntegrationsStore';
|
||||||
import CacheStore from 'stores/CacheStore';
|
import CacheStore from 'stores/CacheStore';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -23,6 +24,7 @@ const Auth = ({ children }: Props) => {
|
|||||||
const { user, team } = stores.auth;
|
const { user, team } = stores.auth;
|
||||||
const cache = new CacheStore(user.id);
|
const cache = new CacheStore(user.id);
|
||||||
authenticatedStores = {
|
authenticatedStores = {
|
||||||
|
integrations: new IntegrationsStore(),
|
||||||
apiKeys: new ApiKeysStore(),
|
apiKeys: new ApiKeysStore(),
|
||||||
users: new UsersStore(),
|
users: new UsersStore(),
|
||||||
documents: new DocumentsStore({
|
documents: new DocumentsStore({
|
||||||
|
@ -59,6 +59,7 @@ render(
|
|||||||
<Route exact path="/" component={Home} />
|
<Route exact path="/" component={Home} />
|
||||||
<Route exact path="/auth/slack" component={SlackAuth} />
|
<Route exact path="/auth/slack" component={SlackAuth} />
|
||||||
<Route exact path="/auth/slack/commands" 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} />
|
<Route exact path="/auth/error" component={ErrorAuth} />
|
||||||
|
|
||||||
<Auth>
|
<Auth>
|
||||||
|
@ -109,9 +109,7 @@ class Collection extends BaseModel {
|
|||||||
delete = async () => {
|
delete = async () => {
|
||||||
try {
|
try {
|
||||||
await client.post('/collections.delete', { id: this.id });
|
await client.post('/collections.delete', { id: this.id });
|
||||||
this.emit('collections.delete', {
|
this.emit('collections.delete', { id: this.id });
|
||||||
id: this.id,
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errors.add('Collection failed to delete');
|
this.errors.add('Collection failed to delete');
|
||||||
|
@ -168,7 +168,7 @@ class Document extends BaseModel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
save = async (publish: boolean = false) => {
|
save = async (publish: boolean = false, done: boolean = false) => {
|
||||||
if (this.isSaving) return this;
|
if (this.isSaving) return this;
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
|
|
||||||
@ -181,6 +181,7 @@ class Document extends BaseModel {
|
|||||||
text: this.text,
|
text: this.text,
|
||||||
lastRevision: this.revision,
|
lastRevision: this.revision,
|
||||||
publish,
|
publish,
|
||||||
|
done,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const data = {
|
const data = {
|
||||||
@ -189,6 +190,7 @@ class Document extends BaseModel {
|
|||||||
title: this.title,
|
title: this.title,
|
||||||
text: this.text,
|
text: this.text,
|
||||||
publish,
|
publish,
|
||||||
|
done,
|
||||||
};
|
};
|
||||||
if (this.parentDocument) {
|
if (this.parentDocument) {
|
||||||
data.parentDocument = 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();
|
const success = await this.props.collection.delete();
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
this.props.collections.remove(this.props.collection.id);
|
|
||||||
this.props.history.push(homeUrl());
|
this.props.history.push(homeUrl());
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
}
|
}
|
||||||
|
@ -161,7 +161,7 @@ class DocumentScene extends React.Component {
|
|||||||
this.editCache = null;
|
this.editCache = null;
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.isPublishing = publish;
|
this.isPublishing = publish;
|
||||||
document = await document.save(publish);
|
document = await document.save(publish, redirect);
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
this.isPublishing = false;
|
this.isPublishing = false;
|
||||||
|
|
||||||
|
@ -1,34 +1,117 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React, { Component } from 'react';
|
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 styled from 'styled-components';
|
||||||
|
|
||||||
|
import Button from 'components/Button';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
import SlackButton from './components/SlackButton';
|
import SlackButton from './components/SlackButton';
|
||||||
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import IntegrationsStore from 'stores/IntegrationsStore';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
collections: CollectionsStore,
|
||||||
|
integrations: IntegrationsStore,
|
||||||
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Slack extends Component {
|
class Slack extends Component {
|
||||||
|
props: Props;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.integrations.fetchPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
get commandIntegration() {
|
||||||
|
return _.find(this.props.integrations.slackIntegrations, {
|
||||||
|
type: 'command',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { collections, integrations } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<PageTitle title="Slack" />
|
<PageTitle title="Slack" />
|
||||||
<h1>Slack</h1>
|
<h1>Slack</h1>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Connect Outline to your Slack team to instantly search for documents
|
Preview Outline links your team mates share and use the{' '}
|
||||||
using the <Code>/outline</Code> command and preview Outline links.
|
<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>
|
</HelpText>
|
||||||
|
|
||||||
<SlackButton
|
<List>
|
||||||
scopes={['commands', 'links:read', 'links:write']}
|
{collections.orderedData.map(collection => {
|
||||||
redirectUri={`${BASE_URL}/auth/slack/commands`}
|
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>
|
</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`
|
const Code = styled.code`
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
@ -36,4 +119,4 @@ const Code = styled.code`
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Slack;
|
export default inject('collections', 'integrations')(Slack);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import * as React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { inject } from 'mobx-react';
|
import { inject } from 'mobx-react';
|
||||||
import { slackAuth } from 'shared/utils/routeHelpers';
|
import { slackAuth } from 'shared/utils/routeHelpers';
|
||||||
@ -11,19 +11,27 @@ type Props = {
|
|||||||
auth: AuthStore,
|
auth: AuthStore,
|
||||||
scopes?: string[],
|
scopes?: string[],
|
||||||
redirectUri?: string,
|
redirectUri?: string,
|
||||||
|
state?: string,
|
||||||
|
label?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
function SlackButton({ auth, scopes, redirectUri }: Props) {
|
function SlackButton({ auth, state, label, scopes, redirectUri }: Props) {
|
||||||
const handleClick = () =>
|
const handleClick = () =>
|
||||||
(window.location.href = slackAuth(
|
(window.location.href = slackAuth(
|
||||||
auth.getOauthState(),
|
state ? auth.saveOauthState(state) : auth.genOauthState(),
|
||||||
scopes,
|
scopes,
|
||||||
redirectUri
|
redirectUri
|
||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={handleClick} icon={<SpacedSlackLogo size={24} />} neutral>
|
<Button onClick={handleClick} icon={<SpacedSlackLogo size={24} />} neutral>
|
||||||
Add to <strong>Slack</strong>
|
{label ? (
|
||||||
|
label
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Add to <strong>Slack</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -38,10 +38,21 @@ class SlackAuth extends React.Component {
|
|||||||
}
|
}
|
||||||
} else if (code) {
|
} else if (code) {
|
||||||
if (this.props.location.pathname === '/auth/slack/commands') {
|
if (this.props.location.pathname === '/auth/slack/commands') {
|
||||||
// User adding webhook integrations
|
// incoming webhooks from Slack
|
||||||
try {
|
try {
|
||||||
await client.post('/auth.slackCommands', { code });
|
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) {
|
} catch (e) {
|
||||||
this.redirectTo = '/auth/error';
|
this.redirectTo = '/auth/error';
|
||||||
}
|
}
|
||||||
@ -56,8 +67,8 @@ class SlackAuth extends React.Component {
|
|||||||
: (this.redirectTo = '/auth/error');
|
: (this.redirectTo = '/auth/error');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Sign In
|
// signing in
|
||||||
window.location.href = slackAuth(this.props.auth.getOauthState());
|
window.location.href = slackAuth(this.props.auth.genOauthState());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ class AuthStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
getOauthState = () => {
|
genOauthState = () => {
|
||||||
const state = Math.random()
|
const state = Math.random()
|
||||||
.toString(36)
|
.toString(36)
|
||||||
.substring(7);
|
.substring(7);
|
||||||
@ -71,6 +71,12 @@ class AuthStore {
|
|||||||
return this.oauthState;
|
return this.oauthState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
saveOauthState = (state: string) => {
|
||||||
|
this.oauthState = state;
|
||||||
|
return this.oauthState;
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
authWithSlack = async (code: string, state: string) => {
|
authWithSlack = async (code: string, state: string) => {
|
||||||
// in the case of direct install from the Slack app store the state is
|
// 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 invariant from 'invariant';
|
||||||
|
|
||||||
import stores from 'stores';
|
import stores from 'stores';
|
||||||
|
import BaseStore from './BaseStore';
|
||||||
|
import ErrorsStore from './ErrorsStore';
|
||||||
|
import UiStore from './UiStore';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
import ErrorsStore from 'stores/ErrorsStore';
|
|
||||||
import UiStore from 'stores/UiStore';
|
|
||||||
import naturalSort from 'shared/utils/naturalSort';
|
import naturalSort from 'shared/utils/naturalSort';
|
||||||
import type { PaginationParams } from 'types';
|
import type { PaginationParams } from 'types';
|
||||||
|
|
||||||
@ -26,7 +27,7 @@ export type DocumentPath = DocumentPathItem & {
|
|||||||
path: DocumentPathItem[],
|
path: DocumentPathItem[],
|
||||||
};
|
};
|
||||||
|
|
||||||
class CollectionsStore {
|
class CollectionsStore extends BaseStore {
|
||||||
@observable data: Map<string, Collection> = new ObservableMap([]);
|
@observable data: Map<string, Collection> = new ObservableMap([]);
|
||||||
@observable isLoaded: boolean = false;
|
@observable isLoaded: boolean = false;
|
||||||
@observable isFetching: boolean = false;
|
@observable isFetching: boolean = false;
|
||||||
@ -154,8 +155,13 @@ class CollectionsStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constructor(options: Options) {
|
constructor(options: Options) {
|
||||||
|
super();
|
||||||
this.errors = stores.errors;
|
this.errors = stores.errors;
|
||||||
this.ui = options.ui;
|
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;
|
if (documentId) data.parentDocument = documentId;
|
||||||
|
|
||||||
let document = new Document(data);
|
let document = new Document(data);
|
||||||
document = await document.save();
|
document = await document.save(true);
|
||||||
documents.add(document);
|
documents.add(document);
|
||||||
resolve(document);
|
resolve(document);
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import auth from './middlewares/authentication';
|
import auth from './middlewares/authentication';
|
||||||
import { presentUser, presentTeam } from '../presenters';
|
import { presentUser, presentTeam } from '../presenters';
|
||||||
import { Authentication, User, Team } from '../models';
|
import { Authentication, Integration, User, Team } from '../models';
|
||||||
import * as Slack from '../slack';
|
import * as Slack from '../slack';
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@ -89,14 +89,56 @@ router.post('auth.slackCommands', auth(), async ctx => {
|
|||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const endpoint = `${process.env.URL || ''}/auth/slack/commands`;
|
const endpoint = `${process.env.URL || ''}/auth/slack/commands`;
|
||||||
const data = await Slack.oauthAccess(code, endpoint);
|
const data = await Slack.oauthAccess(code, endpoint);
|
||||||
|
const serviceId = 'slack';
|
||||||
|
|
||||||
await Authentication.create({
|
const authentication = await Authentication.create({
|
||||||
serviceId: 'slack',
|
serviceId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
token: data.access_token,
|
token: data.access_token,
|
||||||
scopes: data.scope.split(','),
|
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;
|
export default router;
|
||||||
|
@ -5,7 +5,8 @@ import auth from './middlewares/authentication';
|
|||||||
import pagination from './middlewares/pagination';
|
import pagination from './middlewares/pagination';
|
||||||
import { presentDocument, presentRevision } from '../presenters';
|
import { presentDocument, presentRevision } from '../presenters';
|
||||||
import { Document, Collection, Star, View, Revision } from '../models';
|
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';
|
import policy from '../policies';
|
||||||
|
|
||||||
const { authorize } = policy;
|
const { authorize } = policy;
|
||||||
@ -302,7 +303,6 @@ router.post('documents.create', auth(), async ctx => {
|
|||||||
authorize(user, 'read', parentDocumentObj);
|
authorize(user, 'read', parentDocumentObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishedAt = publish === false ? null : new Date();
|
|
||||||
let document = await Document.create({
|
let document = await Document.create({
|
||||||
parentDocumentId: parentDocumentObj.id,
|
parentDocumentId: parentDocumentObj.id,
|
||||||
atlasId: collection.id,
|
atlasId: collection.id,
|
||||||
@ -310,20 +310,19 @@ router.post('documents.create', auth(), async ctx => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
lastModifiedById: user.id,
|
lastModifiedById: user.id,
|
||||||
createdById: user.id,
|
createdById: user.id,
|
||||||
publishedAt,
|
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (publishedAt && collection.type === 'atlas') {
|
if (publish) {
|
||||||
await collection.addDocumentToStructure(document, index);
|
await document.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// reload to get all of the data needed to present (user, collection etc)
|
// 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
|
// we need to specify publishedAt to bypass default scope that only returns
|
||||||
// published documents
|
// published documents
|
||||||
document = await Document.find({
|
document = await Document.find({
|
||||||
where: { id: document.id, publishedAt },
|
where: { id: document.id, publishedAt: document.publishedAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
@ -332,7 +331,7 @@ router.post('documents.create', auth(), async ctx => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('documents.update', 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(id, 'id is required');
|
||||||
ctx.assertPresent(title || text, 'title or text is required');
|
ctx.assertPresent(title || text, 'title or text is required');
|
||||||
|
|
||||||
@ -346,24 +345,20 @@ router.post('documents.update', auth(), async ctx => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update document
|
// Update document
|
||||||
const previouslyPublished = !!document.publishedAt;
|
|
||||||
if (publish) document.publishedAt = new Date();
|
|
||||||
if (title) document.title = title;
|
if (title) document.title = title;
|
||||||
if (text) document.text = text;
|
if (text) document.text = text;
|
||||||
document.lastModifiedById = user.id;
|
document.lastModifiedById = user.id;
|
||||||
|
|
||||||
await document.save();
|
if (publish) {
|
||||||
const collection = document.collection;
|
await document.publish();
|
||||||
if (collection.type === 'atlas') {
|
} else {
|
||||||
if (previouslyPublished) {
|
await document.save();
|
||||||
await collection.updateDocument(document);
|
|
||||||
} else if (publish) {
|
if (document.publishedAt && done) {
|
||||||
await collection.addDocumentToStructure(document);
|
events.add({ name: 'documents.update', model: document });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.collection = collection;
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(ctx, document),
|
||||||
};
|
};
|
||||||
|
@ -385,12 +385,7 @@ describe('#documents.create', async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
const newDocument = await Document.findOne({
|
const newDocument = await Document.findById(body.data.id);
|
||||||
where: {
|
|
||||||
id: body.data.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(newDocument.parentDocumentId).toBe(null);
|
expect(newDocument.parentDocumentId).toBe(null);
|
||||||
expect(newDocument.collection.id).toBe(collection.id);
|
expect(newDocument.collection.id).toBe(collection.id);
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import { AuthenticationError, InvalidRequestError } from '../errors';
|
import { AuthenticationError, InvalidRequestError } from '../errors';
|
||||||
import { Authentication, Document, User } from '../models';
|
import { Authentication, Document, User } from '../models';
|
||||||
|
import { presentSlackAttachment } from '../presenters';
|
||||||
import * as Slack from '../slack';
|
import * as Slack from '../slack';
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
@ -67,14 +68,7 @@ router.post('hooks.slack', async ctx => {
|
|||||||
if (documents.length) {
|
if (documents.length) {
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
for (const document of documents) {
|
for (const document of documents) {
|
||||||
attachments.push({
|
attachments.push(presentSlackAttachment(document));
|
||||||
color: document.collection.color,
|
|
||||||
title: document.title,
|
|
||||||
title_link: `${process.env.URL}${document.getUrl()}`,
|
|
||||||
footer: document.collection.name,
|
|
||||||
text: document.getSummary(),
|
|
||||||
ts: document.getTimestamp(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -13,6 +13,7 @@ import views from './views';
|
|||||||
import hooks from './hooks';
|
import hooks from './hooks';
|
||||||
import apiKeys from './apiKeys';
|
import apiKeys from './apiKeys';
|
||||||
import team from './team';
|
import team from './team';
|
||||||
|
import integrations from './integrations';
|
||||||
|
|
||||||
import validation from './middlewares/validation';
|
import validation from './middlewares/validation';
|
||||||
import methodOverride from '../middlewares/methodOverride';
|
import methodOverride from '../middlewares/methodOverride';
|
||||||
@ -74,6 +75,7 @@ router.use('/', views.routes());
|
|||||||
router.use('/', hooks.routes());
|
router.use('/', hooks.routes());
|
||||||
router.use('/', apiKeys.routes());
|
router.use('/', apiKeys.routes());
|
||||||
router.use('/', team.routes());
|
router.use('/', team.routes());
|
||||||
|
router.use('/', integrations.routes());
|
||||||
|
|
||||||
// Router is embedded in a Koa application wrapper, because koa-router does not
|
// Router is embedded in a Koa application wrapper, because koa-router does not
|
||||||
// allow middleware to catch any routes which were not explicitly defined.
|
// 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
|
// @flow
|
||||||
import Queue from 'bull';
|
import Queue from 'bull';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import services from '../services';
|
import services from './services';
|
||||||
import { Collection, Document } from './models';
|
import { Collection, Document } from './models';
|
||||||
|
|
||||||
type DocumentEvent = {
|
type DocumentEvent = {
|
||||||
name: 'documents.create',
|
name: 'documents.create' | 'documents.update' | 'documents.publish',
|
||||||
model: Document,
|
model: Document,
|
||||||
};
|
};
|
||||||
|
|
||||||
type CollectionEvent = {
|
type CollectionEvent = {
|
||||||
name: 'collections.create',
|
name: 'collections.create' | 'collections.update',
|
||||||
model: Collection,
|
model: Collection,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Event = DocumentEvent | CollectionEvent;
|
export type Event = DocumentEvent | CollectionEvent;
|
||||||
|
|
||||||
const log = debug('events');
|
|
||||||
const globalEventsQueue = new Queue('global events', process.env.REDIS_URL);
|
const globalEventsQueue = new Queue('global events', process.env.REDIS_URL);
|
||||||
const serviceEventsQueue = new Queue('service 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];
|
const service = services[event.service];
|
||||||
|
|
||||||
if (service.on) {
|
if (service.on) {
|
||||||
log(`Triggering ${event.name} for ${service.name}`);
|
|
||||||
service.on(event);
|
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 */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { Collection, Document } from '../models';
|
import { Collection, Document } from '../models';
|
||||||
|
import uuid from 'uuid';
|
||||||
|
|
||||||
beforeEach(flushdb);
|
beforeEach(flushdb);
|
||||||
beforeEach(jest.resetAllMocks);
|
beforeEach(jest.resetAllMocks);
|
||||||
@ -15,34 +16,37 @@ describe('#getUrl', () => {
|
|||||||
describe('#addDocumentToStructure', async () => {
|
describe('#addDocumentToStructure', async () => {
|
||||||
test('should add as last element without index', async () => {
|
test('should add as last element without index', async () => {
|
||||||
const { collection } = await seed();
|
const { collection } = await seed();
|
||||||
|
const id = uuid.v4();
|
||||||
const newDocument = new Document({
|
const newDocument = new Document({
|
||||||
id: '5',
|
id,
|
||||||
title: 'New end node',
|
title: 'New end node',
|
||||||
parentDocumentId: null,
|
parentDocumentId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await collection.addDocumentToStructure(newDocument);
|
await collection.addDocumentToStructure(newDocument);
|
||||||
expect(collection.documentStructure.length).toBe(3);
|
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 () => {
|
test('should add with an index', async () => {
|
||||||
const { collection } = await seed();
|
const { collection } = await seed();
|
||||||
|
const id = uuid.v4();
|
||||||
const newDocument = new Document({
|
const newDocument = new Document({
|
||||||
id: '5',
|
id,
|
||||||
title: 'New end node',
|
title: 'New end node',
|
||||||
parentDocumentId: null,
|
parentDocumentId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await collection.addDocumentToStructure(newDocument, 1);
|
await collection.addDocumentToStructure(newDocument, 1);
|
||||||
expect(collection.documentStructure.length).toBe(3);
|
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 () => {
|
test('should add as a child if with parent', async () => {
|
||||||
const { collection, document } = await seed();
|
const { collection, document } = await seed();
|
||||||
|
const id = uuid.v4();
|
||||||
const newDocument = new Document({
|
const newDocument = new Document({
|
||||||
id: '5',
|
id,
|
||||||
title: 'New end node',
|
title: 'New end node',
|
||||||
parentDocumentId: document.id,
|
parentDocumentId: document.id,
|
||||||
});
|
});
|
||||||
@ -51,18 +55,19 @@ describe('#addDocumentToStructure', async () => {
|
|||||||
expect(collection.documentStructure.length).toBe(2);
|
expect(collection.documentStructure.length).toBe(2);
|
||||||
expect(collection.documentStructure[1].id).toBe(document.id);
|
expect(collection.documentStructure[1].id).toBe(document.id);
|
||||||
expect(collection.documentStructure[1].children.length).toBe(1);
|
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 () => {
|
test('should add as a child if with parent with index', async () => {
|
||||||
const { collection, document } = await seed();
|
const { collection, document } = await seed();
|
||||||
const newDocument = new Document({
|
const newDocument = new Document({
|
||||||
id: '5',
|
id: uuid.v4(),
|
||||||
title: 'node',
|
title: 'node',
|
||||||
parentDocumentId: document.id,
|
parentDocumentId: document.id,
|
||||||
});
|
});
|
||||||
|
const id = uuid.v4();
|
||||||
const secondDocument = new Document({
|
const secondDocument = new Document({
|
||||||
id: '6',
|
id,
|
||||||
title: 'New start node',
|
title: 'New start node',
|
||||||
parentDocumentId: document.id,
|
parentDocumentId: document.id,
|
||||||
});
|
});
|
||||||
@ -72,14 +77,15 @@ describe('#addDocumentToStructure', async () => {
|
|||||||
expect(collection.documentStructure.length).toBe(2);
|
expect(collection.documentStructure.length).toBe(2);
|
||||||
expect(collection.documentStructure[1].id).toBe(document.id);
|
expect(collection.documentStructure[1].id).toBe(document.id);
|
||||||
expect(collection.documentStructure[1].children.length).toBe(2);
|
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 () => {
|
describe('options: documentJson', async () => {
|
||||||
test("should append supplied json over document's own", async () => {
|
test("should append supplied json over document's own", async () => {
|
||||||
const { collection } = await seed();
|
const { collection } = await seed();
|
||||||
|
const id = uuid.v4();
|
||||||
const newDocument = new Document({
|
const newDocument = new Document({
|
||||||
id: '5',
|
id: uuid.v4(),
|
||||||
title: 'New end node',
|
title: 'New end node',
|
||||||
parentDocumentId: null,
|
parentDocumentId: null,
|
||||||
});
|
});
|
||||||
@ -88,7 +94,7 @@ describe('#addDocumentToStructure', async () => {
|
|||||||
documentJson: {
|
documentJson: {
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: '7',
|
id,
|
||||||
title: 'Totally fake',
|
title: 'Totally fake',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
@ -96,7 +102,7 @@ describe('#addDocumentToStructure', async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(collection.documentStructure[2].children.length).toBe(1);
|
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 { Op } from 'sequelize';
|
||||||
|
|
||||||
import isUUID from 'validator/lib/isUUID';
|
import isUUID from 'validator/lib/isUUID';
|
||||||
|
import { Collection } from '../models';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
import events from '../events';
|
import events from '../events';
|
||||||
import parseTitle from '../../shared/utils/parseTitle';
|
import parseTitle from '../../shared/utils/parseTitle';
|
||||||
@ -107,6 +108,10 @@ Document.associate = models => {
|
|||||||
foreignKey: 'atlasId',
|
foreignKey: 'atlasId',
|
||||||
onDelete: 'cascade',
|
onDelete: 'cascade',
|
||||||
});
|
});
|
||||||
|
Document.belongsTo(models.Team, {
|
||||||
|
as: 'team',
|
||||||
|
foreignKey: 'teamId',
|
||||||
|
});
|
||||||
Document.belongsTo(models.User, {
|
Document.belongsTo(models.User, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
foreignKey: 'createdById',
|
foreignKey: 'createdById',
|
||||||
@ -223,23 +228,51 @@ Document.searchForUser = async (
|
|||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
|
|
||||||
Document.addHook('afterCreate', model =>
|
Document.addHook('beforeSave', async model => {
|
||||||
events.add({ name: 'documents.create', 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 =>
|
Document.addHook('afterDestroy', model =>
|
||||||
events.add({ name: 'documents.delete', 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
|
// 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() {
|
Document.prototype.getTimestamp = function() {
|
||||||
return Math.round(new Date(this.updatedAt).getTime() / 1000);
|
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 */
|
/* 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);
|
beforeEach(flushdb);
|
||||||
|
|
||||||
it('should set JWT secret and password digest', async () => {
|
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.passwordDigest).toBeTruthy();
|
||||||
expect(user.getJwtToken()).toBeTruthy();
|
expect(user.getJwtToken()).toBeTruthy();
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import Authentication from './Authentication';
|
import Authentication from './Authentication';
|
||||||
|
import Integration from './Integration';
|
||||||
import Event from './Event';
|
import Event from './Event';
|
||||||
import User from './User';
|
import User from './User';
|
||||||
import Team from './Team';
|
import Team from './Team';
|
||||||
@ -12,6 +13,7 @@ import Star from './Star';
|
|||||||
|
|
||||||
const models = {
|
const models = {
|
||||||
Authentication,
|
Authentication,
|
||||||
|
Integration,
|
||||||
Event,
|
Event,
|
||||||
User,
|
User,
|
||||||
Team,
|
Team,
|
||||||
@ -32,6 +34,7 @@ Object.keys(models).forEach(modelName => {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Authentication,
|
Authentication,
|
||||||
|
Integration,
|
||||||
Event,
|
Event,
|
||||||
User,
|
User,
|
||||||
Team,
|
Team,
|
||||||
|
@ -3,6 +3,7 @@ import policy from './policy';
|
|||||||
import './apiKey';
|
import './apiKey';
|
||||||
import './collection';
|
import './collection';
|
||||||
import './document';
|
import './document';
|
||||||
|
import './integration';
|
||||||
import './user';
|
import './user';
|
||||||
|
|
||||||
export default policy;
|
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 presentCollection from './collection';
|
||||||
import presentApiKey from './apiKey';
|
import presentApiKey from './apiKey';
|
||||||
import presentTeam from './team';
|
import presentTeam from './team';
|
||||||
|
import presentIntegration from './integration';
|
||||||
|
import presentSlackAttachment from './slackAttachment';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
presentUser,
|
presentUser,
|
||||||
@ -15,4 +17,6 @@ export {
|
|||||||
presentCollection,
|
presentCollection,
|
||||||
presentApiKey,
|
presentApiKey,
|
||||||
presentTeam,
|
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
|
// @flow
|
||||||
import _ from 'lodash';
|
|
||||||
import { Revision } from '../models';
|
import { Revision } from '../models';
|
||||||
|
|
||||||
function present(ctx: Object, revision: Revision) {
|
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',
|
id: '26fde1d4-0050-428f-9f0b-0bf77f8bdf62',
|
||||||
name: 'Collection',
|
name: 'Collection',
|
||||||
urlId: 'collection',
|
urlId: 'collection',
|
||||||
@ -71,12 +71,11 @@ const seed = async () => {
|
|||||||
title: 'Second document',
|
title: 'Second document',
|
||||||
text: '# Much guidance',
|
text: '# Much guidance',
|
||||||
});
|
});
|
||||||
collection = await collection.addDocumentToStructure(document);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
admin,
|
admin,
|
||||||
collection,
|
collection: document.collection,
|
||||||
document,
|
document,
|
||||||
team,
|
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