From 93f18315ccde5133d67e8f84e7846eb22533493f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 14 Jul 2025 16:12:38 +0530 Subject: [PATCH] feat: add `Captain::Scenario` Model and API [CW-4597] (#11907) Co-authored-by: Muhsin Keloth --- config/routes.rb | 1 + ...20250710145708_create_captain_scenarios.rb | 18 ++ db/schema.rb | 18 +- .../accounts/captain/scenarios_controller.rb | 47 ++++ enterprise/app/models/captain/assistant.rb | 1 + enterprise/app/models/captain/scenario.rb | 44 +++ .../app/policies/captain/scenario_policy.rb | 21 ++ .../captain/scenarios/create.json.jbuilder | 1 + .../captain/scenarios/index.json.jbuilder | 5 + .../captain/scenarios/show.json.jbuilder | 1 + .../captain/scenarios/update.json.jbuilder | 1 + .../v1/models/captain/_scenario.json.jbuilder | 16 ++ .../captain/scenarios_controller_spec.rb | 258 ++++++++++++++++++ .../models/captain/scenario_spec.rb | 63 +++++ spec/factories/captain/scenario.rb | 11 + 15 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250710145708_create_captain_scenarios.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb create mode 100644 enterprise/app/models/captain/scenario.rb create mode 100644 enterprise/app/policies/captain/scenario_policy.rb create mode 100644 enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb create mode 100644 spec/enterprise/models/captain/scenario_spec.rb create mode 100644 spec/factories/captain/scenario.rb diff --git a/config/routes.rb b/config/routes.rb index 41ee52f42..4aad60039 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,6 +57,7 @@ Rails.application.routes.draw do post :playground end resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id + resources :scenarios end resources :assistant_responses resources :bulk_actions, only: [:create] diff --git a/db/migrate/20250710145708_create_captain_scenarios.rb b/db/migrate/20250710145708_create_captain_scenarios.rb new file mode 100644 index 000000000..a922bd478 --- /dev/null +++ b/db/migrate/20250710145708_create_captain_scenarios.rb @@ -0,0 +1,18 @@ +class CreateCaptainScenarios < ActiveRecord::Migration[7.1] + def change + create_table :captain_scenarios do |t| + t.string :title + t.text :description + t.text :instruction + t.jsonb :tools, default: [] + t.boolean :enabled, default: true, null: false + t.references :assistant, null: false + t.references :account, null: false + + t.timestamps + end + + add_index :captain_scenarios, :enabled + add_index :captain_scenarios, [:assistant_id, :enabled] + end +end diff --git a/db/schema.rb b/db/schema.rb index af0c89086..837eb58bd 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[7.1].define(version: 2025_06_27_195529) do +ActiveRecord::Schema[7.1].define(version: 2025_07_10_145708) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -305,6 +305,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id" end + create_table "captain_scenarios", force: :cascade do |t| + t.string "title" + t.text "description" + t.text "instruction" + t.jsonb "tools", default: [] + t.boolean "enabled", default: true, null: false + t.bigint "assistant_id", null: false + t.bigint "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_captain_scenarios_on_account_id" + t.index ["assistant_id", "enabled"], name: "index_captain_scenarios_on_assistant_id_and_enabled" + t.index ["assistant_id"], name: "index_captain_scenarios_on_assistant_id" + t.index ["enabled"], name: "index_captain_scenarios_on_enabled" + end + create_table "categories", force: :cascade do |t| t.integer "account_id", null: false t.integer "portal_id", null: false diff --git a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb new file mode 100644 index 000000000..7c329053b --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb @@ -0,0 +1,47 @@ +class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Scenario) } + before_action :set_assistant + before_action :set_scenario, only: [:show, :update, :destroy] + + def index + @scenarios = assistant_scenarios.enabled + end + + def show; end + + def create + @scenario = assistant_scenarios.create!(scenario_params.merge(account: Current.account)) + end + + def update + @scenario.update!(scenario_params) + end + + def destroy + @scenario.destroy + head :no_content + end + + private + + def set_assistant + @assistant = account_assistants.find(params[:assistant_id]) + end + + def account_assistants + @account_assistants ||= Current.account.captain_assistants + end + + def set_scenario + @scenario = assistant_scenarios.find(params[:id]) + end + + def assistant_scenarios + @assistant.scenarios + end + + def scenario_params + params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: []) + end +end \ No newline at end of file diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 40cf99df9..8f1227231 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -30,6 +30,7 @@ class Captain::Assistant < ApplicationRecord through: :captain_inboxes has_many :messages, as: :sender, dependent: :nullify has_many :copilot_threads, dependent: :destroy_async + has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async validates :name, presence: true validates :description, presence: true diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb new file mode 100644 index 000000000..ff8e63c64 --- /dev/null +++ b/enterprise/app/models/captain/scenario.rb @@ -0,0 +1,44 @@ +# == Schema Information +# +# Table name: captain_scenarios +# +# id :bigint not null, primary key +# description :text +# enabled :boolean default(TRUE), not null +# instruction :text +# title :string +# tools :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# +# Indexes +# +# index_captain_scenarios_on_account_id (account_id) +# index_captain_scenarios_on_assistant_id (assistant_id) +# index_captain_scenarios_on_assistant_id_and_enabled (assistant_id,enabled) +# index_captain_scenarios_on_enabled (enabled) +# +class Captain::Scenario < ApplicationRecord + self.table_name = 'captain_scenarios' + + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :account + + validates :title, presence: true + validates :description, presence: true + validates :instruction, presence: true + validates :assistant_id, presence: true + validates :account_id, presence: true + + scope :enabled, -> { where(enabled: true) } + + before_save :populate_tools + + private + + def populate_tools + # TODO: Implement tools population logic + end +end diff --git a/enterprise/app/policies/captain/scenario_policy.rb b/enterprise/app/policies/captain/scenario_policy.rb new file mode 100644 index 000000000..82c5d236b --- /dev/null +++ b/enterprise/app/policies/captain/scenario_policy.rb @@ -0,0 +1,21 @@ +class Captain::ScenarioPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end \ No newline at end of file diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder new file mode 100644 index 000000000..98566b10f --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/scenario', scenario: @scenario \ No newline at end of file diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder new file mode 100644 index 000000000..60e97a017 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder @@ -0,0 +1,5 @@ +json.data do + json.array! @scenarios do |scenario| + json.partial! 'api/v1/models/captain/scenario', scenario: scenario + end +end \ No newline at end of file diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder new file mode 100644 index 000000000..98566b10f --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/scenario', scenario: @scenario \ No newline at end of file diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder new file mode 100644 index 000000000..98566b10f --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/scenario', scenario: @scenario \ No newline at end of file diff --git a/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder new file mode 100644 index 000000000..ae78c7bb8 --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder @@ -0,0 +1,16 @@ +json.id scenario.id +json.title scenario.title +json.description scenario.description +json.instruction scenario.instruction +json.tools scenario.tools +json.enabled scenario.enabled +json.assistant_id scenario.assistant_id +json.account_id scenario.account_id +json.created_at scenario.created_at +json.updated_at scenario.updated_at +if scenario.assistant.present? + json.assistant do + json.id scenario.assistant.id + json.name scenario.assistant.name + end +end \ No newline at end of file diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb new file mode 100644 index 000000000..c66571921 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb @@ -0,0 +1,258 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:assistant) { create(:captain_assistant, account: account) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios' do + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status' do + create_list(:captain_scenario, 3, assistant: assistant, account: account) + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:data].length).to eq(3) + end + end + + context 'when it is an admin' do + it 'returns success status and scenarios' do + create_list(:captain_scenario, 5, assistant: assistant, account: account) + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:data].length).to eq(5) + end + + it 'returns only enabled scenarios' do + create(:captain_scenario, assistant: assistant, account: account, enabled: true) + create(:captain_scenario, assistant: assistant, account: account, enabled: false) + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:data].length).to eq(1) + expect(json_response[:data].first[:enabled]).to be(true) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do + let(:scenario) { create(:captain_scenario, assistant: assistant, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status and scenario' do + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:id]).to eq(scenario.id) + expect(json_response[:title]).to eq(scenario.title) + end + end + + context 'when scenario does not exist' do + it 'returns not found status' do + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/999999", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios' do + let(:valid_attributes) do + { + scenario: { + title: 'Test Scenario', + description: 'Test description', + instruction: 'Test instruction', + enabled: true, + tools: %w[tool1 tool2] + } + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios", + params: valid_attributes + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios", + params: valid_attributes, + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'creates a new scenario and returns success status' do + expect do + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios", + params: valid_attributes, + headers: admin.create_new_auth_token, + as: :json + end.to change(Captain::Scenario, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json_response[:title]).to eq('Test Scenario') + expect(json_response[:description]).to eq('Test description') + expect(json_response[:enabled]).to be(true) + expect(json_response[:assistant_id]).to eq(assistant.id) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + scenario: { + title: '', + description: '', + instruction: '' + } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios", + params: invalid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + describe 'PATCH /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do + let(:scenario) { create(:captain_scenario, assistant: assistant, account: account) } + let(:update_attributes) do + { + scenario: { + title: 'Updated Scenario Title', + enabled: false + } + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}", + params: update_attributes + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}", + params: update_attributes, + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'updates the scenario and returns success status' do + patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}", + params: update_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:title]).to eq('Updated Scenario Title') + expect(json_response[:enabled]).to be(false) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + scenario: { + title: '' + } + } + end + + it 'returns unprocessable entity status' do + patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}", + params: invalid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + describe 'DELETE /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do + let!(:scenario) { create(:captain_scenario, assistant: assistant, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}", + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'deletes the scenario and returns no content status' do + expect do + delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}", + headers: admin.create_new_auth_token + end.to change(Captain::Scenario, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + context 'when scenario does not exist' do + it 'returns not found status' do + delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/999999", + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + end + end +end \ No newline at end of file diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb new file mode 100644 index 000000000..f944373e2 --- /dev/null +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe Captain::Scenario, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') } + it { is_expected.to belong_to(:account) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:description) } + it { is_expected.to validate_presence_of(:instruction) } + it { is_expected.to validate_presence_of(:assistant_id) } + it { is_expected.to validate_presence_of(:account_id) } + end + + describe 'scopes' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + describe '.enabled' do + it 'returns only enabled scenarios' do + enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true) + disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false) + + expect(described_class.enabled).to include(enabled_scenario) + expect(described_class.enabled).not_to include(disabled_scenario) + end + end + end + + describe 'callbacks' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + describe 'before_save :populate_tools' do + it 'calls populate_tools before saving' do + scenario = build(:captain_scenario, assistant: assistant, account: account) + expect(scenario).to receive(:populate_tools) + scenario.save + end + end + end + + describe 'factory' do + it 'creates a valid scenario with associations' do + account = create(:account) + assistant = create(:captain_assistant, account: account) + scenario = build(:captain_scenario, assistant: assistant, account: account) + expect(scenario).to be_valid + end + + it 'creates a scenario with all required attributes' do + scenario = create(:captain_scenario) + expect(scenario.title).to be_present + expect(scenario.description).to be_present + expect(scenario.instruction).to be_present + expect(scenario.enabled).to be true + expect(scenario.assistant).to be_present + expect(scenario.account).to be_present + end + end +end \ No newline at end of file diff --git a/spec/factories/captain/scenario.rb b/spec/factories/captain/scenario.rb new file mode 100644 index 000000000..fc9149bfa --- /dev/null +++ b/spec/factories/captain/scenario.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :captain_scenario, class: 'Captain::Scenario' do + sequence(:title) { |n| "Scenario #{n}" } + description { 'Test scenario description' } + instruction { 'Test scenario instruction for the assistant to follow' } + tools { [] } + enabled { true } + association :assistant, factory: :captain_assistant + association :account + end +end