diff --git a/db/migrate/20251003091242_create_captain_custom_tools.rb b/db/migrate/20251003091242_create_captain_custom_tools.rb new file mode 100644 index 000000000..9be8a9048 --- /dev/null +++ b/db/migrate/20251003091242_create_captain_custom_tools.rb @@ -0,0 +1,21 @@ +class CreateCaptainCustomTools < ActiveRecord::Migration[7.1] + def change + create_table :captain_custom_tools do |t| + t.references :account, null: false, index: true + t.string :slug, null: false + t.string :title, null: false + t.text :description + t.string :http_method, null: false, default: 'GET' + t.text :endpoint_url, null: false + t.text :request_template + t.text :response_template + t.string :auth_type, default: 'none' + t.jsonb :auth_config, default: {} + t.boolean :enabled, default: true, null: false + + t.timestamps + end + + add_index :captain_custom_tools, [:account_id, :slug], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d5f0c244c..c561051ca 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_09_17_012759) do +ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -323,6 +323,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do t.index ["account_id"], name: "index_captain_assistants_on_account_id" end + create_table "captain_custom_tools", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "slug", null: false + t.string "title", null: false + t.text "description" + t.string "http_method", default: "GET", null: false + t.text "endpoint_url", null: false + t.text "request_template" + t.text "response_template" + t.string "auth_type", default: "none" + t.jsonb "auth_config", default: {} + t.boolean "enabled", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true + t.index ["account_id"], name: "index_captain_custom_tools_on_account_id" + end + create_table "captain_documents", force: :cascade do |t| t.string "name" t.string "external_link", null: false diff --git a/enterprise/app/models/captain/custom_tool.rb b/enterprise/app/models/captain/custom_tool.rb new file mode 100644 index 000000000..8d52dc8ae --- /dev/null +++ b/enterprise/app/models/captain/custom_tool.rb @@ -0,0 +1,60 @@ +# == Schema Information +# +# Table name: captain_custom_tools +# +# id :bigint not null, primary key +# auth_config :jsonb +# auth_type :string default("none") +# description :text +# enabled :boolean default(TRUE), not null +# endpoint_url :text not null +# http_method :string default("GET"), not null +# request_template :text +# response_template :text +# slug :string not null +# title :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_captain_custom_tools_on_account_id (account_id) +# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE +# +class Captain::CustomTool < ApplicationRecord + self.table_name = 'captain_custom_tools' + + belongs_to :account + + enum :http_method, %w[GET POST].index_by(&:itself), validate: true + enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth + + before_validation :generate_slug + + validates :slug, presence: true, uniqueness: { scope: :account_id } + validates :title, presence: true + validates :endpoint_url, presence: true + + scope :enabled, -> { where(enabled: true) } + + private + + def generate_slug + return if slug.present? + + base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}" + self.slug = find_unique_slug(base_slug) + end + + def find_unique_slug(base_slug, counter = 0) + slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}" + return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate) + + slug_candidate + end + + def slug_exists?(candidate) + self.class.exists?(account_id: account_id, slug: candidate) + end +end diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb new file mode 100644 index 000000000..607fbd9e2 --- /dev/null +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -0,0 +1,146 @@ +require 'rails_helper' + +RSpec.describe Captain::CustomTool, type: :model do + describe 'associations' do + 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(:endpoint_url) } + it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) } + + it { + expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic', + 'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth) + } + + describe 'slug uniqueness' do + let(:account) { create(:account) } + + it 'validates uniqueness of slug scoped to account' do + create(:captain_custom_tool, account: account, slug: 'custom_test-tool') + duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test-tool') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:slug]).to include('has already been taken') + end + + it 'allows same slug across different accounts' do + account2 = create(:account) + create(:captain_custom_tool, account: account, slug: 'custom_test-tool') + different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test-tool') + + expect(different_account_tool).to be_valid + end + end + end + + describe 'scopes' do + let(:account) { create(:account) } + + describe '.enabled' do + it 'returns only enabled custom tools' do + enabled_tool = create(:captain_custom_tool, account: account, enabled: true) + disabled_tool = create(:captain_custom_tool, account: account, enabled: false) + + expect(described_class.enabled).to include(enabled_tool) + expect(described_class.enabled).not_to include(disabled_tool) + end + end + end + + describe 'slug generation' do + let(:account) { create(:account) } + + it 'generates slug from title on creation' do + tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status') + + expect(tool.slug).to eq('custom_fetch-order-status') + end + + it 'adds custom_ prefix to generated slug' do + tool = create(:captain_custom_tool, account: account, title: 'My Tool') + + expect(tool.slug).to start_with('custom_') + end + + it 'does not override manually set slug' do + tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug') + + expect(tool.slug).to eq('custom_manual-slug') + end + + it 'handles slug collisions by appending counter' do + create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') + tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool') + + expect(tool2.slug).to eq('custom_test-tool-1') + end + + it 'handles multiple slug collisions' do + create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') + create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool-1') + tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool') + + expect(tool3.slug).to eq('custom_test-tool-2') + end + + it 'generates slug with UUID when title is blank' do + tool = build(:captain_custom_tool, account: account, title: nil) + tool.valid? + + expect(tool.slug).to match(/^custom_[0-9a-f-]+$/) + end + + it 'parameterizes title correctly' do + tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!') + + expect(tool.slug).to eq('custom_fetch-order-status-details') + end + end + + describe 'factory' do + it 'creates a valid custom tool with default attributes' do + tool = create(:captain_custom_tool) + + expect(tool).to be_valid + expect(tool.title).to be_present + expect(tool.slug).to be_present + expect(tool.endpoint_url).to be_present + expect(tool.http_method).to eq('GET') + expect(tool.auth_type).to eq('none') + expect(tool.enabled).to be true + end + + it 'creates valid tool with POST trait' do + tool = create(:captain_custom_tool, :with_post) + + expect(tool.http_method).to eq('POST') + expect(tool.request_template).to be_present + end + + it 'creates valid tool with bearer auth trait' do + tool = create(:captain_custom_tool, :with_bearer_auth) + + expect(tool.auth_type).to eq('bearer') + expect(tool.auth_config['token']).to eq('test_bearer_token_123') + end + + it 'creates valid tool with basic auth trait' do + tool = create(:captain_custom_tool, :with_basic_auth) + + expect(tool.auth_type).to eq('basic') + expect(tool.auth_config['username']).to eq('test_user') + expect(tool.auth_config['password']).to eq('test_pass') + end + + it 'creates valid tool with api key trait' do + tool = create(:captain_custom_tool, :with_api_key) + + expect(tool.auth_type).to eq('api_key') + expect(tool.auth_config['key']).to eq('test_api_key') + expect(tool.auth_config['location']).to eq('header') + end + end +end diff --git a/spec/factories/captain/custom_tool.rb b/spec/factories/captain/custom_tool.rb new file mode 100644 index 000000000..81921b1c5 --- /dev/null +++ b/spec/factories/captain/custom_tool.rb @@ -0,0 +1,41 @@ +FactoryBot.define do + factory :captain_custom_tool, class: 'Captain::CustomTool' do + sequence(:title) { |n| "Custom Tool #{n}" } + description { 'A custom HTTP tool for external API integration' } + endpoint_url { 'https://api.example.com/endpoint' } + http_method { 'GET' } + auth_type { 'none' } + auth_config { {} } + enabled { true } + association :account + + trait :with_post do + http_method { 'POST' } + request_template { '{ "key": "{{ value }}" }' } + end + + trait :with_bearer_auth do + auth_type { 'bearer' } + auth_config { { token: 'test_bearer_token_123' } } + end + + trait :with_basic_auth do + auth_type { 'basic' } + auth_config { { username: 'test_user', password: 'test_pass' } } + end + + trait :with_api_key do + auth_type { 'api_key' } + auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } } + end + + trait :with_templates do + request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' } + response_template { 'Order status: {{ response.status }}' } + end + + trait :disabled do + enabled { false } + end + end +end