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
38 changed files with 665 additions and 105 deletions

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;

22
server/services/index.js Normal file
View File

@@ -0,0 +1,22 @@
// @flow
import fs from 'fs-extra';
import path from 'path';
const services = {};
fs
.readdirSync(__dirname)
.filter(file => file.indexOf('.') !== 0 && file !== path.basename(__filename))
.forEach(name => {
const servicePath = path.join(__dirname, name);
// $FlowIssue
const pkg = require(path.join(servicePath, 'package.json'));
// $FlowIssue
const hooks = require(servicePath).default;
services[pkg.name] = {
...pkg,
...hooks,
};
});
export default services;

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

@@ -0,0 +1,4 @@
{
"name": "slack",
"description": "Hook up your Slack to your Outline."
}

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