From 919261d8437d6324204e19d7ede8f905be32504d Mon Sep 17 00:00:00 2001 From: Subin T P Date: Fri, 14 Feb 2020 23:19:17 +0530 Subject: [PATCH] Feature: Webhooks (#489) --- .rubocop.yml | 3 + Gemfile | 1 + Gemfile.lock | 1 + .../api/v1/inbox/webhooks_controller.rb | 36 ++ app/helpers/api/v1/webhooks_helper.rb | 2 - app/jobs/webhook_job.rb | 7 + app/listeners/webhook_listener.rb | 15 + app/models/account.rb | 1 + app/models/inbox.rb | 1 + app/models/webhook.rb | 20 + app/policies/webhook_policy.rb | 17 + app/views/api/v1/inbox/index.json.jbuilder | 10 + .../v1/inbox/webhooks/_webhook.json.jbuilder | 7 + .../v1/inbox/webhooks/create.json.jbuilder | 5 + .../api/v1/inbox/webhooks/index.json.jbuilder | 5 + .../v1/inbox/webhooks/update.json.jbuilder | 5 + config/routes.rb | 4 + config/sidekiq.yml | 1 + db/migrate/20200213054733_create_webhooks.rb | 11 + db/schema.rb | 433 +++++++++--------- lib/webhooks/trigger.rb | 7 + .../api/v1/inbox/webhook_controller_spec.rb | 104 +++++ spec/factories/webhooks.rb | 7 + spec/jobs/webhook_job_spec.rb | 14 + spec/lib/webhooks/trigger_spec.rb | 15 + spec/listeners/webhook_listener_spec.rb | 33 ++ spec/models/account_spec.rb | 1 + spec/models/inbox_spec.rb | 1 + spec/models/webhook_spec.rb | 13 + 29 files changed, 566 insertions(+), 214 deletions(-) create mode 100644 app/controllers/api/v1/inbox/webhooks_controller.rb delete mode 100644 app/helpers/api/v1/webhooks_helper.rb create mode 100644 app/jobs/webhook_job.rb create mode 100644 app/listeners/webhook_listener.rb create mode 100644 app/models/webhook.rb create mode 100644 app/policies/webhook_policy.rb create mode 100644 app/views/api/v1/inbox/index.json.jbuilder create mode 100644 app/views/api/v1/inbox/webhooks/_webhook.json.jbuilder create mode 100644 app/views/api/v1/inbox/webhooks/create.json.jbuilder create mode 100644 app/views/api/v1/inbox/webhooks/index.json.jbuilder create mode 100644 app/views/api/v1/inbox/webhooks/update.json.jbuilder create mode 100644 db/migrate/20200213054733_create_webhooks.rb create mode 100644 lib/webhooks/trigger.rb create mode 100644 spec/controllers/api/v1/inbox/webhook_controller_spec.rb create mode 100644 spec/factories/webhooks.rb create mode 100644 spec/jobs/webhook_job_spec.rb create mode 100644 spec/lib/webhooks/trigger_spec.rb create mode 100644 spec/listeners/webhook_listener_spec.rb create mode 100644 spec/models/webhook_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 09b36e576..7c73f0191 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -25,6 +25,7 @@ Metrics/BlockLength: - spec/**/* - '**/routes.rb' - 'config/environments/*' + - db/schema.rb Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' @@ -38,6 +39,8 @@ Style/ClassAndModuleChildren: RSpec/NestedGroups: Enabled: true Max: 4 +RSpec/MessageSpies: + Enabled: false AllCops: Exclude: - db/* diff --git a/Gemfile b/Gemfile index 6e3944ae3..2a8691dce 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,7 @@ gem 'hashie' gem 'jbuilder' gem 'kaminari' gem 'responders' +gem 'rest-client' gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' diff --git a/Gemfile.lock b/Gemfile.lock index 0eb225711..4eb81f633 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -530,6 +530,7 @@ DEPENDENCIES redis-namespace redis-rack-cache responders + rest-client rspec-rails (~> 4.0.0.beta2) rubocop rubocop-performance diff --git a/app/controllers/api/v1/inbox/webhooks_controller.rb b/app/controllers/api/v1/inbox/webhooks_controller.rb new file mode 100644 index 000000000..47ada02a8 --- /dev/null +++ b/app/controllers/api/v1/inbox/webhooks_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Inbox::WebhooksController < Api::BaseController + before_action :check_authorization + before_action :fetch_webhook, only: [:update, :destroy] + + def index + @webhooks = current_account.webhooks + end + + def create + @webhook = current_account.webhooks.new(webhook_params) + @webhook.save! + end + + def update + @webhook.update!(webhook_params) + end + + def destroy + @webhook.destroy + head :ok + end + + private + + def webhook_params + params.require(:webhook).permit(:account_id, :inbox_id, :urls).merge(urls: params[:urls]) + end + + def fetch_webhook + @webhook = current_account.webhooks.find(params[:id]) + end + + def check_authorization + authorize(Webhook) + end +end diff --git a/app/helpers/api/v1/webhooks_helper.rb b/app/helpers/api/v1/webhooks_helper.rb deleted file mode 100644 index 160b78c7c..000000000 --- a/app/helpers/api/v1/webhooks_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module Api::V1::WebhooksHelper -end diff --git a/app/jobs/webhook_job.rb b/app/jobs/webhook_job.rb new file mode 100644 index 000000000..9f4d96a10 --- /dev/null +++ b/app/jobs/webhook_job.rb @@ -0,0 +1,7 @@ +class WebhookJob < ApplicationJob + queue_as :webhooks + + def perform(url, payload) + Webhooks::Trigger.execute(url, payload) + end +end diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb new file mode 100644 index 000000000..8b7c21934 --- /dev/null +++ b/app/listeners/webhook_listener.rb @@ -0,0 +1,15 @@ +class WebhookListener < BaseListener + def message_created(event) + message = extract_message_and_account(event)[0] + inbox = message.inbox + + return unless message.reportable? && inbox.webhook.present? + + webhook = message.inbox.webhook + payload = message.push_event_data + + webhook.urls.each do |url| + WebhookJob.perform_later(url, payload) + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index f0a2d5735..dc73a1e62 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -22,6 +22,7 @@ class Account < ApplicationRecord has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget' has_many :telegram_bots, dependent: :destroy has_many :canned_responses, dependent: :destroy + has_many :webhooks, dependent: :destroy has_one :subscription, dependent: :destroy after_create :create_subscription diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 28d6a2d51..db6f3775c 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -32,6 +32,7 @@ class Inbox < ApplicationRecord has_many :members, through: :inbox_members, source: :user has_many :conversations, dependent: :destroy has_many :messages, through: :conversations + has_one :webhook, dependent: :destroy after_create :subscribe_webhook, if: :facebook? after_destroy :delete_round_robin_agents diff --git a/app/models/webhook.rb b/app/models/webhook.rb new file mode 100644 index 000000000..81f416e98 --- /dev/null +++ b/app/models/webhook.rb @@ -0,0 +1,20 @@ +# == Schema Information +# +# Table name: webhooks +# +# id :bigint not null, primary key +# urls :string +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer +# inbox_id :integer +# + +class Webhook < ApplicationRecord + belongs_to :account + belongs_to :inbox + + validates :account_id, presence: true + validates :inbox_id, presence: true + serialize :urls, Array +end diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb new file mode 100644 index 000000000..04814ee0f --- /dev/null +++ b/app/policies/webhook_policy.rb @@ -0,0 +1,17 @@ +class WebhookPolicy < ApplicationPolicy + def index? + @user.administrator? + end + + def update? + @user.administrator? + end + + def destroy? + @user.administrator? + end + + def create? + @user.administrator? + end +end diff --git a/app/views/api/v1/inbox/index.json.jbuilder b/app/views/api/v1/inbox/index.json.jbuilder new file mode 100644 index 000000000..b2883ad2e --- /dev/null +++ b/app/views/api/v1/inbox/index.json.jbuilder @@ -0,0 +1,10 @@ +json.array! @agents do |agent| + json.account_id agent.account_id + json.availability_status agent.availability_status + json.confirmed agent.confirmed? + json.email agent.email + json.id agent.id + json.name agent.name + json.role agent.role + json.thumbnail agent.avatar_url +end diff --git a/app/views/api/v1/inbox/webhooks/_webhook.json.jbuilder b/app/views/api/v1/inbox/webhooks/_webhook.json.jbuilder new file mode 100644 index 000000000..93c0caadf --- /dev/null +++ b/app/views/api/v1/inbox/webhooks/_webhook.json.jbuilder @@ -0,0 +1,7 @@ +json.id webhook.id +json.urls webhook.urls +json.account_id webhook.account_id +json.inbox do + json.id webhook.inbox.id + json.name webhook.inbox.name +end diff --git a/app/views/api/v1/inbox/webhooks/create.json.jbuilder b/app/views/api/v1/inbox/webhooks/create.json.jbuilder new file mode 100644 index 000000000..5d05d1349 --- /dev/null +++ b/app/views/api/v1/inbox/webhooks/create.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.webhook do + json.partial! 'webhook', webhook: @webhook + end +end diff --git a/app/views/api/v1/inbox/webhooks/index.json.jbuilder b/app/views/api/v1/inbox/webhooks/index.json.jbuilder new file mode 100644 index 000000000..885bba602 --- /dev/null +++ b/app/views/api/v1/inbox/webhooks/index.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.webhooks do + json.array! @webhooks, partial: 'webhooks/webhook', as: :webhook + end +end diff --git a/app/views/api/v1/inbox/webhooks/update.json.jbuilder b/app/views/api/v1/inbox/webhooks/update.json.jbuilder new file mode 100644 index 000000000..5d05d1349 --- /dev/null +++ b/app/views/api/v1/inbox/webhooks/update.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.webhook do + json.partial! 'webhook', webhook: @webhook + end +end diff --git a/config/routes.rb b/config/routes.rb index c1c935fc8..66f29cb4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,6 +40,10 @@ Rails.application.routes.draw do resource :contact_merge, only: [:create] end + namespace :inbox do + resources :webhooks, except: [:show] + end + resource :profile, only: [:show, :update] resources :accounts, only: [:create] resources :inboxes, only: [:index, :destroy] diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 32e255aad..1ffb5fa51 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -15,6 +15,7 @@ - [default, 2] - [low, 1] - [mailers, 2] + - [webhooks, 1] # you can override concurrency based on environment production: diff --git a/db/migrate/20200213054733_create_webhooks.rb b/db/migrate/20200213054733_create_webhooks.rb new file mode 100644 index 000000000..470cbe373 --- /dev/null +++ b/db/migrate/20200213054733_create_webhooks.rb @@ -0,0 +1,11 @@ +class CreateWebhooks < ActiveRecord::Migration[6.0] + def change + create_table :webhooks do |t| + t.integer :account_id + t.integer :inbox_id + t.string :urls + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 80f268b90..9a1376f90 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,256 +10,265 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_02_06_182948) do - +ActiveRecord::Schema.define(version: 20_200_213_054_733) do # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension 'plpgsql' - create_table "accounts", id: :serial, force: :cascade do |t| - t.string "name", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table 'accounts', id: :serial, force: :cascade do |t| + t.string 'name', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false end - create_table "active_storage_attachments", force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.bigint "record_id", null: false - t.bigint "blob_id", null: false - t.datetime "created_at", null: false - t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" - t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + create_table 'active_storage_attachments', force: :cascade do |t| + t.string 'name', null: false + t.string 'record_type', null: false + t.bigint 'record_id', null: false + t.bigint 'blob_id', null: false + t.datetime 'created_at', null: false + t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' + t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', unique: true end - create_table "active_storage_blobs", force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.bigint "byte_size", null: false - t.string "checksum", null: false - t.datetime "created_at", null: false - t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + create_table 'active_storage_blobs', force: :cascade do |t| + t.string 'key', null: false + t.string 'filename', null: false + t.string 'content_type' + t.text 'metadata' + t.bigint 'byte_size', null: false + t.string 'checksum', null: false + t.datetime 'created_at', null: false + t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true end - create_table "attachments", id: :serial, force: :cascade do |t| - t.integer "file_type", default: 0 - t.string "external_url" - t.float "coordinates_lat", default: 0.0 - t.float "coordinates_long", default: 0.0 - t.integer "message_id", null: false - t.integer "account_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "fallback_title" - t.string "extension" + create_table 'attachments', id: :serial, force: :cascade do |t| + t.integer 'file_type', default: 0 + t.string 'external_url' + t.float 'coordinates_lat', default: 0.0 + t.float 'coordinates_long', default: 0.0 + t.integer 'message_id', null: false + t.integer 'account_id', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.string 'fallback_title' + t.string 'extension' end - create_table "canned_responses", id: :serial, force: :cascade do |t| - t.integer "account_id", null: false - t.string "short_code" - t.text "content" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table 'canned_responses', id: :serial, force: :cascade do |t| + t.integer 'account_id', null: false + t.string 'short_code' + t.text 'content' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false end - create_table "channel_facebook_pages", id: :serial, force: :cascade do |t| - t.string "name", null: false - t.string "page_id", null: false - t.string "user_access_token", null: false - t.string "page_access_token", null: false - t.integer "account_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["page_id", "account_id"], name: "index_channel_facebook_pages_on_page_id_and_account_id", unique: true - t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id" + create_table 'channel_facebook_pages', id: :serial, force: :cascade do |t| + t.string 'name', null: false + t.string 'page_id', null: false + t.string 'user_access_token', null: false + t.string 'page_access_token', null: false + t.integer 'account_id', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index %w[page_id account_id], name: 'index_channel_facebook_pages_on_page_id_and_account_id', unique: true + t.index ['page_id'], name: 'index_channel_facebook_pages_on_page_id' end - create_table "channel_twitter_profiles", force: :cascade do |t| - t.string "name" - t.string "profile_id", null: false - t.string "twitter_access_token", null: false - t.string "twitter_access_token_secret", null: false - t.integer "account_id", null: false - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + create_table 'channel_twitter_profiles', force: :cascade do |t| + t.string 'name' + t.string 'profile_id', null: false + t.string 'twitter_access_token', null: false + t.string 'twitter_access_token_secret', null: false + t.integer 'account_id', null: false + t.datetime 'created_at', precision: 6, null: false + t.datetime 'updated_at', precision: 6, null: false end - create_table "channel_web_widgets", id: :serial, force: :cascade do |t| - t.string "website_name" - t.string "website_url" - t.integer "account_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "website_token" - t.string "widget_color", default: "#1f93ff" - t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true + create_table 'channel_web_widgets', id: :serial, force: :cascade do |t| + t.string 'website_name' + t.string 'website_url' + t.integer 'account_id' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.string 'website_token' + t.string 'widget_color', default: '#1f93ff' + t.index ['website_token'], name: 'index_channel_web_widgets_on_website_token', unique: true end - create_table "contact_inboxes", force: :cascade do |t| - t.bigint "contact_id" - t.bigint "inbox_id" - t.string "source_id", null: false - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false - t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id" - t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true - t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id" - t.index ["source_id"], name: "index_contact_inboxes_on_source_id" + create_table 'contact_inboxes', force: :cascade do |t| + t.bigint 'contact_id' + t.bigint 'inbox_id' + t.string 'source_id', null: false + t.datetime 'created_at', precision: 6, null: false + t.datetime 'updated_at', precision: 6, null: false + t.index ['contact_id'], name: 'index_contact_inboxes_on_contact_id' + t.index %w[inbox_id source_id], name: 'index_contact_inboxes_on_inbox_id_and_source_id', unique: true + t.index ['inbox_id'], name: 'index_contact_inboxes_on_inbox_id' + t.index ['source_id'], name: 'index_contact_inboxes_on_source_id' end - create_table "contacts", id: :serial, force: :cascade do |t| - t.string "name" - t.string "email" - t.string "phone_number" - t.integer "account_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "pubsub_token" - t.jsonb "additional_attributes" - t.index ["account_id"], name: "index_contacts_on_account_id" - t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true + create_table 'contacts', id: :serial, force: :cascade do |t| + t.string 'name' + t.string 'email' + t.string 'phone_number' + t.integer 'account_id', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.string 'pubsub_token' + t.jsonb 'additional_attributes' + t.index ['account_id'], name: 'index_contacts_on_account_id' + t.index ['pubsub_token'], name: 'index_contacts_on_pubsub_token', unique: true end - create_table "conversations", id: :serial, force: :cascade do |t| - t.integer "account_id", null: false - t.integer "inbox_id", null: false - t.integer "status", default: 0, null: false - t.integer "assignee_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.bigint "contact_id" - t.integer "display_id", null: false - t.datetime "user_last_seen_at" - t.datetime "agent_last_seen_at" - t.boolean "locked", default: false - t.jsonb "additional_attributes" - t.bigint "contact_inbox_id" - t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true - t.index ["account_id"], name: "index_conversations_on_account_id" - t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id" + create_table 'conversations', id: :serial, force: :cascade do |t| + t.integer 'account_id', null: false + t.integer 'inbox_id', null: false + t.integer 'status', default: 0, null: false + t.integer 'assignee_id' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.bigint 'contact_id' + t.integer 'display_id', null: false + t.datetime 'user_last_seen_at' + t.datetime 'agent_last_seen_at' + t.boolean 'locked', default: false + t.jsonb 'additional_attributes' + t.bigint 'contact_inbox_id' + t.index %w[account_id display_id], name: 'index_conversations_on_account_id_and_display_id', unique: true + t.index ['account_id'], name: 'index_conversations_on_account_id' + t.index ['contact_inbox_id'], name: 'index_conversations_on_contact_inbox_id' end - create_table "inbox_members", id: :serial, force: :cascade do |t| - t.integer "user_id", null: false - t.integer "inbox_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["inbox_id"], name: "index_inbox_members_on_inbox_id" + create_table 'inbox_members', id: :serial, force: :cascade do |t| + t.integer 'user_id', null: false + t.integer 'inbox_id', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index ['inbox_id'], name: 'index_inbox_members_on_inbox_id' end - create_table "inboxes", id: :serial, force: :cascade do |t| - t.integer "channel_id", null: false - t.integer "account_id", null: false - t.string "name", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "channel_type" - t.index ["account_id"], name: "index_inboxes_on_account_id" + create_table 'inboxes', id: :serial, force: :cascade do |t| + t.integer 'channel_id', null: false + t.integer 'account_id', null: false + t.string 'name', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.string 'channel_type' + t.index ['account_id'], name: 'index_inboxes_on_account_id' end - create_table "messages", id: :serial, force: :cascade do |t| - t.text "content" - t.integer "account_id", null: false - t.integer "inbox_id", null: false - t.integer "conversation_id", null: false - t.integer "message_type", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "private", default: false - t.integer "user_id" - t.integer "status", default: 0 - t.string "source_id" - t.integer "content_type", default: 0 - t.json "content_attributes", default: {} - t.bigint "contact_id" - t.index ["contact_id"], name: "index_messages_on_contact_id" - t.index ["conversation_id"], name: "index_messages_on_conversation_id" - t.index ["source_id"], name: "index_messages_on_source_id" + create_table 'messages', id: :serial, force: :cascade do |t| + t.text 'content' + t.integer 'account_id', null: false + t.integer 'inbox_id', null: false + t.integer 'conversation_id', null: false + t.integer 'message_type', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.boolean 'private', default: false + t.integer 'user_id' + t.integer 'status', default: 0 + t.string 'source_id' + t.integer 'content_type', default: 0 + t.json 'content_attributes', default: {} + t.bigint 'contact_id' + t.index ['contact_id'], name: 'index_messages_on_contact_id' + t.index ['conversation_id'], name: 'index_messages_on_conversation_id' + t.index ['source_id'], name: 'index_messages_on_source_id' end - create_table "subscriptions", id: :serial, force: :cascade do |t| - t.string "pricing_version" - t.integer "account_id" - t.datetime "expiry" - t.string "billing_plan", default: "trial" - t.string "stripe_customer_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "state", default: 0 - t.boolean "payment_source_added", default: false + create_table 'subscriptions', id: :serial, force: :cascade do |t| + t.string 'pricing_version' + t.integer 'account_id' + t.datetime 'expiry' + t.string 'billing_plan', default: 'trial' + t.string 'stripe_customer_id' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.integer 'state', default: 0 + t.boolean 'payment_source_added', default: false end - create_table "taggings", id: :serial, force: :cascade do |t| - t.integer "tag_id" - t.string "taggable_type" - t.integer "taggable_id" - t.string "tagger_type" - t.integer "tagger_id" - t.string "context", limit: 128 - t.datetime "created_at" - t.index ["context"], name: "index_taggings_on_context" - t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true - t.index ["tag_id"], name: "index_taggings_on_tag_id" - t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" - t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy" - t.index ["taggable_id"], name: "index_taggings_on_taggable_id" - t.index ["taggable_type"], name: "index_taggings_on_taggable_type" - t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type" - t.index ["tagger_id"], name: "index_taggings_on_tagger_id" + create_table 'taggings', id: :serial, force: :cascade do |t| + t.integer 'tag_id' + t.string 'taggable_type' + t.integer 'taggable_id' + t.string 'tagger_type' + t.integer 'tagger_id' + t.string 'context', limit: 128 + t.datetime 'created_at' + t.index ['context'], name: 'index_taggings_on_context' + t.index %w[tag_id taggable_id taggable_type context tagger_id tagger_type], name: 'taggings_idx', unique: true + t.index ['tag_id'], name: 'index_taggings_on_tag_id' + t.index %w[taggable_id taggable_type context], name: 'index_taggings_on_taggable_id_and_taggable_type_and_context' + t.index %w[taggable_id taggable_type tagger_id context], name: 'taggings_idy' + t.index ['taggable_id'], name: 'index_taggings_on_taggable_id' + t.index %w[taggable_type taggable_id], name: 'index_taggings_on_taggable_type_and_taggable_id' + t.index ['taggable_type'], name: 'index_taggings_on_taggable_type' + t.index %w[tagger_id tagger_type], name: 'index_taggings_on_tagger_id_and_tagger_type' + t.index ['tagger_id'], name: 'index_taggings_on_tagger_id' + t.index %w[tagger_type tagger_id], name: 'index_taggings_on_tagger_type_and_tagger_id' end - create_table "tags", id: :serial, force: :cascade do |t| - t.string "name" - t.integer "taggings_count", default: 0 - t.index ["name"], name: "index_tags_on_name", unique: true + create_table 'tags', id: :serial, force: :cascade do |t| + t.string 'name' + t.integer 'taggings_count', default: 0 + t.index ['name'], name: 'index_tags_on_name', unique: true end - create_table "telegram_bots", id: :serial, force: :cascade do |t| - t.string "name" - t.string "auth_key" - t.integer "account_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table 'telegram_bots', id: :serial, force: :cascade do |t| + t.string 'name' + t.string 'auth_key' + t.integer 'account_id' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false end - create_table "users", id: :serial, force: :cascade do |t| - t.string "provider", default: "email", null: false - t.string "uid", default: "", null: false - t.string "encrypted_password", default: "", null: false - t.string "reset_password_token" - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" - t.integer "sign_in_count", default: 0, null: false - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" - t.string "current_sign_in_ip" - t.string "last_sign_in_ip" - t.string "confirmation_token" - t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" - t.string "unconfirmed_email" - t.string "name", null: false - t.string "nickname" - t.string "email" - t.json "tokens" - t.integer "account_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "pubsub_token" - t.integer "role", default: 0 - t.bigint "inviter_id" - t.index ["email"], name: "index_users_on_email" - t.index ["inviter_id"], name: "index_users_on_inviter_id" - t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true - t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true - t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true + create_table 'users', id: :serial, force: :cascade do |t| + t.string 'provider', default: 'email', null: false + t.string 'uid', default: '', null: false + t.string 'encrypted_password', default: '', null: false + t.string 'reset_password_token' + t.datetime 'reset_password_sent_at' + t.datetime 'remember_created_at' + t.integer 'sign_in_count', default: 0, null: false + t.datetime 'current_sign_in_at' + t.datetime 'last_sign_in_at' + t.string 'current_sign_in_ip' + t.string 'last_sign_in_ip' + t.string 'confirmation_token' + t.datetime 'confirmed_at' + t.datetime 'confirmation_sent_at' + t.string 'unconfirmed_email' + t.string 'name', null: false + t.string 'nickname' + t.string 'email' + t.json 'tokens' + t.integer 'account_id', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.string 'pubsub_token' + t.integer 'role', default: 0 + t.bigint 'inviter_id' + t.index ['email'], name: 'index_users_on_email' + t.index ['inviter_id'], name: 'index_users_on_inviter_id' + t.index ['pubsub_token'], name: 'index_users_on_pubsub_token', unique: true + t.index ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true + t.index %w[uid provider], name: 'index_users_on_uid_and_provider', unique: true end - add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" - add_foreign_key "contact_inboxes", "contacts" - add_foreign_key "contact_inboxes", "inboxes" - add_foreign_key "conversations", "contact_inboxes" - add_foreign_key "messages", "contacts" - add_foreign_key "users", "users", column: "inviter_id", on_delete: :nullify + create_table 'webhooks', force: :cascade do |t| + t.integer 'account_id' + t.integer 'inbox_id' + t.string 'urls' + t.datetime 'created_at', precision: 6, null: false + t.datetime 'updated_at', precision: 6, null: false + end + + add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id' + add_foreign_key 'contact_inboxes', 'contacts' + add_foreign_key 'contact_inboxes', 'inboxes' + add_foreign_key 'conversations', 'contact_inboxes' + add_foreign_key 'messages', 'contacts' + add_foreign_key 'users', 'users', column: 'inviter_id', on_delete: :nullify end diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb new file mode 100644 index 000000000..57020e299 --- /dev/null +++ b/lib/webhooks/trigger.rb @@ -0,0 +1,7 @@ +class Webhooks::Trigger + def self.execute(url, payload) + RestClient.post(url, payload) + rescue StandardError => e + Raven.capture_exception(e) + end +end diff --git a/spec/controllers/api/v1/inbox/webhook_controller_spec.rb b/spec/controllers/api/v1/inbox/webhook_controller_spec.rb new file mode 100644 index 000000000..fdd5eb502 --- /dev/null +++ b/spec/controllers/api/v1/inbox/webhook_controller_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +RSpec.describe 'Webhooks API', type: :request do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:webhook) { create(:webhook, account: account, inbox: inbox, urls: ['https://hello.com']) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + describe 'GET /api/v1/inbox/webhooks' do + context 'when it is an authenticated agent' do + it 'returns unauthorized' do + get '/api/v1/inbox/webhooks', + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin user' do + it 'gets all webhook' do + get '/api/v1/inbox/webhooks', + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)['payload']['webhooks'].count).to eql account.webhooks.count + end + end + end + + describe 'POST /api/v1/inbox/webhooks' do + context 'when it is an authenticated agent' do + it 'returns unauthorized' do + post '/api/v1/inbox/webhooks', + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin user' do + it 'creates webhook' do + post '/api/v1/inbox/webhooks', + params: { account_id: account.id, inbox_id: inbox.id, urls: ['https://hello.com'] }, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + expect(JSON.parse(response.body)['payload']['webhook']['urls']).to eql ['https://hello.com'] + end + end + end + + describe 'PUT /api/v1/inbox/webhooks/:id' do + context 'when it is an authenticated agent' do + it 'returns unauthorized' do + put "/api/v1/inbox/webhooks/#{webhook.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin user' do + it 'updates webhook' do + put "/api/v1/inbox/webhooks/#{webhook.id}", + params: { urls: ['https://hello.com', 'https://world.com'] }, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)['payload']['webhook']['urls']).to eql ['https://hello.com', 'https://world.com'] + end + end + end + + describe 'DELETE /api/v1/inbox/webhooks/:id' do + context 'when it is an authenticated agent' do + it 'returns unauthorized' do + delete "/api/v1/inbox/webhooks/#{webhook.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin user' do + it 'deletes webhook' do + delete "/api/v1/inbox/webhooks/#{webhook.id}", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(account.webhooks.count).to be 0 + end + end + end +end diff --git a/spec/factories/webhooks.rb b/spec/factories/webhooks.rb new file mode 100644 index 000000000..5cb322526 --- /dev/null +++ b/spec/factories/webhooks.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :webhook do + account_id { 1 } + inbox_id { 1 } + urls { ['MyString'] } + end +end diff --git a/spec/jobs/webhook_job_spec.rb b/spec/jobs/webhook_job_spec.rb new file mode 100644 index 000000000..4d8eeff97 --- /dev/null +++ b/spec/jobs/webhook_job_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe WebhookJob, type: :job do + subject(:job) { described_class.perform_later(url, payload) } + + let(:url) { 'https://test.com' } + let(:payload) { { name: 'test' } } + + it 'queues the job' do + expect { job }.to have_enqueued_job(described_class) + .with(url, payload) + .on_queue('webhooks') + end +end diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb new file mode 100644 index 000000000..17564510e --- /dev/null +++ b/spec/lib/webhooks/trigger_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe Webhooks::Trigger do + subject(:trigger) { described_class } + + describe '#execute' do + it 'triggers webhook' do + params = { hello: 'hello' } + url = 'htpps://test.com' + + expect(RestClient).to receive(:post).with(url, params).once + trigger.execute(url, params) + end + end +end diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb new file mode 100644 index 000000000..e65497006 --- /dev/null +++ b/spec/listeners/webhook_listener_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' +describe WebhookListener do + let(:listener) { described_class.instance } + let!(:account) { create(:account) } + let(:report_identity) { Reports::UpdateAccountIdentity.new(account, Time.zone.now) } + let!(:user) { create(:user, account: account) } + let!(:inbox) { create(:inbox, account: account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) } + let!(:message) do + create(:message, message_type: 'outgoing', + account: account, inbox: inbox, conversation: conversation) + end + let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) } + + describe '#message_created' do + let(:event_name) { :'conversation.created' } + + context 'when webhook is not configured' do + it 'does not trigger webhook' do + expect(RestClient).to receive(:post).exactly(0).times + listener.message_created(event) + end + end + + context 'when webhook is configured' do + it 'triggers webhook' do + create(:webhook, inbox: inbox, account: account) + expect(WebhookJob).to receive(:perform_later).once + listener.message_created(event) + end + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index dddc4d679..84dcf2677 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -14,4 +14,5 @@ RSpec.describe Account do it { is_expected.to have_many(:facebook_pages).class_name('::Channel::FacebookPage').dependent(:destroy) } it { is_expected.to have_many(:web_widgets).class_name('::Channel::WebWidget').dependent(:destroy) } it { is_expected.to have_one(:subscription).dependent(:destroy) } + it { is_expected.to have_many(:webhooks).dependent(:destroy) } end diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb index 7773a73d3..5fc166cb0 100644 --- a/spec/models/inbox_spec.rb +++ b/spec/models/inbox_spec.rb @@ -23,6 +23,7 @@ RSpec.describe Inbox do it { is_expected.to have_many(:conversations).dependent(:destroy) } it { is_expected.to have_many(:messages).through(:conversations) } + it { is_expected.to have_one(:webhook) } end describe '#add_member' do diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb new file mode 100644 index 000000000..7ac53419c --- /dev/null +++ b/spec/models/webhook_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe Webhook, type: :model do + describe 'validations' do + it { is_expected.to validate_presence_of(:account_id) } + it { is_expected.to validate_presence_of(:inbox_id) } + end + + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:inbox) } + end +end