diff --git a/Gemfile b/Gemfile index 406c2b84..57e43e50 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,11 @@ gem 'best_in_place' #in-place editing gem 'kaminari' # pagination gem 'uservoice-ruby' gem 'dotenv' +gem 'httparty' +gem 'delayed_job', '~> 4.0.2' +gem 'delayed_job_active_record', '~> 4.0.1' +gem 'sequenced', '~> 2.0.0' +gem 'active_model_serializers', '~> 0.8.1' gem 'paperclip' gem 'aws-sdk' diff --git a/Gemfile.lock b/Gemfile.lock index 8bd00a57..743f5084 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,6 +20,8 @@ GEM erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) + active_model_serializers (0.8.3) + activemodel (>= 3.0) activejob (4.2.4) activesupport (= 4.2.4) globalid (>= 0.3.0) @@ -65,6 +67,11 @@ GEM coffee-script-source execjs coffee-script-source (1.9.1.1) + 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.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -82,6 +89,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.3.1) activesupport (>= 3.0.0, < 5) @@ -108,6 +118,7 @@ GEM mini_portile (0.6.2) minitest (5.8.0) multi_json (1.11.2) + multi_xml (0.5.5) nokogiri (1.6.6.2) mini_portile (~> 0.6.0) oauth (0.4.7) @@ -172,6 +183,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) slop (3.6.0) sprockets (3.3.4) rack (~> 1.0) @@ -198,15 +212,19 @@ PLATFORMS ruby DEPENDENCIES + active_model_serializers (~> 0.8.1) aws-sdk best_in_place better_errors cancancan coffee-rails + delayed_job (~> 4.0.2) + delayed_job_active_record (~> 4.0.1) devise dotenv formtastic formula + httparty jbuilder jquery-rails jquery-ui-rails @@ -221,5 +239,9 @@ DEPENDENCIES rails_12factor redis sass-rails + sequenced (~> 2.0.0) uglifier uservoice-ruby + +BUNDLED WITH + 1.10.6 diff --git a/Procfile b/Procfile index 443f2e35..802726be 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,2 @@ web: bundle exec rails server -p $PORT + 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 79d8d80a..695243cc 100644 --- a/app/controllers/mappings_controller.rb +++ b/app/controllers/mappings_controller.rb @@ -15,10 +15,10 @@ class MappingsController < ApplicationController def create @mapping = Mapping.new(mapping_params) - @mapping.map.touch(:updated_at) - if @mapping.save render json: @mapping, status: :created + @mapping.map.touch(:updated_at) + 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..eef52d32 --- /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.topic_id ? "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 ee949676..c3464283 100644 --- a/app/models/map.rb +++ b/app/models/map.rb @@ -7,6 +7,9 @@ class Map < ActiveRecord::Base has_many :topics, through: :topicmappings has_many :synapses, through: :synapsemappings + 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..904e77ec --- /dev/null +++ b/app/models/webhooks/slack/base.rb @@ -0,0 +1,73 @@ +Webhooks::Slack::Base = Struct.new(:event) do + include Routing + + def username + "Metamaps Bot" + end + + def icon_url + # we'll host our own image soon I reckon + "https://fbcdn-profile-a.akamaihd.net/hprofile-ak-xap1/v/t1.0-1/p50x50/11537803_694991540606106_5116967442850884451_n.jpg?oh=eeba96f797e9cb12340bfd94df2650f0&oe=56802643&__gda__=1447229045_95708238caee2d950ea43c93b38b071c" + 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..25093127 --- /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.synapse.topic1.name}* #{eventable.synapse.desc || '->'} *#{eventable.synapse.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..41c4dca3 --- /dev/null +++ b/app/models/webhooks/slack/topic_added_to_map.rb @@ -0,0 +1,26 @@ +class Webhooks::Slack::TopicAddedToMap < Webhooks::Slack::Base + + def text + "New #{eventable.topic.metacode.name} topic *#{eventable.topic.name}* was added 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/serializers/event_serializer.rb b/app/serializers/event_serializer.rb new file mode 100644 index 00000000..09ca3596 --- /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: UserSerializer, root: 'users' + has_one :map, serializer: MapSerializer + + 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/map_serializer.rb b/app/serializers/map_serializer.rb new file mode 100644 index 00000000..ffb9aa99 --- /dev/null +++ b/app/serializers/map_serializer.rb @@ -0,0 +1,17 @@ +class MapSerializer < ActiveModel::Serializer + embed :ids, include: true + + attributes :id, + :name, + :desc, + :permission + + #has_one :author, serializer: UserSerializer, root: 'users' + #has_one :group, serializer: GroupSerializer, root: 'groups' + #has_one :active_proposal, serializer: MotionSerializer, root: 'proposals' + + #def author + # object.author + #end + +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb new file mode 100644 index 00000000..550e8df9 --- /dev/null +++ b/app/serializers/user_serializer.rb @@ -0,0 +1,28 @@ +class UserSerializer < ActiveModel::Serializer + embed :ids, include: true + + attributes :id, :name, :username + + #:avatar_initials, :avatar_kind, :avatar_url, :profile_url, :gravatar_md5, :time_zone, :search_fragment, :label + + #def label + # username + #end + + #def gravatar_md5 + # Digest::MD5.hexdigest(object.email.to_s.downcase) + #end + + #def include_gravatar_md5? + # object.avatar_kind == 'gravatar' + #end + + #def avatar_url + # object.avatar_url :large + #end + + #def profile_url + # object.avatar_url :large + #end + +end diff --git a/app/serializers/webhook_serializer.rb b/app/serializers/webhook_serializer.rb new file mode 100644 index 00000000..9adfd101 --- /dev/null +++ b/app/serializers/webhook_serializer.rb @@ -0,0 +1,3 @@ +class WebhookSerializer < ActiveModel::Serializer + attributes :text, :username #, :attachments #, :icon_url +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 f9e0a87d..2ed023b6 100644 --- a/config/application.rb +++ b/config/application.rb @@ -6,6 +6,8 @@ Bundler.require(:default, Rails.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/config/initializers/delayed_job.rb b/config/initializers/delayed_job.rb new file mode 100644 index 00000000..18c782cc --- /dev/null +++ b/config/initializers/delayed_job.rb @@ -0,0 +1,2 @@ +Delayed::Worker.max_attempts = 2 +Delayed::Worker.delay_jobs = !(Rails.env.test? or Rails.env.development?) \ No newline at end of file diff --git a/db/migrate/20150930233907_create_delayed_jobs.rb b/db/migrate/20150930233907_create_delayed_jobs.rb new file mode 100644 index 00000000..27fdcf6c --- /dev/null +++ b/db/migrate/20150930233907_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 fc6b7335..28e8c6c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -9,21 +9,57 @@ # from scratch. The latter is a flawed and unsustainable approach (the more migrations # you'll amass, the slower it'll run and the greater likelihood for issues). # -# It's strongly recommended to check this file into your version control system. +# It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(:version => 20141121204712) do +ActiveRecord::Schema.define(version: 20150930233907) do - create_table "in_metacode_sets", :force => true do |t| - t.integer "metacode_id" - t.integer "metacode_set_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + # 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 "in_metacode_sets", ["metacode_id"], :name => "index_in_metacode_sets_on_metacode_id" - add_index "in_metacode_sets", ["metacode_set_id"], :name => "index_in_metacode_sets_on_metacode_set_id" + add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree - create_table "mappings", :force => true do |t| + create_table "events", force: :cascade do |t| + t.string "kind", limit: 255 + t.datetime "created_at" + t.datetime "updated_at" + t.integer "eventable_id" + t.string "eventable_type", limit: 255 + t.integer "user_id" + t.integer "map_id" + t.integer "sequence_id" + end + + add_index "events", ["created_at"], name: "index_events_on_created_at", 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", ["eventable_type", "eventable_id"], name: "index_events_on_eventable_type_and_eventable_id", using: :btree + add_index "events", ["sequence_id"], name: "index_events_on_sequence_id", using: :btree + + create_table "in_metacode_sets", force: :cascade do |t| + t.integer "metacode_id" + t.integer "metacode_set_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "in_metacode_sets", ["metacode_id"], name: "index_in_metacode_sets_on_metacode_id", using: :btree + add_index "in_metacode_sets", ["metacode_set_id"], name: "index_in_metacode_sets_on_metacode_set_id", using: :btree + + create_table "mappings", force: :cascade do |t| t.text "category" t.integer "xloc" t.integer "yloc" @@ -31,18 +67,18 @@ ActiveRecord::Schema.define(:version => 20141121204712) do t.integer "synapse_id" t.integer "map_id" t.integer "user_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end - create_table "maps", :force => true do |t| + create_table "maps", force: :cascade do |t| t.text "name" t.boolean "arranged" t.text "desc" t.text "permission" t.integer "user_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.boolean "featured" t.string "screenshot_file_name" t.string "screenshot_content_type" @@ -50,26 +86,26 @@ ActiveRecord::Schema.define(:version => 20141121204712) do t.datetime "screenshot_updated_at" end - create_table "metacode_sets", :force => true do |t| + create_table "metacode_sets", force: :cascade do |t| t.string "name" t.text "desc" t.integer "user_id" t.boolean "mapperContributed" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end - add_index "metacode_sets", ["user_id"], :name => "index_metacode_sets_on_user_id" + add_index "metacode_sets", ["user_id"], name: "index_metacode_sets_on_user_id", using: :btree - create_table "metacodes", :force => true do |t| + create_table "metacodes", force: :cascade do |t| t.text "name" t.string "icon" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "color" end - create_table "synapses", :force => true do |t| + create_table "synapses", force: :cascade do |t| t.text "desc" t.text "category" t.text "weight" @@ -77,19 +113,19 @@ ActiveRecord::Schema.define(:version => 20141121204712) do t.integer "node1_id" t.integer "node2_id" t.integer "user_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end - create_table "topics", :force => true do |t| + create_table "topics", force: :cascade do |t| t.text "name" t.text "desc" t.text "link" t.text "permission" t.integer "user_id" t.integer "metacode_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "image_file_name" t.string "image_content_type" t.integer "image_file_size" @@ -100,25 +136,25 @@ ActiveRecord::Schema.define(:version => 20141121204712) do t.datetime "audio_updated_at" end - create_table "users", :force => true do |t| + create_table "users", force: :cascade do |t| t.string "name" t.string "email" t.text "settings" - t.string "code", :limit => 8 - t.string "joinedwithcode", :limit => 8 + t.string "code", limit: 8 + t.string "joinedwithcode", limit: 8 t.string "crypted_password" t.string "password_salt" t.string "persistence_token" t.string "perishable_token" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false - t.string "encrypted_password", :limit => 128, :default => "" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "encrypted_password", limit: 128, default: "" t.string "remember_token" t.datetime "remember_created_at" t.string "reset_password_token" t.datetime "last_sign_in_at" t.string "last_sign_in_ip" - t.integer "sign_in_count", :default => 0 + t.integer "sign_in_count", default: 0 t.datetime "current_sign_in_at" t.string "current_sign_in_ip" t.datetime "reset_password_sent_at" @@ -130,6 +166,16 @@ ActiveRecord::Schema.define(:version => 20141121204712) do t.integer "generation" end - add_index "users", ["reset_password_token"], :name => "index_users_on_reset_password_token", :unique => true + 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 end