diff --git a/Gemfile b/Gemfile index 80d4731..9f634ba 100644 --- a/Gemfile +++ b/Gemfile @@ -34,6 +34,9 @@ gem 'delayed_job_active_record' gem 'daemons' gem 'sinatra', :require => nil +#webhooks +gem 'httparty' + # error tracking gem 'airbrake' diff --git a/Gemfile.lock b/Gemfile.lock index c43057e..48e01b9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,6 +101,9 @@ GEM activesupport (>= 3) hashie (3.4.2) highline (1.7.2) + httparty (0.13.7) + json (~> 1.8) + multi_xml (>= 0.5.2) http-cookie (1.0.2) domain_name (~> 0.5) i18n (0.7.0) @@ -123,6 +126,7 @@ GEM money (~> 6.5.0) railties (>= 3.0) multi_json (1.11.2) + multi_xml (0.5.5) net-scp (1.2.1) net-ssh (>= 2.6.5) net-sftp (2.1.2) @@ -270,6 +274,7 @@ DEPENDENCIES factory_girl_rails faker foreigner + httparty groupdate money-rails newrelic_rpm @@ -292,3 +297,6 @@ DEPENDENCIES spring stripe timecop + +BUNDLED WITH + 1.11.2 diff --git a/app/controllers/buckets_controller.rb b/app/controllers/buckets_controller.rb index 93ab3ff..c664c5a 100644 --- a/app/controllers/buckets_controller.rb +++ b/app/controllers/buckets_controller.rb @@ -15,6 +15,7 @@ def show def create bucket = Bucket.new(bucket_params_create) if bucket.save + BucketService.bucket_created(bucket: bucket, current_user: current_user) render json: [bucket] else render json: { @@ -41,6 +42,7 @@ def update def open_for_funding bucket = Bucket.find(params[:id]) render status: 403, nothing: true and return unless bucket.is_editable_by?(current_user) && !bucket.archived? + BucketService.bucket_moved_to_funding(bucket: bucket, current_user: current_user) bucket.update(status: "live") render json: [bucket] end diff --git a/app/controllers/contributions_controller.rb b/app/controllers/contributions_controller.rb index cd28c92..d6c1794 100644 --- a/app/controllers/contributions_controller.rb +++ b/app/controllers/contributions_controller.rb @@ -20,6 +20,7 @@ def index def create contribution = Contribution.create(contribution_params) if contribution.valid? + BucketService.bucket_received_contribution(bucket: contribution.bucket, current_user: current_user) render json: [contribution] else render json: contribution.errors.full_messages, status: 422 diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000..88d87fc --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,24 @@ +class Event < ActiveRecord::Base + KINDS = %w[bucket_created bucket_moved_to_funding bucket_funded] + + belongs_to :eventable, polymorphic: true + belongs_to :group + belongs_to :user + + scope :chronologically, -> { order('created_at asc') } + + after_create :notify_webhooks!, if: :group + + validates_inclusion_of :kind, :in => KINDS + validates_presence_of :eventable + + def belongs_to?(this_user) + self.user_id == this_user.id + end + + def notify_webhooks! + self.group.webhooks.each { |webhook| WebhookService.publish! webhook: webhook, event: self } + end + handle_asynchronously :notify_webhooks! + +end diff --git a/app/models/events/bucket_created.rb b/app/models/events/bucket_created.rb new file mode 100644 index 0000000..8a46b12 --- /dev/null +++ b/app/models/events/bucket_created.rb @@ -0,0 +1,10 @@ +class Events::BucketCreated < Event + + def self.publish!(bucket, user) + create!(kind: 'bucket_created', + eventable: bucket, + group: bucket.group, + user: user) + end + +end diff --git a/app/models/events/bucket_funded.rb b/app/models/events/bucket_funded.rb new file mode 100644 index 0000000..c302703 --- /dev/null +++ b/app/models/events/bucket_funded.rb @@ -0,0 +1,11 @@ +class Events::BucketFunded < Event + + def self.publish!(bucket, user) + create!(kind: 'bucket_funded', + eventable: bucket, + group: bucket.group, + user: user) + end + +end + diff --git a/app/models/events/bucket_moved_to_funding.rb b/app/models/events/bucket_moved_to_funding.rb new file mode 100644 index 0000000..977feb4 --- /dev/null +++ b/app/models/events/bucket_moved_to_funding.rb @@ -0,0 +1,11 @@ +class Events::BucketMovedToFunding < Event + + def self.publish!(bucket, user) + create!(kind: 'bucket_moved_to_funding', + eventable: bucket, + group: bucket.group, + user: user) + end + +end + diff --git a/app/models/group.rb b/app/models/group.rb index 254900d..841975a 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,6 +3,7 @@ class Group < ActiveRecord::Base has_many :allocations, dependent: :destroy has_many :memberships has_many :members, through: :memberships, source: :member + has_many :webhooks, as: :hookable validates_presence_of :name diff --git a/app/models/webhook.rb b/app/models/webhook.rb new file mode 100644 index 0000000..4e20272 --- /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 0000000..64c4e2f --- /dev/null +++ b/app/models/webhooks/slack/base.rb @@ -0,0 +1,50 @@ +Webhooks::Slack::Base = Struct.new(:event) do + + def username + "Cobudget Bot" + end + + def icon_url + ENV["SLACK_ICON_URL"] + end + + def text + "" + end + + def attachments + [{ + title: attachment_title, + color: '#00B8D4', + text: attachment_text, + fields: attachment_fields, + fallback: attachment_fallback + }] + end + + alias :read_attribute_for_serialization :send + + private + + def view_group_on_cobudget(text = nil) + "<#{url_root}groups/#{eventable.group.id}|#{text || eventable.group.name}>" + end + + def bucket_link(text = nil) + "<#{url_root}buckets/#{eventable.id}|#{text || eventable.name}>" + end + + def url_root + ENV["ROOT_URL"] + "/#/" + end + + def eventable + @eventable ||= event.eventable + end + + def author + @author ||= eventable.author + end + +end + diff --git a/app/models/webhooks/slack/bucket_created.rb b/app/models/webhooks/slack/bucket_created.rb new file mode 100644 index 0000000..a1d8068 --- /dev/null +++ b/app/models/webhooks/slack/bucket_created.rb @@ -0,0 +1,23 @@ +class Webhooks::Slack::BucketCreated < Webhooks::Slack::Base + + def text + "*#{eventable.user.name}* created a new idea in *#{view_group_on_cobudget}*" + end + + def attachment_fallback + "*#{eventable.name}*\n#{eventable.description}\n" + end + + def attachment_title + bucket_link + end + + def attachment_text + "#{eventable.description}\n#{bucket_link('Discuss it on Cobudget')}" + end + + def attachment_fields + [] + end + +end diff --git a/app/models/webhooks/slack/bucket_funded.rb b/app/models/webhooks/slack/bucket_funded.rb new file mode 100644 index 0000000..9d45ce1 --- /dev/null +++ b/app/models/webhooks/slack/bucket_funded.rb @@ -0,0 +1,23 @@ +class Webhooks::Slack::BucketFunded < Webhooks::Slack::Base + + def text + "#{eventable.group.currency_symbol}*#{eventable.target}* #{eventable.group.currency_code} bucket just got fully funded!" + end + + def attachment_fallback + "*#{eventable.name}*\n#{eventable.description}\n" + end + + def attachment_title + bucket_link + end + + def attachment_text + "#{eventable.description}\n#{bucket_link('View it on Cobudget')}" + end + + def attachment_fields + [] + end + +end diff --git a/app/models/webhooks/slack/bucket_moved_to_funding.rb b/app/models/webhooks/slack/bucket_moved_to_funding.rb new file mode 100644 index 0000000..4f7b93e --- /dev/null +++ b/app/models/webhooks/slack/bucket_moved_to_funding.rb @@ -0,0 +1,23 @@ +class Webhooks::Slack::BucketMovedToFunding < Webhooks::Slack::Base + + def text + "Bucket with #{eventable.group.currency_symbol}#{eventable.target} #{eventable.group.currency_code} target is now open for funding" + end + + def attachment_fallback + "*#{eventable.name}*\n#{eventable.description}\n" + end + + def attachment_title + bucket_link + end + + def attachment_text + "#{eventable.description}\n#{bucket_link('Comment or Fund it on Cobudget')}" + end + + def attachment_fields + [] + end + +end diff --git a/app/serializers/webhook_serializer.rb b/app/serializers/webhook_serializer.rb new file mode 100644 index 0000000..f132a59 --- /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/bucket_service.rb b/app/services/bucket_service.rb index ac8ba91..5846b47 100644 --- a/app/services/bucket_service.rb +++ b/app/services/bucket_service.rb @@ -1,4 +1,18 @@ class BucketService + def self.bucket_created(bucket: ,current_user: ) + Events::BucketCreated.publish!(bucket, current_user) + end + + def self.bucket_moved_to_funding(bucket: , current_user: ) + Events::BucketMovedToFunding.publish!(bucket, current_user) + end + + def self.bucket_received_contribution(bucket: , current_user: ) + if bucket.funded? + Events::BucketFunded.publish!(bucket, current_user) + end + end + def self.archive(bucket:, exclude_author_from_email_notifications: false) bucket.update(archived_at: DateTime.now.utc) if bucket.status == 'live' diff --git a/app/services/webhook_service.rb b/app/services/webhook_service.rb new file mode 100644 index 0000000..7a4361b --- /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/db/migrate/20160317235420_create_webhooks.rb b/db/migrate/20160317235420_create_webhooks.rb new file mode 100644 index 0000000..0d1cf89 --- /dev/null +++ b/db/migrate/20160317235420_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/20160317235436_create_events.rb b/db/migrate/20160317235436_create_events.rb new file mode 100644 index 0000000..82b8e71 --- /dev/null +++ b/db/migrate/20160317235436_create_events.rb @@ -0,0 +1,11 @@ +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 :group, index: true + t.timestamps + end + end +end