diff --git a/.env.sample b/.env.sample index fdef2cc4..5b27a8e5 100644 --- a/.env.sample +++ b/.env.sample @@ -12,6 +12,7 @@ REDIS_URL=redis://redis:6379 URL=http://localhost:3000 DEPLOYMENT=hosted ENABLE_UPDATES=true +DEBUG=sql,cache,presenters,events # Third party credentials (required) SLACK_KEY=71315967491.XXXXXXXXXX diff --git a/README.md b/README.md index 85a5a371..f08a5601 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ In development you can quickly get an environment running using Docker by follow To enable debugging statements, set the following env vars: ``` -DEBUG=sql,cache,presenters +DEBUG=sql,cache,presenters,events ``` ## Migrations diff --git a/app/scenes/Settings/Settings.js b/app/scenes/Settings/Settings.js index 14a8561d..ae32d05c 100644 --- a/app/scenes/Settings/Settings.js +++ b/app/scenes/Settings/Settings.js @@ -48,7 +48,7 @@ class Settings extends Component { name: this.name, avatarUrl: this.avatarUrl, }); - invariant(res && res.data, 'Document list not available'); + invariant(res && res.data, 'User response not available'); const { data } = res; runInAction('Settings#handleSubmit', () => { this.props.auth.user = data; @@ -56,7 +56,7 @@ class Settings extends Component { this.timeout = setTimeout(() => (this.isUpdated = false), 2500); }); } catch (e) { - this.props.errors.add('Failed to load documents'); + this.props.errors.add('Failed to update user'); } finally { this.isSaving = false; } @@ -153,7 +153,7 @@ const AvatarContainer = styled(Flex)` &:hover div { opacity: 1; background: rgba(0, 0, 0, 0.75); - color: #ffffff; + color: ${color.white}; } `; @@ -165,4 +165,4 @@ const StyledInput = styled(Input)` max-width: 350px; `; -export default inject('auth', 'errors', 'auth')(Settings); +export default inject('auth', 'errors')(Settings); diff --git a/app/scenes/Settings/Tokens.js b/app/scenes/Settings/Tokens.js index 169ef5d3..c7c9ff51 100644 --- a/app/scenes/Settings/Tokens.js +++ b/app/scenes/Settings/Tokens.js @@ -16,7 +16,7 @@ import HelpText from 'components/HelpText'; import Subheading from 'components/Subheading'; @observer -class Settings extends Component { +class Tokens extends Component { @observable name: string = ''; props: { settings: SettingsStore, @@ -96,4 +96,4 @@ const Table = styled.table` } `; -export default inject('settings')(Settings); +export default inject('settings')(Tokens); diff --git a/server/events.js b/server/events.js new file mode 100644 index 00000000..26bf6cf5 --- /dev/null +++ b/server/events.js @@ -0,0 +1,46 @@ +// @flow +import Queue from 'bull'; +import debug from 'debug'; +import services from '../services'; +import Document from './models/Document'; +import Collection from './models/Collection'; + +type DocumentEvent = { + name: 'documents.create', + model: Document, +}; + +type CollectionEvent = { + name: 'collections.create', + 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); + +// this queue processes global events and hands them off to service hooks +globalEventsQueue.process(async function(job) { + const names = Object.keys(services); + names.forEach(name => { + const service = services[name]; + if (service.on) { + serviceEventsQueue.add({ service: name, ...job.data }); + } + }); +}); + +// this queue processes an individual event for a specific service +serviceEventsQueue.process(async function(job) { + const event = job.data; + const service = services[event.service]; + + if (service.on) { + log(`Triggering ${event.name} for ${service.name}`); + service.on(event); + } +}); + +export default globalEventsQueue; diff --git a/server/models/Collection.js b/server/models/Collection.js index 73069c9a..fd22a77c 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,11 +1,12 @@ // @flow +import _ from 'lodash'; import slug from 'slug'; import randomstring from 'randomstring'; import { DataTypes, sequelize } from '../sequelize'; import { asyncLock } from '../redis'; +import events from '../events'; import Document from './Document'; import Event from './Event'; -import _ from 'lodash'; // $FlowIssue invalid flow-typed slug.defaults.mode = 'rfc3986'; @@ -93,6 +94,20 @@ Collection.associate = models => { }); }; +// Hooks + +Collection.addHook('afterCreate', model => + events.add({ name: 'collections.create', model }) +); + +Collection.addHook('afterDestroy', model => + events.add({ name: 'collections.delete', model }) +); + +Collection.addHook('afterUpdate', model => + events.add({ name: 'collections.update', model }) +); + // Instance methods Collection.prototype.getUrl = function() { diff --git a/server/models/Document.js b/server/models/Document.js index e618b403..1d41f1ea 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -7,6 +7,7 @@ import Plain from 'slate-plain-serializer'; import isUUID from 'validator/lib/isUUID'; import { DataTypes, sequelize } from '../sequelize'; +import events from '../events'; import parseTitle from '../../shared/utils/parseTitle'; import Revision from './Revision'; @@ -204,6 +205,20 @@ Document.searchForUser = async ( return _.sortBy(documents, doc => ids.indexOf(doc.id)); }; +// Hooks + +Document.addHook('afterCreate', model => + events.add({ name: 'documents.create', model }) +); + +Document.addHook('afterDestroy', model => + events.add({ name: 'documents.delete', model }) +); + +Document.addHook('afterUpdate', model => + events.add({ name: 'documents.update', model }) +); + // Instance methods Document.prototype.getSummary = function() { diff --git a/server/services/slack/index.js b/server/services/slack/index.js deleted file mode 100644 index acbe3a41..00000000 --- a/server/services/slack/index.js +++ /dev/null @@ -1,7 +0,0 @@ -// @flow -const Slack = { - id: 'slack', - name: 'Slack', -}; - -export default Slack; diff --git a/services/index.js b/services/index.js new file mode 100644 index 00000000..5c509f00 --- /dev/null +++ b/services/index.js @@ -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; diff --git a/services/slack/index.js b/services/slack/index.js new file mode 100644 index 00000000..578bd8ee --- /dev/null +++ b/services/slack/index.js @@ -0,0 +1,10 @@ +// @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; diff --git a/services/slack/package.json b/services/slack/package.json new file mode 100644 index 00000000..a8fd5772 --- /dev/null +++ b/services/slack/package.json @@ -0,0 +1,4 @@ +{ + "name": "slack", + "description": "Hook up your Slack to your Outline." +}