diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js new file mode 100644 index 000000000..d0818d941 --- /dev/null +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainCustomTools extends ApiClient { + constructor() { + super('captain/custom_tools', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { page, searchKey }, + }); + } + + show(id) { + return axios.get(`${this.url}/${id}`); + } + + create(data = {}) { + return axios.post(this.url, { + custom_tool: data, + }); + } + + update(id, data = {}) { + return axios.put(`${this.url}/${id}`, { + custom_tool: data, + }); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } +} + +export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue index 31e18394f..8d67344e1 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue @@ -10,6 +10,10 @@ const props = defineProps({ type: String, required: true, }, + translationKey: { + type: String, + required: true, + }, entity: { type: Object, required: true, @@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']); const { t } = useI18n(); const store = useStore(); const deleteDialogRef = ref(null); -const i18nKey = computed(() => props.type.toUpperCase()); +const i18nKey = computed(() => { + return props.translationKey || props.type.toUpperCase(); +}); const deleteEntity = async payload => { if (!payload) return; diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue new file mode 100644 index 000000000..208a94dba --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue new file mode 100644 index 000000000..0745c6546 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue @@ -0,0 +1,87 @@ + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue new file mode 100644 index 000000000..d1d1dd011 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue @@ -0,0 +1,125 @@ + + + + + + + {{ title }} + + + + + + + + + + + + {{ description }} + + + + {{ authTypeLabel }} + + + + {{ timestamp }} + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue new file mode 100644 index 000000000..14ebc6a57 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue @@ -0,0 +1,271 @@ + + + + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }} + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }} + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }} + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }} + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/ParamRow.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/ParamRow.vue new file mode 100644 index 000000000..33cd64468 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/ParamRow.vue @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }} + + + + + + + {{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }} + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue new file mode 100644 index 000000000..420f953da --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index ab6537031..cef4346e9 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -232,6 +232,11 @@ const menuItems = computed(() => { label: t('SIDEBAR.CAPTAIN_RESPONSES'), to: accountScopedRoute('captain_responses_index'), }, + { + name: 'Tools', + label: t('SIDEBAR.CAPTAIN_TOOLS'), + to: accountScopedRoute('captain_tools_index'), + }, ], }, { diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 8a812dff3..c65d2d040 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -750,6 +750,115 @@ } } }, + "CUSTOM_TOOLS": { + "HEADER": "Tools", + "ADD_NEW": "Create a new tool", + "EMPTY_STATE": { + "TITLE": "No custom tools available", + "SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.", + "FEATURE_SPOTLIGHT": { + "TITLE": "Custom Tools", + "NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities." + } + }, + "FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs", + "OPTIONS": { + "EDIT_TOOL": "Edit tool", + "DELETE_TOOL": "Delete tool" + }, + "CREATE": { + "TITLE": "Create Custom Tool", + "SUCCESS_MESSAGE": "Custom tool created successfully", + "ERROR_MESSAGE": "Failed to create custom tool" + }, + "EDIT": { + "TITLE": "Edit Custom Tool", + "SUCCESS_MESSAGE": "Custom tool updated successfully", + "ERROR_MESSAGE": "Failed to update custom tool" + }, + "DELETE": { + "TITLE": "Delete Custom Tool", + "DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.", + "CONFIRM": "Yes, delete", + "SUCCESS_MESSAGE": "Custom tool deleted successfully", + "ERROR_MESSAGE": "Failed to delete custom tool" + }, + "FORM": { + "TITLE": { + "LABEL": "Tool Name", + "PLACEHOLDER": "Order Lookup", + "ERROR": "Tool name is required" + }, + "DESCRIPTION": { + "LABEL": "Description", + "PLACEHOLDER": "Looks up order details by order ID" + }, + "HTTP_METHOD": { + "LABEL": "Method" + }, + "ENDPOINT_URL": { + "LABEL": "Endpoint URL", + "PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}", + "ERROR": "Valid URL is required" + }, + "AUTH_TYPE": { + "LABEL": "Authentication Type" + }, + "AUTH_TYPES": { + "NONE": "None", + "BEARER": "Bearer Token", + "BASIC": "Basic Auth", + "API_KEY": "API Key" + }, + "AUTH_CONFIG": { + "BEARER_TOKEN": "Bearer Token", + "BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token", + "USERNAME": "Username", + "USERNAME_PLACEHOLDER": "Enter username", + "PASSWORD": "Password", + "PASSWORD_PLACEHOLDER": "Enter password", + "API_KEY": "Header Name", + "API_KEY_PLACEHOLDER": "X-API-Key", + "API_VALUE": "Header Value", + "API_VALUE_PLACEHOLDER": "Enter API key value" + }, + "PARAMETERS": { + "LABEL": "Parameters", + "HELP_TEXT": "Define the parameters that will be extracted from user queries" + }, + "ADD_PARAMETER": "Add Parameter", + "PARAM_NAME": { + "PLACEHOLDER": "Parameter name (e.g., order_id)" + }, + "PARAM_TYPE": { + "PLACEHOLDER": "Type" + }, + "PARAM_TYPES": { + "STRING": "String", + "NUMBER": "Number", + "BOOLEAN": "Boolean", + "ARRAY": "Array", + "OBJECT": "Object" + }, + "PARAM_DESCRIPTION": { + "PLACEHOLDER": "Description of the parameter" + }, + "PARAM_REQUIRED": { + "LABEL": "Required" + }, + "REQUEST_TEMPLATE": { + "LABEL": "Request Body Template (Optional)", + "PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}" + }, + "RESPONSE_TEMPLATE": { + "LABEL": "Response Template (Optional)", + "PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}" + }, + "ERRORS": { + "PARAM_NAME_REQUIRED": "Parameter name is required" + } + } + }, "RESPONSES": { "HEADER": "FAQs", "ADD_NEW": "Create new FAQ", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 9ddc3b805..812b0cd8b 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -304,6 +304,7 @@ "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_RESPONSES": "FAQs", + "CAPTAIN_TOOLS": "Tools", "HOME": "Home", "AGENTS": "Agents", "AGENT_BOTS": "Bots", diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js index 52fda537b..9d5609ab7 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js @@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue'; import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; import DocumentsIndex from './documents/Index.vue'; import ResponsesIndex from './responses/Index.vue'; +import CustomToolsIndex from './tools/Index.vue'; export const routes = [ { @@ -124,4 +125,17 @@ export const routes = [ ], }, }, + { + path: frontendURL('accounts/:accountId/captain/tools'), + component: CustomToolsIndex, + name: 'captain_tools_index', + meta: { + permissions: ['administrator', 'agent'], + featureFlag: FEATURE_FLAGS.CAPTAIN_V2, + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, ]; diff --git a/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue new file mode 100644 index 000000000..880bdbbf3 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/store/captain/customTools.js b/app/javascript/dashboard/store/captain/customTools.js new file mode 100644 index 000000000..3d3af03c0 --- /dev/null +++ b/app/javascript/dashboard/store/captain/customTools.js @@ -0,0 +1,35 @@ +import CaptainCustomTools from 'dashboard/api/captain/customTools'; +import { createStore } from './storeFactory'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +export default createStore({ + name: 'CaptainCustomTool', + API: CaptainCustomTools, + actions: mutations => ({ + update: async ({ commit }, { id, ...updateObj }) => { + commit(mutations.SET_UI_FLAG, { updatingItem: true }); + try { + const response = await CaptainCustomTools.update(id, updateObj); + commit(mutations.EDIT, response.data); + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return throwErrorMessage(error); + } + }, + + delete: async ({ commit }, id) => { + commit(mutations.SET_UI_FLAG, { deletingItem: true }); + try { + await CaptainCustomTools.delete(id); + commit(mutations.DELETE, id); + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return id; + } catch (error) { + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return throwErrorMessage(error); + } + }, + }), +}); diff --git a/app/javascript/dashboard/store/captain/tools.js b/app/javascript/dashboard/store/captain/tools.js index 9a9bcc330..9638e45c3 100644 --- a/app/javascript/dashboard/store/captain/tools.js +++ b/app/javascript/dashboard/store/captain/tools.js @@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools'; import { throwErrorMessage } from 'dashboard/store/utils/api'; const toolsStore = createStore({ - name: 'captainTool', + name: 'Tools', API: CaptainToolsAPI, actions: mutations => ({ getTools: async ({ commit }) => { diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 16bcab3f9..d56958eb5 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads'; import copilotMessages from './captain/copilotMessages'; import captainScenarios from './captain/scenarios'; import captainTools from './captain/tools'; +import captainCustomTools from './captain/customTools'; const plugins = []; @@ -119,6 +120,7 @@ export default createStore({ copilotMessages, captainScenarios, captainTools, + captainCustomTools, }, plugins, }); diff --git a/config/locales/en.yml b/config/locales/en.yml index ca3a9e950..c106531a0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -336,6 +336,8 @@ en: processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})' chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}' page_processing_error: 'Error processing pages %{start}-%{end}: %{error}' + custom_tool: + slug_generation_failed: 'Unable to generate unique slug after 5 attempts' public_portal: search: search_placeholder: Search for article by title or body... diff --git a/config/routes.rb b/config/routes.rb index bf455949c..757d20620 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,6 +67,7 @@ Rails.application.routes.draw do resources :copilot_threads, only: [:index, :create] do resources :copilot_messages, only: [:index, :create] end + resources :custom_tools resources :documents, only: [:index, :show, :create, :destroy] end resource :saml_settings, only: [:show, :create, :update, :destroy] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb new file mode 100644 index 000000000..3137ded09 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb @@ -0,0 +1,49 @@ +class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::CustomTool) } + before_action :set_custom_tool, only: [:show, :update, :destroy] + + def index + @custom_tools = account_custom_tools.enabled + end + + def show; end + + def create + @custom_tool = account_custom_tools.create!(custom_tool_params) + end + + def update + @custom_tool.update!(custom_tool_params) + end + + def destroy + @custom_tool.destroy + head :no_content + end + + private + + def set_custom_tool + @custom_tool = account_custom_tools.find(params[:id]) + end + + def account_custom_tools + @account_custom_tools ||= Current.account.captain_custom_tools + end + + def custom_tool_params + params.require(:custom_tool).permit( + :title, + :description, + :endpoint_url, + :http_method, + :request_template, + :response_template, + :auth_type, + :enabled, + auth_config: {}, + param_schema: [:name, :type, :description, :required] + ) + end +end diff --git a/enterprise/app/models/captain/custom_tool.rb b/enterprise/app/models/captain/custom_tool.rb index 8ad02f401..bf3f351dd 100644 --- a/enterprise/app/models/captain/custom_tool.rb +++ b/enterprise/app/models/captain/custom_tool.rb @@ -29,6 +29,8 @@ class Captain::CustomTool < ApplicationRecord self.table_name = 'captain_custom_tools' + NAME_PREFIX = 'custom'.freeze + NAME_SEPARATOR = '_'.freeze PARAM_SCHEMA_VALIDATION = { 'type': 'array', 'items': { @@ -73,16 +75,23 @@ class Captain::CustomTool < ApplicationRecord def generate_slug return if slug.present? + return if title.blank? - base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}" + paramterized_title = title.parameterize(separator: NAME_SEPARATOR) + + base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}" 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) + def find_unique_slug(base_slug) + return base_slug unless slug_exists?(base_slug) - slug_candidate + 5.times do + slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}" + return slug_candidate unless slug_exists?(slug_candidate) + end + + raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed') end def slug_exists?(candidate) diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb index 24c16c6f4..bae1771e4 100644 --- a/enterprise/app/models/concerns/toolable.rb +++ b/enterprise/app/models/concerns/toolable.rb @@ -3,7 +3,10 @@ module Concerns::Toolable def tool(assistant) custom_tool_record = self + # Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize) + class_name = custom_tool_record.slug.underscore.camelize + # Always create a fresh class to reflect current metadata tool_class = Class.new(Captain::Tools::HttpTool) do description custom_tool_record.description @@ -15,6 +18,16 @@ module Concerns::Toolable end end + # Register the dynamically created class as a constant in the Captain::Tools namespace. + # This is required because RubyLLM's Tool base class derives the tool name from the class name + # (via Class#name). Anonymous classes created with Class.new have no name and return empty strings, + # which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API. + # By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup") + # which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API. + # We refresh the constant on each call to ensure tool metadata changes are reflected. + Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false) + Captain::Tools.const_set(class_name, tool_class) + tool_class.new(assistant, self) end diff --git a/enterprise/app/policies/captain/custom_tool_policy.rb b/enterprise/app/policies/captain/custom_tool_policy.rb new file mode 100644 index 000000000..b88a23860 --- /dev/null +++ b/enterprise/app/policies/captain/custom_tool_policy.rb @@ -0,0 +1,21 @@ +class Captain::CustomToolPolicy < 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 diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder new file mode 100644 index 000000000..c57a92261 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder @@ -0,0 +1,10 @@ +json.payload do + json.array! @custom_tools do |custom_tool| + json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool + end +end + +json.meta do + json.total_count @custom_tools.count + json.page 1 +end diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder new file mode 100644 index 000000000..778b30061 --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder @@ -0,0 +1,15 @@ +json.id custom_tool.id +json.slug custom_tool.slug +json.title custom_tool.title +json.description custom_tool.description +json.endpoint_url custom_tool.endpoint_url +json.http_method custom_tool.http_method +json.request_template custom_tool.request_template +json.response_template custom_tool.response_template +json.auth_type custom_tool.auth_type +json.auth_config custom_tool.auth_config +json.param_schema custom_tool.param_schema +json.enabled custom_tool.enabled +json.account_id custom_tool.account_id +json.created_at custom_tool.created_at.to_i +json.updated_at custom_tool.updated_at.to_i diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb new file mode 100644 index 000000000..7a1526995 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb @@ -0,0 +1,281 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status' do + create_list(:captain_custom_tool, 3, account: account) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(3) + end + end + + context 'when it is an admin' do + it 'returns success status and custom tools' do + create_list(:captain_custom_tool, 5, account: account) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(5) + end + + it 'returns only enabled custom tools' do + create(:captain_custom_tool, account: account, enabled: true) + create(:captain_custom_tool, account: account, enabled: false) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(1) + expect(json_response[:payload].first[:enabled]).to be(true) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do + let(:custom_tool) { create(:captain_custom_tool, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status and custom tool' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:id]).to eq(custom_tool.id) + expect(json_response[:title]).to eq(custom_tool.title) + end + end + + context 'when custom tool does not exist' do + it 'returns not found status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/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/custom_tools' do + let(:valid_attributes) do + { + custom_tool: { + title: 'Fetch Order Status', + description: 'Fetches order status from external API', + endpoint_url: 'https://api.example.com/orders/{{ order_id }}', + http_method: 'GET', + enabled: true, + param_schema: [ + { name: 'order_id', type: 'string', description: 'The order ID', required: true } + ] + } + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + 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/custom_tools", + 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 custom tool and returns success status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: valid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:title]).to eq('Fetch Order Status') + expect(json_response[:description]).to eq('Fetches order status from external API') + expect(json_response[:enabled]).to be(true) + expect(json_response[:slug]).to eq('custom_fetch_order_status') + expect(json_response[:param_schema]).to eq([ + { name: 'order_id', type: 'string', description: 'The order ID', required: true } + ]) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + custom_tool: { + title: '', + endpoint_url: '' + } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: invalid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'with invalid endpoint URL' do + let(:invalid_url_attributes) do + { + custom_tool: { + title: 'Test Tool', + endpoint_url: 'http://localhost/api', + http_method: 'GET' + } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: invalid_url_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/custom_tools/{id}' do + let(:custom_tool) { create(:captain_custom_tool, account: account) } + let(:update_attributes) do + { + custom_tool: { + title: 'Updated Tool 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/custom_tools/#{custom_tool.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/custom_tools/#{custom_tool.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 custom tool and returns success status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.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 Tool Title') + expect(json_response[:enabled]).to be(false) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + custom_tool: { + title: '' + } + } + end + + it 'returns unprocessable entity status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.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/custom_tools/{id}' do + let!(:custom_tool) { create(:captain_custom_tool, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.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/custom_tools/#{custom_tool.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 custom tool and returns no content status' do + expect do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: admin.create_new_auth_token + end.to change(Captain::CustomTool, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + context 'when custom tool does not exist' do + it 'returns not found status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999", + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + end + end +end diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb index c7c0451b1..5f6c7b19a 100644 --- a/spec/enterprise/models/captain/custom_tool_spec.rb +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -19,8 +19,8 @@ RSpec.describe Captain::CustomTool, type: :model 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') + 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') @@ -28,8 +28,8 @@ RSpec.describe Captain::CustomTool, type: :model do 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') + 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 @@ -114,7 +114,7 @@ RSpec.describe Captain::CustomTool, type: :model do 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') + expect(tool.slug).to eq('custom_fetch_order_status') end it 'adds custom_ prefix to generated slug' do @@ -124,37 +124,39 @@ RSpec.describe Captain::CustomTool, type: :model do end it 'does not override manually set slug' do - tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug') + tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug') - expect(tool.slug).to eq('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') + it 'handles slug collisions by appending random suffix' 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') + expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/) 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') + 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_abc123') tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool') - expect(tool3.slug).to eq('custom_test-tool-2') + expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/) + expect(tool3.slug).not_to eq('custom_test_tool') + expect(tool3.slug).not_to eq('custom_test_tool_abc123') end - it 'generates slug with UUID when title is blank' do + it 'does not generate slug 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-]+$/) + expect(tool).not_to be_valid + expect(tool.errors[:title]).to include("can't be blank") 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') + expect(tool.slug).to eq('custom_fetch_order_status_details') end end
+ {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }} +