diff --git a/app/javascript/dashboard/api/captain/bulkActions.js b/app/javascript/dashboard/api/captain/bulkActions.js new file mode 100644 index 000000000..fd69a1108 --- /dev/null +++ b/app/javascript/dashboard/api/captain/bulkActions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainBulkActionsAPI extends ApiClient { + constructor() { + super('captain/bulk_actions', { accountScoped: true }); + } +} + +export default new CaptainBulkActionsAPI(); diff --git a/app/javascript/dashboard/components-next/CardLayout.vue b/app/javascript/dashboard/components-next/CardLayout.vue index 0fd3f5986..462402167 100644 --- a/app/javascript/dashboard/components-next/CardLayout.vue +++ b/app/javascript/dashboard/components-next/CardLayout.vue @@ -4,6 +4,10 @@ defineProps({ type: String, default: 'col', }, + selectable: { + type: Boolean, + default: false, + }, }); const emit = defineEmits(['click']); @@ -18,10 +22,11 @@ const handleClick = () => { class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2" >
diff --git a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue index 312f2ad34..f00354105 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue @@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper'; import CardLayout from 'dashboard/components-next/CardLayout.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import Button from 'dashboard/components-next/button/Button.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import Policy from 'dashboard/components/policy.vue'; const props = defineProps({ @@ -46,14 +47,27 @@ const props = defineProps({ type: Number, required: true, }, + isSelected: { + type: Boolean, + default: false, + }, + selectable: { + type: Boolean, + default: false, + }, }); -const emit = defineEmits(['action', 'navigate']); +const emit = defineEmits(['action', 'navigate', 'select', 'hover']); const { t } = useI18n(); const [showActionsDropdown, toggleDropdown] = useToggle(); +const modelValue = computed({ + get: () => props.isSelected, + set: () => emit('select', props.id), +}); + const statusAction = computed(() => { if (props.status === 'pending') { return [ @@ -102,8 +116,17 @@ const handleDocumentableClick = () => { @@ -218,8 +344,12 @@ onMounted(() => { :status="response.status" :created-at="response.created_at" :updated-at="response.updated_at" + :is-selected="bulkSelectedIds.has(response.id)" + :selectable="hoveredCard === response.id || bulkSelectedIds.size > 0" @action="handleAction" @navigate="handleNavigationAction" + @select="handleCardSelect" + @hover="isHovered => handleCardHover(isHovered, response.id)" />
@@ -232,6 +362,14 @@ onMounted(() => { @delete-success="onDeleteSuccess" /> + + ({ + processBulkAction: async function processBulkAction( + { commit }, + { type, actionType, ids } + ) { + commit(mutations.SET_UI_FLAG, { isUpdating: true }); + try { + const response = await CaptainBulkActionsAPI.create({ + type: type, + ids, + fields: { status: actionType }, + }); + commit(mutations.SET_UI_FLAG, { isUpdating: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { isUpdating: false }); + return throwErrorMessage(error); + } + }, + + handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) { + const response = await dispatch('processBulkAction', { + type: 'AssistantResponse', + actionType: 'delete', + ids, + }); + + // Update the response store after successful API call + await dispatch('captainResponses/removeBulkResponses', ids, { + root: true, + }); + return response; + }, + + handleBulkApprove: async function handleBulkApprove({ dispatch }, ids) { + const response = await dispatch('processBulkAction', { + type: 'AssistantResponse', + actionType: 'approve', + ids, + }); + + // Update response store after successful API call + await dispatch('captainResponses/updateBulkResponses', response, { + root: true, + }); + return response; + }, + }), +}); diff --git a/app/javascript/dashboard/store/captain/response.js b/app/javascript/dashboard/store/captain/response.js index 6280e417e..5f8c2eee1 100644 --- a/app/javascript/dashboard/store/captain/response.js +++ b/app/javascript/dashboard/store/captain/response.js @@ -4,4 +4,29 @@ import { createStore } from './storeFactory'; export default createStore({ name: 'CaptainResponse', API: CaptainResponseAPI, + actions: mutations => ({ + removeBulkResponses: ({ commit, state }, ids) => { + const updatedRecords = state.records.filter( + record => !ids.includes(record.id) + ); + commit(mutations.SET, updatedRecords); + }, + updateBulkResponses: ({ commit, state }, approvedResponses) => { + // Create a map of updated responses for faster lookup + const updatedResponsesMap = approvedResponses.reduce((map, response) => { + map[response.id] = response; + return map; + }, {}); + + // Update existing records with updated data + const updatedRecords = state.records.map(record => { + if (updatedResponsesMap[record.id]) { + return updatedResponsesMap[record.id]; // Replace with the updated response + } + return record; + }); + + commit(mutations.SET, updatedRecords); + }, + }), }); diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 379ff21f1..5daf73ae1 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -50,6 +50,7 @@ import captainAssistants from './captain/assistant'; import captainDocuments from './captain/document'; import captainResponses from './captain/response'; import captainInboxes from './captain/inboxes'; +import captainBulkActions from './captain/bulkActions'; const plugins = []; export default createStore({ @@ -104,6 +105,7 @@ export default createStore({ captainDocuments, captainResponses, captainInboxes, + captainBulkActions, }, plugins, }); diff --git a/config/routes.rb b/config/routes.rb index 3e2616a46..3b5435ed9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -54,6 +54,7 @@ Rails.application.routes.draw do end resources :documents, only: [:index, :show, :create, :destroy] resources :assistant_responses + resources :bulk_actions, only: [:create] end resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do delete :avatar, on: :member diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb new file mode 100644 index 000000000..3130a68cc --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb @@ -0,0 +1,51 @@ +class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Assistant) } + before_action :validate_params + before_action :type_matches? + + MODEL_TYPE = ['AssistantResponse'].freeze + + def create + @responses = process_bulk_action + end + + private + + def validate_params + return if params[:type].present? && params[:ids].present? && params[:fields].present? + + render json: { success: false }, status: :unprocessable_entity + end + + def type_matches? + return if MODEL_TYPE.include?(params[:type]) + + render json: { success: false }, status: :unprocessable_entity + end + + def process_bulk_action + case params[:type] + when 'AssistantResponse' + handle_assistant_responses + end + end + + def handle_assistant_responses + responses = Current.account.captain_assistant_responses.where(id: params[:ids]) + return unless responses.exists? + + case params[:fields][:status] + when 'approve' + responses.pending.update(status: 'approved') + responses + when 'delete' + responses.destroy_all + [] + end + end + + def permitted_params + params.permit(:type, ids: [], fields: [:status]) + end +end diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb index a32f1d630..397c3ad63 100644 --- a/enterprise/app/services/captain/tools/firecrawl_service.rb +++ b/enterprise/app/services/captain/tools/firecrawl_service.rb @@ -1,7 +1,7 @@ class Captain::Tools::FirecrawlService def initialize @api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value - raise 'Missing API key' if @api_key.nil? + raise 'Missing API key' if @api_key.empty? end def perform(url, webhook_url, crawl_limit = 10) diff --git a/enterprise/app/views/api/v1/accounts/captain/bulk_actions/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/bulk_actions/create.json.jbuilder new file mode 100644 index 000000000..4ba4b1ad6 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/bulk_actions/create.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @responses do |response| + json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: response +end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb new file mode 100644 index 000000000..968649085 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb @@ -0,0 +1,143 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:pending_responses) do + create_list( + :captain_assistant_response, + 2, + assistant: assistant, + account: account, + status: 'pending' + ) + end + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'POST /api/v1/accounts/:account_id/captain/bulk_actions' do + context 'when approving responses' do + let(:valid_params) do + { + type: 'AssistantResponse', + ids: pending_responses.map(&:id), + fields: { status: 'approve' } + } + end + + it 'approves the responses and returns the updated records' do + post "/api/v1/accounts/#{account.id}/captain/bulk_actions", + params: valid_params, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(json_response).to be_an(Array) + expect(json_response.length).to eq(2) + + # Verify responses were approved + pending_responses.each do |response| + expect(response.reload.status).to eq('approved') + end + end + end + + context 'when deleting responses' do + let(:delete_params) do + { + type: 'AssistantResponse', + ids: pending_responses.map(&:id), + fields: { status: 'delete' } + } + end + + it 'deletes the responses and returns an empty array' do + expect do + post "/api/v1/accounts/#{account.id}/captain/bulk_actions", + params: delete_params, + headers: admin.create_new_auth_token, + as: :json + end.to change(Captain::AssistantResponse, :count).by(-2) + + expect(response).to have_http_status(:ok) + expect(json_response).to eq([]) + + # Verify responses were deleted + pending_responses.each do |response| + expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + context 'with invalid type' do + let(:invalid_params) do + { + type: 'InvalidType', + ids: pending_responses.map(&:id), + fields: { status: 'approve' } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/bulk_actions", + params: invalid_params, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response[:success]).to be(false) + + # Verify no changes were made + pending_responses.each do |response| + expect(response.reload.status).to eq('pending') + end + end + end + + context 'with missing parameters' do + let(:missing_params) do + { + type: 'AssistantResponse', + fields: { status: 'approve' } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/bulk_actions", + params: missing_params, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response[:success]).to be(false) + + # Verify no changes were made + pending_responses.each do |response| + expect(response.reload.status).to eq('pending') + end + end + end + + context 'with unauthorized user' do + let(:unauthorized_user) { create(:user, account: create(:account)) } + + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/bulk_actions", + params: { type: 'AssistantResponse', ids: [1], fields: { status: 'approve' } }, + headers: unauthorized_user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + + # Verify no changes were made + pending_responses.each do |response| + expect(response.reload.status).to eq('pending') + end + end + end + end +end diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb index 8b358a655..d6563b163 100644 --- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb +++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb @@ -32,6 +32,16 @@ RSpec.describe Captain::Tools::FirecrawlService do InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil) end + it 'raises an error' do + expect { described_class.new }.to raise_error(NoMethodError) + end + end + + context 'when API key is empty' do + before do + InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: '') + end + it 'raises an error' do expect { described_class.new }.to raise_error('Missing API key') end