diff --git a/config/routes.rb b/config/routes.rb index c623ff053..ee6ec5e8a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -58,9 +58,12 @@ Rails.application.routes.draw do end resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id end - resources :documents, only: [:index, :show, :create, :destroy] resources :assistant_responses resources :bulk_actions, only: [:create] + resources :copilot_threads, only: [:index] do + resources :copilot_messages, only: [:index] + end + resources :documents, only: [:index, :show, :create, :destroy] end resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do delete :avatar, on: :member diff --git a/db/migrate/20250512231036_create_copilot_threads.rb b/db/migrate/20250512231036_create_copilot_threads.rb new file mode 100644 index 000000000..68686e066 --- /dev/null +++ b/db/migrate/20250512231036_create_copilot_threads.rb @@ -0,0 +1,14 @@ +class CreateCopilotThreads < ActiveRecord::Migration[7.0] + def change + create_table :copilot_threads do |t| + t.string :title, null: false + t.references :user, null: false, index: true + t.references :account, null: false, index: true + t.uuid :uuid, null: false, default: 'gen_random_uuid()' + + t.timestamps + end + + add_index :copilot_threads, :uuid, unique: true + end +end diff --git a/db/migrate/20250512231037_create_copilot_messages.rb b/db/migrate/20250512231037_create_copilot_messages.rb new file mode 100644 index 000000000..fd03cc9f8 --- /dev/null +++ b/db/migrate/20250512231037_create_copilot_messages.rb @@ -0,0 +1,13 @@ +class CreateCopilotMessages < ActiveRecord::Migration[7.0] + def change + create_table :copilot_messages do |t| + t.references :copilot_thread, null: false, index: true + t.references :user, null: false, index: true + t.references :account, null: false, index: true + t.string :message_type, null: false + t.jsonb :message, null: false, default: {} + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d858899ab..452354d49 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.0].define(version: 2025_04_21_085134) do +ActiveRecord::Schema[7.0].define(version: 2025_05_12_231037) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -575,6 +575,31 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do t.index ["waiting_since"], name: "index_conversations_on_waiting_since" end + create_table "copilot_messages", force: :cascade do |t| + t.bigint "copilot_thread_id", null: false + t.bigint "user_id", null: false + t.bigint "account_id", null: false + t.string "message_type", null: false + t.jsonb "message", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_copilot_messages_on_account_id" + t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id" + t.index ["user_id"], name: "index_copilot_messages_on_user_id" + end + + create_table "copilot_threads", force: :cascade do |t| + t.string "title", null: false + t.bigint "user_id", null: false + t.bigint "account_id", null: false + t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_copilot_threads_on_account_id" + t.index ["user_id"], name: "index_copilot_threads_on_user_id" + t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true + end + create_table "csat_survey_responses", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "conversation_id", null: false diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb new file mode 100644 index 000000000..2a30fba48 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb @@ -0,0 +1,25 @@ +class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Assistant) } + before_action :set_copilot_thread + + def index + @copilot_messages = @copilot_thread + .copilot_messages + .order(created_at: :asc) + .page(permitted_params[:page] || 1) + .per(1000) + end + + private + + def set_copilot_thread + @copilot_thread = Current.account.copilot_threads.find_by!( + uuid: params[:copilot_thread_id], user_id: Current.user.id + ) + end + + def permitted_params + params.permit(:page) + end +end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb new file mode 100644 index 000000000..e313f448c --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Assistant) } + + def index + @copilot_threads = Current.account.copilot_threads + .where(user_id: Current.user.id) + .includes(:user) + .order(created_at: :desc) + .page(permitted_params[:page] || 1) + .per(5) + end + + private + + def permitted_params + params.permit(:page) + end +end diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb new file mode 100644 index 000000000..16ae2c3c9 --- /dev/null +++ b/enterprise/app/models/copilot_message.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: copilot_messages +# +# id :bigint not null, primary key +# message :jsonb not null +# message_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# copilot_thread_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_copilot_messages_on_account_id (account_id) +# index_copilot_messages_on_copilot_thread_id (copilot_thread_id) +# index_copilot_messages_on_user_id (user_id) +# +class CopilotMessage < ApplicationRecord + belongs_to :copilot_thread + belongs_to :user + belongs_to :account + + validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] } + validates :message, presence: true +end diff --git a/enterprise/app/models/copilot_thread.rb b/enterprise/app/models/copilot_thread.rb new file mode 100644 index 000000000..865418ad7 --- /dev/null +++ b/enterprise/app/models/copilot_thread.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: copilot_threads +# +# id :bigint not null, primary key +# title :string not null +# uuid :uuid not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_copilot_threads_on_account_id (account_id) +# index_copilot_threads_on_user_id (user_id) +# index_copilot_threads_on_uuid (uuid) UNIQUE +# +class CopilotThread < ApplicationRecord + belongs_to :user + belongs_to :account + has_many :copilot_messages, dependent: :destroy + + validates :title, presence: true + validates :uuid, presence: true, uniqueness: true +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 4fcb9b34b..4a573a4c4 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -9,5 +9,7 @@ module Enterprise::Concerns::Account has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' + + has_many :copilot_threads, dependent: :destroy_async end end diff --git a/enterprise/app/models/enterprise/concerns/user.rb b/enterprise/app/models/enterprise/concerns/user.rb index 5d2687fbf..0e597b8d8 100644 --- a/enterprise/app/models/enterprise/concerns/user.rb +++ b/enterprise/app/models/enterprise/concerns/user.rb @@ -5,6 +5,8 @@ module Enterprise::Concerns::User before_validation :ensure_installation_pricing_plan_quantity, on: :create has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable + has_many :copilot_threads, dependent: :destroy_async + has_many :copilot_messages, dependent: :destroy_async end def ensure_installation_pricing_plan_quantity diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder new file mode 100644 index 000000000..ce0d5b175 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder @@ -0,0 +1,8 @@ +json.payload do + json.array! @copilot_messages do |message| + json.id message.id + json.message message.message + json.message_type message.message_type + json.created_at message.created_at.to_i + end +end diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder new file mode 100644 index 000000000..c06182ffd --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder @@ -0,0 +1,12 @@ +json.payload do + json.array! @copilot_threads do |thread| + json.id thread.id + json.title thread.title + json.uuid thread.uuid + json.created_at thread.created_at.to_i + json.user do + json.id thread.user.id + json.name thread.user.name + end + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb new file mode 100644 index 000000000..0ccca90c5 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, account: account, role: :administrator) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) } + let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) } + + describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do + context 'when it is an authenticated user' do + it 'returns all messages' do + get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages", + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['payload'].length).to eq(1) + expect(json_response['payload'][0]['id']).to eq(copilot_message.id) + end + end + + context 'when thread uuid is invalid' do + it 'returns not found error' do + get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages", + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb new file mode 100644 index 000000000..8533a2d1d --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', 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/copilot_threads' do + context 'when it is an un-authenticated user' do + it 'does not fetch copilot threads' do + get "/api/v1/accounts/#{account.id}/captain/copilot_threads", + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'fetches copilot threads for the current user' do + # Create threads for the current agent + create_list(:captain_copilot_thread, 3, account: account, user: agent) + # Create threads for another user (should not be included) + create_list(:captain_copilot_thread, 2, account: account, user: admin) + + get "/api/v1/accounts/#{account.id}/captain/copilot_threads", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(3) + + expect(json_response[:payload].map { |thread| thread[:user][:id] }.uniq).to eq([agent.id]) + end + + it 'returns threads in descending order of creation' do + threads = create_list(:captain_copilot_thread, 3, account: account, user: agent) + + get "/api/v1/accounts/#{account.id}/captain/copilot_threads", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].pluck(:id)).to eq(threads.reverse.pluck(:id)) + end + end + end +end diff --git a/spec/factories/captain/copilot_message.rb b/spec/factories/captain/copilot_message.rb new file mode 100644 index 000000000..78f9f202e --- /dev/null +++ b/spec/factories/captain/copilot_message.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :captain_copilot_message, class: 'CopilotMessage' do + account + user + copilot_thread { association :captain_copilot_thread } + message { { content: 'This is a test message' } } + message_type { 'user' } + end +end diff --git a/spec/factories/captain/copilot_thread.rb b/spec/factories/captain/copilot_thread.rb new file mode 100644 index 000000000..fee78a7e7 --- /dev/null +++ b/spec/factories/captain/copilot_thread.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :captain_copilot_thread, class: 'CopilotThread' do + account + user + title { Faker::Lorem.sentence } + uuid { SecureRandom.uuid } + end +end