diff --git a/Gemfile b/Gemfile index b7ea08e9..bf5997af 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,11 @@ gem 'kaminari' # pagination gem 'uservoice-ruby' gem 'dotenv' gem 'snorlax', '~> 0.1.3' +gem 'httparty' +gem 'sequenced', '~> 2.0.0' gem 'active_model_serializers', '~> 0.8.1' +gem 'delayed_job', '~> 4.0.2' +gem 'delayed_job_active_record', '~> 4.0.1' gem 'paperclip' gem 'aws-sdk', '< 2.0' diff --git a/Gemfile.lock b/Gemfile.lock index a2a3b031..ff5c1317 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,6 +73,11 @@ GEM coffee-script-source (1.10.0) concurrent-ruby (1.0.1) debug_inspector (0.0.2) + delayed_job (4.0.6) + activesupport (>= 3.0, < 5.0) + delayed_job_active_record (4.0.3) + activerecord (>= 3.0, < 5.0) + delayed_job (>= 3.0, < 4.1) devise (3.5.6) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -97,6 +102,9 @@ GEM rails (> 3.0.0) globalid (0.3.6) activesupport (>= 4.1.0) + httparty (0.13.7) + json (~> 1.8) + multi_xml (>= 0.5.2) i18n (0.7.0) jbuilder (2.4.1) activesupport (>= 3.0.0, < 5.1) @@ -123,6 +131,7 @@ GEM mini_portile2 (2.0.0) minitest (5.8.4) multi_json (1.11.2) + multi_xml (0.5.5) nokogiri (1.6.7.2) mini_portile2 (~> 2.0.0.rc2) oauth (0.5.1) @@ -210,6 +219,9 @@ GEM sprockets (>= 2.8, < 4.0) sprockets-rails (>= 2.0, < 4.0) tilt (>= 1.1, < 3) + sequenced (2.0.0) + activerecord (>= 3.0) + activesupport (>= 3.0) shoulda-matchers (3.1.1) activesupport (>= 4.0.0) simplecov (0.11.2) @@ -255,11 +267,14 @@ DEPENDENCIES binding_of_caller cancan coffee-rails + delayed_job (~> 4.0.2) + delayed_job_active_record (~> 4.0.1) devise dotenv factory_girl_rails formtastic formula + httparty jbuilder jquery-rails jquery-ui-rails @@ -279,6 +294,7 @@ DEPENDENCIES redis rspec-rails sass-rails + sequenced (~> 2.0.0) shoulda-matchers simplecov snorlax (~> 0.1.3) diff --git a/Procfile b/Procfile index 443f2e35..e00c3019 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,3 @@ web: bundle exec rails server -p $PORT +worker: bundle exec rake jobs:work + diff --git a/Vagrantfile b/Vagrantfile index dad6c7c7..c9ea9363 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -16,7 +16,6 @@ sudo apt-get install nodejs -y sudo apt-get install npm -y sudo apt-get install postgresql -y sudo apt-get install libpq-dev -y -sudo apt-get install redis-server -y # get imagemagick sudo apt-get install imagemagick --fix-missing diff --git a/app/controllers/mappings_controller.rb b/app/controllers/mappings_controller.rb index 649ef8f3..936ffcc2 100644 --- a/app/controllers/mappings_controller.rb +++ b/app/controllers/mappings_controller.rb @@ -22,6 +22,7 @@ class MappingsController < ApplicationController if @mapping.save render json: @mapping, status: :created + Events::NewMapping.publish!(@mapping, current_user) else render json: @mapping.errors, status: :unprocessable_entity end diff --git a/app/models/concerns/routing.rb b/app/models/concerns/routing.rb new file mode 100644 index 00000000..2f8467bf --- /dev/null +++ b/app/models/concerns/routing.rb @@ -0,0 +1,10 @@ +module Routing + extend ActiveSupport::Concern + include Rails.application.routes.url_helpers + + included do + def default_url_options + ActionMailer::Base.default_url_options + end + end +end diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 00000000..b49b4856 --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,34 @@ +class Event < ActiveRecord::Base + KINDS = %w[topic_added_to_map synapse_added_to_map] + + #has_many :notifications, dependent: :destroy + belongs_to :eventable, polymorphic: true + belongs_to :map + belongs_to :user + + scope :sequenced, -> { where('sequence_id is not null').order('sequence_id asc') } + scope :chronologically, -> { order('created_at asc') } + + after_create :notify_webhooks!, if: :map + + validates_inclusion_of :kind, :in => KINDS + validates_presence_of :eventable + + acts_as_sequenced scope: :map_id, column: :sequence_id, skip: lambda {|e| e.map.nil? || e.map_id.nil? } + + #def notify!(user) + # notifications.create!(user: user) + #end + + def belongs_to?(this_user) + self.user_id == this_user.id + end + + def notify_webhooks! + #group = self.discussion.group + self.map.webhooks.each { |webhook| WebhookService.publish! webhook: webhook, event: self } + #group.webhooks.each { |webhook| WebhookService.publish! webhook: webhook, event: self } + end + handle_asynchronously :notify_webhooks! + +end diff --git a/app/models/events/new_mapping.rb b/app/models/events/new_mapping.rb new file mode 100644 index 00000000..7d6e0696 --- /dev/null +++ b/app/models/events/new_mapping.rb @@ -0,0 +1,18 @@ +class Events::NewMapping < Event + #after_create :notify_users! + + def self.publish!(mapping, user) + create!(kind: mapping.mappable_type == "Topic" ? "topic_added_to_map" : "synapse_added_to_map", + eventable: mapping, + map: mapping.map, + user: user) + end + + private + + #def notify_users! + # unless comment_vote.user == comment_vote.comment_user + # notify!(comment_vote.comment_user) + # end + #end +end diff --git a/app/models/map.rb b/app/models/map.rb index 87c8d641..acfb627d 100644 --- a/app/models/map.rb +++ b/app/models/map.rb @@ -7,6 +7,9 @@ class Map < ActiveRecord::Base has_many :topics, through: :topicmappings, source: :mappable, source_type: "Topic" has_many :synapses, through: :synapsemappings, source: :mappable, source_type: "Synapse" + has_many :webhooks, as: :hookable + has_many :events, -> { includes :user }, as: :eventable, dependent: :destroy + # This method associates the attribute ":image" with a file attachment has_attached_file :screenshot, :styles => { :thumb => ['188x126#', :png] diff --git a/app/models/webhook.rb b/app/models/webhook.rb new file mode 100644 index 00000000..4e20272c --- /dev/null +++ b/app/models/webhook.rb @@ -0,0 +1,13 @@ +class Webhook < ActiveRecord::Base + belongs_to :hookable, polymorphic: true + + validates :uri, presence: true + validates :hookable, presence: true + validates_inclusion_of :kind, in: %w[slack] + validates :event_types, length: { minimum: 1 } + + def headers + {} + end + +end diff --git a/app/models/webhooks/slack/base.rb b/app/models/webhooks/slack/base.rb new file mode 100644 index 00000000..98503916 --- /dev/null +++ b/app/models/webhooks/slack/base.rb @@ -0,0 +1,72 @@ +Webhooks::Slack::Base = Struct.new(:event) do + include Routing + + def username + "Metamaps Bot" + end + + def icon_url + "https://pbs.twimg.com/profile_images/539300245029392385/dJ1bwnw7.jpeg" + end + + def text + "something" + end + + def attachments + [{ + title: attachment_title, + text: attachment_text, + fields: attachment_fields, + fallback: attachment_fallback + }] + end + + alias :read_attribute_for_serialization :send + + private + + #def motion_vote_field + # { + # title: "Vote on this proposal", + # value: "#{proposal_link(eventable, "yes")} · " + + # "#{proposal_link(eventable, "abstain")} · " + + # "#{proposal_link(eventable, "no")} · " + + # "#{proposal_link(eventable, "block")}" + # } + #end + + def view_map_on_metamaps(text = nil) + "<#{map_url(eventable.map)}|#{text || eventable.map.name}>" + end + + #def view_discussion_on_loomio(params = {}) + # { value: discussion_link(I18n.t(:"webhooks.slack.view_it_on_loomio"), params) } + #end + + #def proposal_link(proposal, position = nil) + # discussion_link position || proposal.name, { proposal: proposal.key, position: position } + #end + + #def discussion_link(text = nil, params = {}) + # "<#{discussion_url(eventable.map, params)}|#{text || eventable.discussion.title}>" + #end + + def eventable + @eventable ||= event.eventable + end + + def author + @author ||= eventable.author + end + +end + +#webhooks: +# slack: +# motion_closed: "*%{name}* has closed" +# motion_closing_soon: "*%{name}* has a proposal closing in 24 hours" +# motion_outcome_created: "*%{author}* published an outcome in *%{name}*" +# motion_outcome_updated: "*%{author}* updated the outcome for *%{name}*" +# new_motion: "*%{author}* started a new proposal in *%{name}*" +# view_it_on_loomio: "View it on Loomio" diff --git a/app/models/webhooks/slack/synapse_added_to_map.rb b/app/models/webhooks/slack/synapse_added_to_map.rb new file mode 100644 index 00000000..3d70450b --- /dev/null +++ b/app/models/webhooks/slack/synapse_added_to_map.rb @@ -0,0 +1,26 @@ +class Webhooks::Slack::SynapseAddedToMap < Webhooks::Slack::Base + + def text + "\"*#{eventable.mappable.topic1.name}* #{eventable.mappable.desc || '->'} *#{eventable.mappable.topic2.name}*\" was added as a connection to the map *#{view_map_on_metamaps()}*" + end + + def attachment_fallback + "" #{}"*#{eventable.name}*\n#{eventable.description}\n" + end + + def attachment_title + "" #proposal_link(eventable) + end + + def attachment_text + "" # "#{eventable.description}\n" + end + + def attachment_fields + [{ + title: "nothing", + value: "nothing" + }] #[motion_vote_field] + end + +end diff --git a/app/models/webhooks/slack/topic_added_to_map.rb b/app/models/webhooks/slack/topic_added_to_map.rb new file mode 100644 index 00000000..3574a464 --- /dev/null +++ b/app/models/webhooks/slack/topic_added_to_map.rb @@ -0,0 +1,27 @@ +class Webhooks::Slack::TopicAddedToMap < Webhooks::Slack::Base + + def text + "New #{eventable.mappable.metacode.name} topic *#{eventable.mappable.name}* was added to the map *#{view_map_on_metamaps()}*" + end + # todo: it would be sweet if it sends it with the metacode as the icon_url + + def attachment_fallback + "" #{}"*#{eventable.name}*\n#{eventable.description}\n" + end + + def attachment_title + "" #proposal_link(eventable) + end + + def attachment_text + "" # "#{eventable.description}\n" + end + + def attachment_fields + [{ + title: "nothing", + value: "nothing" + }] #[motion_vote_field] + end + +end diff --git a/app/serializers/event_serializer.rb b/app/serializers/event_serializer.rb new file mode 100644 index 00000000..0e87cd44 --- /dev/null +++ b/app/serializers/event_serializer.rb @@ -0,0 +1,15 @@ +class EventSerializer < ActiveModel::Serializer + embed :ids, include: true + attributes :id, :sequence_id, :kind, :map_id, :created_at + + has_one :actor, serializer: NewUserSerializer, root: 'users' + has_one :map, serializer: NewMapSerializer + + def actor + object.user || object.eventable.try(:user) + end + + def map + object.eventable.try(:map) || object.eventable.map + end +end diff --git a/app/serializers/webhook_serializer.rb b/app/serializers/webhook_serializer.rb new file mode 100644 index 00000000..ed013cae --- /dev/null +++ b/app/serializers/webhook_serializer.rb @@ -0,0 +1,3 @@ +class WebhookSerializer < ActiveModel::Serializer + attributes :text, :username, :icon_url #, :attachments +end diff --git a/app/services/webhook_service.rb b/app/services/webhook_service.rb new file mode 100644 index 00000000..7a4361b4 --- /dev/null +++ b/app/services/webhook_service.rb @@ -0,0 +1,18 @@ +class WebhookService + + def self.publish!(webhook:, event:) + return false unless webhook.event_types.include? event.kind + HTTParty.post webhook.uri, body: payload_for(webhook, event), headers: webhook.headers + end + + private + + def self.payload_for(webhook, event) + WebhookSerializer.new(webhook_object_for(webhook, event), root: false).to_json + end + + def self.webhook_object_for(webhook, event) + "Webhooks::#{webhook.kind.classify}::#{event.kind.classify}".constantize.new(event) + end + +end diff --git a/bin/delayed_job b/bin/delayed_job new file mode 100755 index 00000000..edf19598 --- /dev/null +++ b/bin/delayed_job @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment')) +require 'delayed/command' +Delayed::Command.new(ARGV).daemonize diff --git a/config/application.rb b/config/application.rb index 658a4203..e7a47614 100644 --- a/config/application.rb +++ b/config/application.rb @@ -10,6 +10,7 @@ Dotenv.load ".env.#{ENV["RAILS_ENV"]}", '.env' module Metamaps class Application < Rails::Application + config.active_job.queue_adapter = :delayed_job # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. diff --git a/db/migrate/20160312234946_create_events.rb b/db/migrate/20160312234946_create_events.rb new file mode 100644 index 00000000..7ab71099 --- /dev/null +++ b/db/migrate/20160312234946_create_events.rb @@ -0,0 +1,13 @@ +class CreateEvents < ActiveRecord::Migration + def change + create_table :events do |t| + t.string :kind, limit: 255 + t.references :eventable, polymorphic: true, index: true + t.references :user, index: true + t.references :map, index: true + t.integer :sequence_id, index: true, default: nil, null: true + t.timestamps + end + add_index :events, [:map_id, :sequence_id], unique: true + end +end diff --git a/db/migrate/20160312235006_create_webhooks.rb b/db/migrate/20160312235006_create_webhooks.rb new file mode 100644 index 00000000..0d1cf899 --- /dev/null +++ b/db/migrate/20160312235006_create_webhooks.rb @@ -0,0 +1,10 @@ +class CreateWebhooks < ActiveRecord::Migration + def change + create_table :webhooks do |t| + t.references :hookable, polymorphic: true, index: true + t.string :kind, null: false + t.string :uri, null: false + t.text :event_types, array: true, default: [] + end + end +end diff --git a/db/migrate/20160313003721_create_delayed_jobs.rb b/db/migrate/20160313003721_create_delayed_jobs.rb new file mode 100644 index 00000000..27fdcf6c --- /dev/null +++ b/db/migrate/20160313003721_create_delayed_jobs.rb @@ -0,0 +1,22 @@ +class CreateDelayedJobs < ActiveRecord::Migration + def self.up + create_table :delayed_jobs, force: true do |table| + table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue + table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually. + table.text :handler, null: false # YAML-encoded string of the object that will do work + table.text :last_error # reason for last failure (See Note below) + table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future. + table.datetime :locked_at # Set when a client is working on this object + table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) + table.string :locked_by # Who is working on this object (if locked) + table.string :queue # The name of the queue this job is in + table.timestamps null: true + end + + add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority" + end + + def self.down + drop_table :delayed_jobs + end +end diff --git a/db/schema.rb b/db/schema.rb index 334fd4ad..d0381055 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,11 +11,44 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160310200131) do +ActiveRecord::Schema.define(version: 20160313003721) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "delayed_jobs", force: :cascade do |t| + t.integer "priority", default: 0, null: false + t.integer "attempts", default: 0, null: false + t.text "handler", null: false + t.text "last_error" + t.datetime "run_at" + t.datetime "locked_at" + t.datetime "failed_at" + t.string "locked_by" + t.string "queue" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree + + create_table "events", force: :cascade do |t| + t.string "kind", limit: 255 + t.integer "eventable_id" + t.string "eventable_type" + t.integer "user_id" + t.integer "map_id" + t.integer "sequence_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "events", ["eventable_type", "eventable_id"], name: "index_events_on_eventable_type_and_eventable_id", using: :btree + add_index "events", ["map_id", "sequence_id"], name: "index_events_on_map_id_and_sequence_id", unique: true, using: :btree + add_index "events", ["map_id"], name: "index_events_on_map_id", using: :btree + add_index "events", ["sequence_id"], name: "index_events_on_sequence_id", using: :btree + add_index "events", ["user_id"], name: "index_events_on_user_id", using: :btree + create_table "in_metacode_sets", force: :cascade do |t| t.integer "metacode_id" t.integer "metacode_set_id" @@ -181,5 +214,15 @@ ActiveRecord::Schema.define(version: 20160310200131) do add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree + create_table "webhooks", force: :cascade do |t| + t.integer "hookable_id" + t.string "hookable_type" + t.string "kind", null: false + t.string "uri", null: false + t.text "event_types", default: [], array: true + end + + add_index "webhooks", ["hookable_type", "hookable_id"], name: "index_webhooks_on_hookable_type_and_hookable_id", using: :btree + add_foreign_key "tokens", "users" end