diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb new file mode 100644 index 000000000..f6162d2d0 --- /dev/null +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -0,0 +1,28 @@ +class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController + before_action :campaign, except: [:index, :create] + before_action :check_authorization + + def index + @campaigns = Current.account.campaigns + end + + def create + @campaign = Current.account.campaigns.create!(campaign_params) + end + + def show; end + + def update + @campaign.update(campaign_params) + end + + private + + def campaign + @campaign ||= Current.account.campaigns.find_by(display_id: params[:id]) + end + + def campaign_params + params.require(:campaign).permit(:title, :description, :content, :enabled, :inbox_id, :sender_id, trigger_rules: {}) + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index c8d01a1c7..014e213d4 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -11,6 +11,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq end + def campaigns + @campaigns = @inbox.campaigns + end + def create ActiveRecord::Base.transaction do channel = create_channel diff --git a/app/models/account.rb b/app/models/account.rb index 35a731aba..b7a023bda 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -36,6 +36,7 @@ class Account < ApplicationRecord has_many :data_imports, dependent: :destroy has_many :users, through: :account_users has_many :inboxes, dependent: :destroy + has_many :campaigns, dependent: :destroy has_many :conversations, dependent: :destroy has_many :messages, dependent: :destroy has_many :contacts, dependent: :destroy @@ -104,4 +105,8 @@ class Account < ApplicationRecord trigger.after(:insert).for_each(:row) do "execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);" end + + trigger.name('camp_dpid_before_insert').after(:insert).for_each(:row) do + "execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);" + end end diff --git a/app/models/campaign.rb b/app/models/campaign.rb new file mode 100644 index 000000000..71cf58ce7 --- /dev/null +++ b/app/models/campaign.rb @@ -0,0 +1,49 @@ +# == Schema Information +# +# Table name: campaigns +# +# id :bigint not null, primary key +# content :text not null +# description :text +# enabled :boolean default(TRUE) +# title :string not null +# trigger_rules :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# display_id :integer not null +# inbox_id :bigint not null +# sender_id :integer +# +# Indexes +# +# index_campaigns_on_account_id (account_id) +# index_campaigns_on_inbox_id (inbox_id) +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# fk_rails_... (inbox_id => inboxes.id) +# +class Campaign < ApplicationRecord + validates :account_id, presence: true + validates :inbox_id, presence: true + belongs_to :account + belongs_to :inbox + belongs_to :sender, class_name: 'User', optional: true + + has_many :conversations, dependent: :nullify, autosave: true + + after_commit :set_display_id, unless: :display_id? + + private + + def set_display_id + reload + end + + # creating db triggers + trigger.before(:insert).for_each(:row) do + "NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);" + end +end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 40272846c..3ac8b0d6c 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -14,6 +14,7 @@ # updated_at :datetime not null # account_id :integer not null # assignee_id :integer +# campaign_id :bigint # contact_id :bigint # contact_inbox_id :bigint # display_id :integer not null @@ -24,11 +25,13 @@ # # index_conversations_on_account_id (account_id) # index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE +# index_conversations_on_campaign_id (campaign_id) # index_conversations_on_contact_inbox_id (contact_inbox_id) # index_conversations_on_team_id (team_id) # # Foreign Keys # +# fk_rails_... (campaign_id => campaigns.id) # fk_rails_... (contact_inbox_id => contact_inboxes.id) # fk_rails_... (team_id => teams.id) # @@ -54,6 +57,7 @@ class Conversation < ApplicationRecord belongs_to :contact belongs_to :contact_inbox belongs_to :team, optional: true + belongs_to :campaign, optional: true has_many :messages, dependent: :destroy, autosave: true diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 398af0352..e1b330bbf 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -36,6 +36,7 @@ class Inbox < ApplicationRecord belongs_to :channel, polymorphic: true, dependent: :destroy + has_many :campaigns, dependent: :destroy has_many :contact_inboxes, dependent: :destroy has_many :contacts, through: :contact_inboxes diff --git a/app/policies/campaign_policy.rb b/app/policies/campaign_policy.rb new file mode 100644 index 000000000..b210fdd99 --- /dev/null +++ b/app/policies/campaign_policy.rb @@ -0,0 +1,21 @@ +class CampaignPolicy < ApplicationPolicy + def index? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def show? + @account_user.administrator? + end + + def create? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb index 2968129db..927ff9435 100644 --- a/app/policies/inbox_policy.rb +++ b/app/policies/inbox_policy.rb @@ -27,6 +27,10 @@ class InboxPolicy < ApplicationPolicy true end + def campaigns? + @account_user.administrator? + end + def create? @account_user.administrator? end diff --git a/app/views/api/v1/accounts/campaigns/create.json.jbuilder b/app/views/api/v1/accounts/campaigns/create.json.jbuilder new file mode 100644 index 000000000..bd136a8a2 --- /dev/null +++ b/app/views/api/v1/accounts/campaigns/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/campaign.json.jbuilder', resource: @campaign diff --git a/app/views/api/v1/accounts/campaigns/index.json.jbuilder b/app/views/api/v1/accounts/campaigns/index.json.jbuilder new file mode 100644 index 000000000..c0e90acd7 --- /dev/null +++ b/app/views/api/v1/accounts/campaigns/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @campaigns do |campaign| + json.partial! 'api/v1/models/campaign.json.jbuilder', resource: campaign +end diff --git a/app/views/api/v1/accounts/campaigns/show.json.jbuilder b/app/views/api/v1/accounts/campaigns/show.json.jbuilder new file mode 100644 index 000000000..bd136a8a2 --- /dev/null +++ b/app/views/api/v1/accounts/campaigns/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/campaign.json.jbuilder', resource: @campaign diff --git a/app/views/api/v1/accounts/campaigns/update.json.jbuilder b/app/views/api/v1/accounts/campaigns/update.json.jbuilder new file mode 100644 index 000000000..bd136a8a2 --- /dev/null +++ b/app/views/api/v1/accounts/campaigns/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/campaign.json.jbuilder', resource: @campaign diff --git a/app/views/api/v1/accounts/inboxes/campaigns.json.jbuilder b/app/views/api/v1/accounts/inboxes/campaigns.json.jbuilder new file mode 100644 index 000000000..c0e90acd7 --- /dev/null +++ b/app/views/api/v1/accounts/inboxes/campaigns.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @campaigns do |campaign| + json.partial! 'api/v1/models/campaign.json.jbuilder', resource: campaign +end diff --git a/app/views/api/v1/models/_campaign.json.jbuilder b/app/views/api/v1/models/_campaign.json.jbuilder new file mode 100644 index 000000000..836751728 --- /dev/null +++ b/app/views/api/v1/models/_campaign.json.jbuilder @@ -0,0 +1,15 @@ +json.id resource.display_id +json.content resource.content +json.description resource.description +json.enabled resource.enabled +json.title resource.title +json.trigger_rules resource.trigger_rules +json.account_id resource.account_id +json.inbox do + json.partial! 'api/v1/models/inbox.json.jbuilder', resource: resource.inbox +end +json.sender do + json.partial! 'api/v1/models/agent.json.jbuilder', resource: resource.sender if resource.sender.present? +end +json.created_at resource.created_at +json.updated_at resource.updated_at diff --git a/config/routes.rb b/config/routes.rb index 74a778d55..288dae9ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,8 @@ Rails.application.routes.draw do end end resources :canned_responses, except: [:show, :edit, :new] + resources :campaigns, only: [:index, :create, :show, :update] + namespace :channels do resource :twilio_channel, only: [:create] end @@ -89,6 +91,7 @@ Rails.application.routes.draw do resources :inboxes, only: [:index, :create, :update, :destroy] do get :assignable_agents, on: :member + get :campaigns, on: :member post :set_agent_bot, on: :member end resources :inbox_members, only: [:create, :show], param: :inbox_id diff --git a/db/migrate/20210428135041_campaigns.rb b/db/migrate/20210428135041_campaigns.rb new file mode 100644 index 000000000..cdd352d9f --- /dev/null +++ b/db/migrate/20210428135041_campaigns.rb @@ -0,0 +1,18 @@ +class Campaigns < ActiveRecord::Migration[6.0] + def change + create_table :campaigns do |t| + t.integer :display_id, null: false + t.string :title, null: false + t.text :description + t.text :content, null: false + t.integer :sender_id + t.boolean :enabled, default: true + t.references :account, null: false, foreign_key: true + t.references :inbox, null: false, foreign_key: true + t.column :trigger_rules, :jsonb, default: {} + t.timestamps + end + + add_reference :conversations, :campaign, foreign_key: true + end +end diff --git a/db/migrate/20210428151147_create_triggers_accounts_insert_or_campaigns_insert.rb b/db/migrate/20210428151147_create_triggers_accounts_insert_or_campaigns_insert.rb new file mode 100644 index 000000000..3fdc4f18e --- /dev/null +++ b/db/migrate/20210428151147_create_triggers_accounts_insert_or_campaigns_insert.rb @@ -0,0 +1,28 @@ +# This migration was auto-generated via `rake db:generate_trigger_migration'. +# While you can edit this file, any changes you make to the definitions here +# will be undone by the next auto-generated trigger migration. + +class CreateTriggersAccountsInsertOrCampaignsInsert < ActiveRecord::Migration[6.0] + def up + create_trigger('camp_dpid_before_insert', generated: true, compatibility: 1) + .on('accounts') + .name('camp_dpid_before_insert') + .after(:insert) + .for_each(:row) do + "execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);" + end + + create_trigger('campaigns_before_insert_row_tr', generated: true, compatibility: 1) + .on('campaigns') + .before(:insert) + .for_each(:row) do + "NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);" + end + end + + def down + drop_trigger('camp_dpid_before_insert', 'accounts', generated: true) + + drop_trigger('campaigns_before_insert_row_tr', 'campaigns', generated: true) + end +end diff --git a/db/schema.rb b/db/schema.rb index a4af968d3..331a7609c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_04_26_191914) do +ActiveRecord::Schema.define(version: 2021_04_28_151147) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -113,6 +113,22 @@ ActiveRecord::Schema.define(version: 2021_04_26_191914) do t.string "extension" end + create_table "campaigns", force: :cascade do |t| + t.integer "display_id", null: false + t.string "title", null: false + t.text "description" + t.text "content", null: false + t.integer "sender_id" + t.boolean "enabled", default: true + t.bigint "account_id", null: false + t.bigint "inbox_id", null: false + t.jsonb "trigger_rules", default: {} + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id"], name: "index_campaigns_on_account_id" + t.index ["inbox_id"], name: "index_campaigns_on_inbox_id" + end + create_table "canned_responses", id: :serial, force: :cascade do |t| t.integer "account_id", null: false t.string "short_code" @@ -235,8 +251,10 @@ ActiveRecord::Schema.define(version: 2021_04_26_191914) do t.string "identifier" t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "team_id" + t.bigint "campaign_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 ["campaign_id"], name: "index_conversations_on_campaign_id" t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id" t.index ["team_id"], name: "index_conversations_on_team_id" end @@ -592,8 +610,11 @@ ActiveRecord::Schema.define(version: 2021_04_26_191914) do add_foreign_key "account_users", "accounts" add_foreign_key "account_users", "users" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "campaigns", "accounts" + add_foreign_key "campaigns", "inboxes" add_foreign_key "contact_inboxes", "contacts" add_foreign_key "contact_inboxes", "inboxes" + add_foreign_key "conversations", "campaigns" add_foreign_key "conversations", "contact_inboxes" add_foreign_key "conversations", "teams" add_foreign_key "data_imports", "accounts" @@ -614,4 +635,19 @@ ActiveRecord::Schema.define(version: 2021_04_26_191914) do "NEW.display_id := nextval('conv_dpid_seq_' || NEW.account_id);" end + create_trigger("camp_dpid_before_insert", :generated => true, :compatibility => 1). + on("accounts"). + name("camp_dpid_before_insert"). + after(:insert). + for_each(:row) do + "execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);" + end + + create_trigger("campaigns_before_insert_row_tr", :generated => true, :compatibility => 1). + on("campaigns"). + before(:insert). + for_each(:row) do + "NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);" + end + end diff --git a/spec/controllers/api/v1/accounts/campaigns_controller_spec.rb b/spec/controllers/api/v1/accounts/campaigns_controller_spec.rb new file mode 100644 index 000000000..2e2936884 --- /dev/null +++ b/spec/controllers/api/v1/accounts/campaigns_controller_spec.rb @@ -0,0 +1,148 @@ +require 'rails_helper' + +RSpec.describe 'Campaigns API', type: :request do + let(:account) { create(:account) } + + describe 'GET /api/v1/accounts/{account.id}/campaigns' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/campaigns" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let!(:campaign) { create(:campaign, account: account) } + + it 'returns unauthorized for agents' do + get "/api/v1/accounts/#{account.id}/campaigns" + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns all campaigns to administrators' do + get "/api/v1/accounts/#{account.id}/campaigns", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body, symbolize_names: true) + expect(body.first[:id]).to eq(campaign.display_id) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/campaigns/:id' do + let(:campaign) { create(:campaign, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/campaigns/#{campaign.display_id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agents' do + get "/api/v1/accounts/#{account.id}/campaigns/#{campaign.display_id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'shows the campaign for administrators' do + get "/api/v1/accounts/#{account.id}/campaigns/#{campaign.display_id}", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(campaign.display_id) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/campaigns' do + let(:inbox) { create(:inbox, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/campaigns", + params: { inbox_id: inbox.id, title: 'test', content: 'test message' }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agents' do + post "/api/v1/accounts/#{account.id}/campaigns", + params: { inbox_id: inbox.id, title: 'test', content: 'test message' }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a new campaign' do + post "/api/v1/accounts/#{account.id}/campaigns", + params: { inbox_id: inbox.id, title: 'test', content: 'test message' }, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:title]).to eq('test') + end + end + end + + describe 'PATCH /api/v1/accounts/{account.id}/campaigns/:id' do + let(:inbox) { create(:inbox, account: account) } + let!(:campaign) { create(:campaign, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/campaigns/#{campaign.display_id}", + params: { inbox_id: inbox.id, title: 'test', content: 'test message' }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agents' do + patch "/api/v1/accounts/#{account.id}/campaigns/#{campaign.display_id}", + params: { inbox_id: inbox.id, title: 'test', content: 'test message' }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'updates the campaign' do + patch "/api/v1/accounts/#{account.id}/campaigns/#{campaign.display_id}", + params: { inbox_id: inbox.id, title: 'test', content: 'test message' }, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:title]).to eq('test') + end + end + end +end diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index 76d2d9f00..5efa8049c 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -74,6 +74,44 @@ RSpec.describe 'Inboxes API', type: :request do end end + describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/campaigns' do + let(:inbox) { create(:inbox, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/campaigns" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + let!(:campaign) { create(:campaign, account: account, inbox: inbox) } + + it 'returns unauthorized for agents' do + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/campaigns" + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns all campaigns belonging to the inbox to administrators' do + # create a random campaign + create(:campaign, account: account) + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/campaigns", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body, symbolize_names: true) + expect(body.first[:id]).to eq(campaign.display_id) + expect(body.length).to eq(1) + end + end + end + describe 'DELETE /api/v1/accounts/{account.id}/inboxes/:id' do let(:inbox) { create(:inbox, account: account) } diff --git a/spec/factories/campaigns.rb b/spec/factories/campaigns.rb new file mode 100644 index 000000000..bdd84efc9 --- /dev/null +++ b/spec/factories/campaigns.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :campaign do + sequence(:title) { |n| "Campaign #{n}" } + sequence(:content) { |n| "Campaign content #{n}" } + after(:build) do |campaign| + campaign.account ||= create(:account) + campaign.inbox ||= create( + :inbox, + account: campaign.account, + channel: create(:channel_widget, account: campaign.account) + ) + end + end +end diff --git a/spec/models/campaign_spec.rb b/spec/models/campaign_spec.rb new file mode 100644 index 000000000..30fa1a43d --- /dev/null +++ b/spec/models/campaign_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Campaign, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:inbox) } + end + + describe '.before_create' do + let(:campaign) { build(:campaign, display_id: nil) } + + before do + campaign.save + campaign.reload + end + + it 'runs before_create callbacks' do + expect(campaign.display_id).to eq(1) + end + end +end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 065cc9999..55067578e 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -7,6 +7,7 @@ require Rails.root.join 'spec/models/concerns/round_robin_handler_spec.rb' RSpec.describe Conversation, type: :model do describe 'associations' do it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:inbox) } end describe 'concerns' do