From 0cee42a9f9faa1c6d6c89ee40a96c28f33b9f3bd Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Tue, 19 Jul 2022 17:37:00 +0530 Subject: [PATCH] feat: Macros CRUD api (#5047) --- .../api/v1/accounts/articles_controller.rb | 2 +- .../api/v1/accounts/macros_controller.rb | 51 +++++ app/models/account.rb | 3 +- app/models/macro.rb | 49 +++++ app/models/user.rb | 1 + app/policies/macro_policy.rb | 21 +++ .../v1/accounts/macros/create.json.jbuilder | 3 + .../v1/accounts/macros/index.json.jbuilder | 5 + .../api/v1/accounts/macros/show.json.jbuilder | 3 + .../v1/accounts/macros/update.json.jbuilder | 3 + app/views/api/v1/models/_macro.json.jbuilder | 18 ++ config/routes.rb | 1 + db/migrate/20220711090528_create_macros.rb | 14 ++ db/schema.rb | 16 ++ .../api/v1/accounts/macros_controller_spec.rb | 176 ++++++++++++++++++ spec/factories/macros.rb | 11 ++ spec/models/macro_spec.rb | 85 +++++++++ 17 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/v1/accounts/macros_controller.rb create mode 100644 app/models/macro.rb create mode 100644 app/policies/macro_policy.rb create mode 100644 app/views/api/v1/accounts/macros/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/macros/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/macros/show.json.jbuilder create mode 100644 app/views/api/v1/accounts/macros/update.json.jbuilder create mode 100644 app/views/api/v1/models/_macro.json.jbuilder create mode 100644 db/migrate/20220711090528_create_macros.rb create mode 100644 spec/controllers/api/v1/accounts/macros_controller_spec.rb create mode 100644 spec/factories/macros.rb create mode 100644 spec/models/macro_spec.rb diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 77a1fdf65..efe761501 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -46,7 +46,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def list_params params.require(:payload).permit( - :category_slug, :locale, :query + :category_slug, :locale, :query, :page ) end end diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb new file mode 100644 index 000000000..9a37faa4b --- /dev/null +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -0,0 +1,51 @@ +class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController + before_action :check_authorization + before_action :fetch_macro, only: [:show, :update, :destroy] + + def index + @macros = Macro.with_visibility(current_user, params) + end + + def create + @macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id)) + @macro.set_visibility(current_user, permitted_params) + @macro.actions = params[:actions] + + render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid? + + @macro.save! + end + + def show; end + + def destroy + @macro.destroy! + head :ok + end + + def update + ActiveRecord::Base.transaction do + @macro.update!(macros_with_user) + @macro.set_visibility(current_user, permitted_params) + @macro.save! + rescue StandardError => e + Rails.logger.error e + render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity + end + end + + def permitted_params + params.permit( + :name, :account_id, :visibility, + actions: [:action_name, { action_params: [] }] + ) + end + + def macros_with_user + permitted_params.merge(updated_by_id: current_user.id) + end + + def fetch_macro + @macro = Current.account.macros.find_by(id: params[:id]) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 678cc3445..d7c4718b0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -40,7 +40,8 @@ class Account < ApplicationRecord has_many :agent_bots, dependent: :destroy_async has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api' has_many :articles, dependent: :destroy_async, class_name: '::Article' - has_many :automation_rules, dependent: :destroy + has_many :automation_rules, dependent: :destroy_async + has_many :macros, dependent: :destroy_async has_many :campaigns, dependent: :destroy_async has_many :canned_responses, dependent: :destroy_async has_many :categories, dependent: :destroy_async, class_name: '::Category' diff --git a/app/models/macro.rb b/app/models/macro.rb new file mode 100644 index 000000000..2a6e44f5f --- /dev/null +++ b/app/models/macro.rb @@ -0,0 +1,49 @@ +# == Schema Information +# +# Table name: macros +# +# id :bigint not null, primary key +# actions :jsonb not null +# name :string not null +# visibility :integer default("user") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# created_by_id :bigint not null +# updated_by_id :bigint not null +# +# Indexes +# +# index_macros_on_account_id (account_id) +# index_macros_on_created_by_id (created_by_id) +# index_macros_on_updated_by_id (updated_by_id) +# +# Foreign Keys +# +# fk_rails_... (created_by_id => users.id) +# fk_rails_... (updated_by_id => users.id) +# +class Macro < ApplicationRecord + belongs_to :account + belongs_to :created_by, + class_name: :User + belongs_to :updated_by, + class_name: :User + enum visibility: { personal: 0, global: 1 } + + def set_visibility(user, params) + self.visibility = params[:visibility] + self.visibility = :personal if user.agent? + end + + def self.with_visibility(user, params) + records = user.administrator? ? Current.account.macros : Current.account.macros.global + records = records.or(personal.where(created_by_id: user.id)) if user.agent? + records.page(current_page(params)) + records + end + + def self.current_page(params) + params[:page] || 1 + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 1da1fb3b7..6ed95ddb3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -100,6 +100,7 @@ class User < ApplicationRecord class_name: :Portal, dependent: :nullify, source: :portal + has_many :macros, foreign_key: 'created_by_id', dependent: :destroy_async before_validation :set_password_and_uid, on: :create diff --git a/app/policies/macro_policy.rb b/app/policies/macro_policy.rb new file mode 100644 index 000000000..3ad4cd2de --- /dev/null +++ b/app/policies/macro_policy.rb @@ -0,0 +1,21 @@ +class MacroPolicy < ApplicationPolicy + def index? + true + end + + def create? + true + end + + def show? + true + end + + def update? + true + end + + def destroy? + true + end +end diff --git a/app/views/api/v1/accounts/macros/create.json.jbuilder b/app/views/api/v1/accounts/macros/create.json.jbuilder new file mode 100644 index 000000000..5c8ef098f --- /dev/null +++ b/app/views/api/v1/accounts/macros/create.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/macro.json.jbuilder', macro: @macro +end diff --git a/app/views/api/v1/accounts/macros/index.json.jbuilder b/app/views/api/v1/accounts/macros/index.json.jbuilder new file mode 100644 index 000000000..5f5c5a9c2 --- /dev/null +++ b/app/views/api/v1/accounts/macros/index.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.array! @macros do |macro| + json.partial! 'api/v1/models/macro.json.jbuilder', macro: macro + end +end diff --git a/app/views/api/v1/accounts/macros/show.json.jbuilder b/app/views/api/v1/accounts/macros/show.json.jbuilder new file mode 100644 index 000000000..5c8ef098f --- /dev/null +++ b/app/views/api/v1/accounts/macros/show.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/macro.json.jbuilder', macro: @macro +end diff --git a/app/views/api/v1/accounts/macros/update.json.jbuilder b/app/views/api/v1/accounts/macros/update.json.jbuilder new file mode 100644 index 000000000..5c8ef098f --- /dev/null +++ b/app/views/api/v1/accounts/macros/update.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/macro.json.jbuilder', macro: @macro +end diff --git a/app/views/api/v1/models/_macro.json.jbuilder b/app/views/api/v1/models/_macro.json.jbuilder new file mode 100644 index 000000000..f88d5922b --- /dev/null +++ b/app/views/api/v1/models/_macro.json.jbuilder @@ -0,0 +1,18 @@ +json.id macro.id +json.name macro.name +json.visibility macro.visibility + +if macro.created_by.present? + json.created_by do + json.partial! 'api/v1/models/agent.json.jbuilder', resource: macro.created_by + end +end + +if macro.updated_by.present? + json.updated_by do + json.partial! 'api/v1/models/agent.json.jbuilder', resource: macro.updated_by + end +end + +json.account_id macro.account_id +json.actions macro.actions diff --git a/config/routes.rb b/config/routes.rb index 6e31c4835..9e0acc196 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,6 +57,7 @@ Rails.application.routes.draw do post :clone post :attach_file, on: :collection end + resources :macros, only: [:index, :create, :show, :update, :destroy] resources :campaigns, only: [:index, :create, :show, :update, :destroy] resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy] namespace :channels do diff --git a/db/migrate/20220711090528_create_macros.rb b/db/migrate/20220711090528_create_macros.rb new file mode 100644 index 000000000..fcde5f9e3 --- /dev/null +++ b/db/migrate/20220711090528_create_macros.rb @@ -0,0 +1,14 @@ +class CreateMacros < ActiveRecord::Migration[6.1] + def change + create_table :macros do |t| + t.bigint :account_id, null: false + t.string :name, null: false + t.integer :visibility, default: 0 + t.references :created_by, null: false, index: true, foreign_key: { to_table: :users } + t.references :updated_by, null: false, index: true, foreign_key: { to_table: :users } + t.jsonb :actions, null: false, default: '{}' + t.timestamps + t.index :account_id, name: 'index_macros_on_account_id' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 67de25ec9..df5950031 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -563,6 +563,20 @@ ActiveRecord::Schema.define(version: 2022_07_18_123938) do t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true end + create_table "macros", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "name", null: false + t.integer "visibility", default: 0 + t.bigint "created_by_id", null: false + t.bigint "updated_by_id", null: false + t.jsonb "actions", default: "{}", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id"], name: "index_macros_on_account_id" + t.index ["created_by_id"], name: "index_macros_on_created_by_id" + t.index ["updated_by_id"], name: "index_macros_on_updated_by_id" + end + create_table "mentions", force: :cascade do |t| t.bigint "user_id", null: false t.bigint "conversation_id", null: false @@ -869,6 +883,8 @@ ActiveRecord::Schema.define(version: 2022_07_18_123938) do add_foreign_key "dashboard_apps", "accounts" add_foreign_key "dashboard_apps", "users" add_foreign_key "data_imports", "accounts", on_delete: :cascade + add_foreign_key "macros", "users", column: "created_by_id" + add_foreign_key "macros", "users", column: "updated_by_id" add_foreign_key "mentions", "conversations", on_delete: :cascade add_foreign_key "mentions", "users", on_delete: :cascade add_foreign_key "notes", "accounts", on_delete: :cascade diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb new file mode 100644 index 000000000..2da70ed1b --- /dev/null +++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb @@ -0,0 +1,176 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do + let(:account) { create(:account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:agent_1) { create(:user, account: account, role: :agent) } + + before do + create(:macro, account: account, created_by: administrator, updated_by: administrator, visibility: :global) + create(:macro, account: account, created_by: administrator, updated_by: administrator, visibility: :global) + create(:macro, account: account, created_by: administrator, updated_by: administrator, visibility: :personal) + create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :personal) + create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :personal) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) + end + + describe 'GET /api/v1/accounts/{account.id}/macros' do + context 'when it is an authenticated administrator' do + it 'returns all records in the account' do + get "/api/v1/accounts/#{account.id}/macros", + headers: administrator.create_new_auth_token + + visible_macros = account.macros + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body['payload'].length).to eq(visible_macros.count) + expect(body['payload'].first['id']).to eq(Macro.first.id) + expect(body['payload'].last['id']).to eq(Macro.last.id) + end + end + + context 'when it is an authenticated agent' do + it 'returns all records in account and created_by the agent' do + get "/api/v1/accounts/#{account.id}/macros", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + + body = JSON.parse(response.body) + visible_macros = account.macros.global.or(account.macros.personal.where(created_by_id: agent.id)) + + expect(body['payload'].length).to eq(visible_macros.count) + expect(body['payload'].first['id']).to eq(visible_macros.first.id) + expect(body['payload'].last['id']).to eq(visible_macros.last.id) + end + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/macros" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/macros' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/macros" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:params) do + { + 'name': 'Add label, send message and close the chat', + 'actions': [ + { + 'action_name': :add_label, + 'action_params': %w[support priority_customer] + }, + { + 'action_name': :send_message, + 'action_params': ['Welcome to the chatwoot platform.'] + }, + { + 'action_name': :resolved + } + ], + visibility: 'global', + created_by_id: administrator.id + }.with_indifferent_access + end + + it 'creates the macro' do + post "/api/v1/accounts/#{account.id}/macros", + params: params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + + expect(json_response['payload']['name']).to eql(params['name']) + expect(json_response['payload']['visibility']).to eql(params['visibility']) + expect(json_response['payload']['created_by']['id']).to eql(administrator.id) + end + + it 'sets visibility default to personal for agent' do + post "/api/v1/accounts/#{account.id}/macros", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + + expect(json_response['payload']['name']).to eql(params['name']) + expect(json_response['payload']['visibility']).to eql('personal') + expect(json_response['payload']['created_by']['id']).to eql(agent.id) + end + end + end + + describe 'PUT /api/v1/accounts/{account.id}/macros/{macro.id}' do + let!(:macro) { create(:macro, account: account, created_by: administrator, updated_by: administrator) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/macros/#{macro.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:params) do + { + 'name': 'Add label, send message and close the chat' + } + end + + it 'Updates the macro' do + put "/api/v1/accounts/#{account.id}/macros/#{macro.id}", + params: params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['name']).to eql(params['name']) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/macros/{macro.id}' do + let!(:macro) { create(:macro, account: account, created_by: administrator, updated_by: administrator) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/macros/#{macro.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'fetch the macro' do + get "/api/v1/accounts/#{account.id}/macros/#{macro.id}", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + + expect(json_response['payload']['name']).to eql(macro.name) + expect(json_response['payload']['created_by']['id']).to eql(administrator.id) + end + end + end +end diff --git a/spec/factories/macros.rb b/spec/factories/macros.rb new file mode 100644 index 000000000..1730f58ef --- /dev/null +++ b/spec/factories/macros.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :macro do + account + name { 'wrong_message_actions' } + actions do + [ + { 'action_name' => 'add_label', 'action_params' => %w[wrong_chat] } + ] + end + end +end diff --git a/spec/models/macro_spec.rb b/spec/models/macro_spec.rb new file mode 100644 index 000000000..d58a7d9da --- /dev/null +++ b/spec/models/macro_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +RSpec.describe Macro, type: :model do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:created_by) } + it { is_expected.to belong_to(:updated_by) } + end + + describe '#set_visibility' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:macro) { create(:macro, account: account, created_by: admin, updated_by: admin) } + + context 'when user is administrator' do + it 'set visibility with params' do + expect(macro.visibility).to eq('personal') + + macro.set_visibility(admin, { visibility: :global }) + + expect(macro.visibility).to eq('global') + + macro.set_visibility(admin, { visibility: :personal }) + + expect(macro.visibility).to eq('personal') + end + end + + context 'when user is agent' do + it 'set visibility always to agent' do + Current.user = agent + Current.account = account + + expect(macro.visibility).to eq('personal') + + macro.set_visibility(agent, { visibility: :global }) + + expect(macro.visibility).to eq('personal') + end + end + end + + describe '#with_visibility' do + let(:agent_1) { create(:user, account: account, role: :agent) } + let(:agent_2) { create(:user, account: account, role: :agent) } + + before do + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) + end + + context 'when user is administrator' do + it 'return all macros in account' do + Current.user = admin + Current.account = account + + expect(described_class.with_visibility(admin, {}).count).to eq(account.macros.count) + end + end + + context 'when user is agent' do + it 'return all macros in account and created_by user' do + Current.user = agent_2 + Current.account = account + + macros_for_agent_2 = account.macros.global.count + agent_2.macros.personal.count + expect(described_class.with_visibility(agent_2, {}).count).to eq(macros_for_agent_2) + + Current.user = agent_1 + + macros_for_agent_1 = account.macros.global.count + agent_1.macros.personal.count + expect(described_class.with_visibility(agent_1, {}).count).to eq(macros_for_agent_1) + end + end + end +end