From 8dd0d08322edafaec24624b72ed2f6045921cb7b Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 16 Jul 2026 18:17:29 +0530 Subject: [PATCH 01/33] refactor: align conversation direct uploads with standard account auth (#15039) Conversation attachment uploads now go through the same authentication that every other account-scoped API endpoint uses. Agents continue to attach files exactly as before, and the upload request is now tied to the agent's dashboard session instead of a separately serialized access token. Because the upload request is now authenticated, the dashboard proves the agent's session directly instead of passing `currentUser.access_token`. This keeps uploads working alongside the profile access-token changes in #14973, including on accounts where that token is serialized as empty. ## What changed - `Api::V1::Accounts::Conversations::DirectUploadsController` now runs the standard account auth stack: API access token when the `api_access_token` header is present, dashboard session (devise-token-auth) otherwise, with agent-bot tokens rejected. Previously it inherited `ActiveStorage::DirectUploadsController` directly and did not run any authentication. - `EnsureCurrentAccountHelper#ensure_current_account` now returns `401` when a request has neither an authenticated user nor a bot resource, instead of continuing. This closes the same gap for any controller that relies on the helper. - The dashboard direct-upload paths (`useFileUpload.js` and the legacy `fileUploadMixin.js`) now attach the agent's session headers to the upload request via a new `directUploadsHelper.js`, instead of sending `currentUser.access_token`. ## How to test 1. As a logged-in agent, open a conversation and attach a file. Upload should succeed as before, on installs with direct uploads enabled. 2. Confirm attachments still work for an agent on an account whose profile access token is not serialized (e.g. a Cloud plan without `api_and_webhooks`). 3. Send a `POST` to `/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads` with no credentials, an empty `api_access_token`, or an invalid token, and confirm it returns `401`. 4. Confirm a valid agent of the account (via API token or session) gets `200`, while an agent of a different account gets `401`. --- .../direct_uploads_controller.rb | 21 +++ .../concerns/ensure_current_account_helper.rb | 4 +- .../dashboard/composables/useFileUpload.js | 7 +- .../dashboard/helper/directUploadsHelper.js | 19 +++ .../helper/specs/directUploadsHelper.spec.js | 61 +++++++++ .../dashboard/mixins/fileUploadMixin.js | 6 +- config/locales/en.yml | 1 + .../direct_uploads_controller_spec.rb | 125 +++++++++++++++--- 8 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 app/javascript/dashboard/helper/directUploadsHelper.js create mode 100644 app/javascript/dashboard/helper/specs/directUploadsHelper.spec.js diff --git a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb index f4ac05d6e..915ade8a3 100644 --- a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb @@ -1,6 +1,17 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController + include DeviseTokenAuth::Concerns::SetUserByToken + include RequestExceptionHandler + include AccessTokenAuthHelper include EnsureCurrentAccountHelper + + skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token? + + around_action :handle_with_exception + before_action :authenticate_access_token!, if: :authenticate_by_access_token? + before_action :validate_bot_access_token!, if: :authenticate_by_access_token? + before_action :authenticate_user!, unless: :authenticate_by_access_token? before_action :current_account + before_action :validate_token_api_access, if: :authenticate_by_access_token? before_action :conversation def create @@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage: private + def authenticate_by_access_token? + request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present? + end + + def validate_token_api_access + return if Current.account.api_and_webhooks_enabled? + + render json: { error: 'API access is not enabled for this account' }, status: :forbidden + end + def conversation @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) end diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index ea36a48f2..7ed39fa98 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper account_accessible_for_user?(account) elsif @resource.is_a?(AgentBot) account_accessible_for_bot?(account) + else + render_unauthorized(I18n.t('errors.account.not_authorized')) end account end @@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper def account_accessible_for_user?(account) @current_account_user = account.account_users.find_by(user_id: current_user.id) Current.account_user = @current_account_user - render_unauthorized('You are not authorized to access this account') unless @current_account_user + render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user end def account_accessible_for_bot?(account) diff --git a/app/javascript/dashboard/composables/useFileUpload.js b/app/javascript/dashboard/composables/useFileUpload.js index a0c7e3297..49809f606 100644 --- a/app/javascript/dashboard/composables/useFileUpload.js +++ b/app/javascript/dashboard/composables/useFileUpload.js @@ -2,6 +2,7 @@ import { useMapGetter } from 'dashboard/composables/store'; import { useAlert } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; import { DirectUpload } from 'activestorage'; +import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { getMaxUploadSizeByChannel } from '@chatwoot/utils'; import { @@ -21,7 +22,6 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => { const { t } = useI18n(); const accountId = useMapGetter('getCurrentAccountId'); - const currentUser = useMapGetter('getCurrentUser'); const currentChat = useMapGetter('getSelectedChat'); const globalConfig = useMapGetter('globalConfig/get'); @@ -78,10 +78,7 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => { `/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`, { directUploadWillCreateBlobWithXHR: xhr => { - xhr.setRequestHeader( - 'api_access_token', - currentUser.value.access_token - ); + setDirectUploadAuthHeaders(xhr); }, } ); diff --git a/app/javascript/dashboard/helper/directUploadsHelper.js b/app/javascript/dashboard/helper/directUploadsHelper.js new file mode 100644 index 000000000..6fafcee20 --- /dev/null +++ b/app/javascript/dashboard/helper/directUploadsHelper.js @@ -0,0 +1,19 @@ +import Auth from 'dashboard/api/auth'; + +export const setDirectUploadAuthHeaders = xhr => { + const { + 'access-token': accessToken, + 'token-type': tokenType, + client, + expiry, + uid, + } = Auth.getAuthData() || {}; + + if (!accessToken) return; + + xhr.setRequestHeader('access-token', accessToken); + xhr.setRequestHeader('token-type', tokenType); + xhr.setRequestHeader('client', client); + xhr.setRequestHeader('expiry', expiry); + xhr.setRequestHeader('uid', uid); +}; diff --git a/app/javascript/dashboard/helper/specs/directUploadsHelper.spec.js b/app/javascript/dashboard/helper/specs/directUploadsHelper.spec.js new file mode 100644 index 000000000..69c90a485 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/directUploadsHelper.spec.js @@ -0,0 +1,61 @@ +import { setDirectUploadAuthHeaders } from '../directUploadsHelper'; +import Auth from 'dashboard/api/auth'; + +vi.mock('dashboard/api/auth', () => ({ + default: { getAuthData: vi.fn() }, +})); + +describe('setDirectUploadAuthHeaders', () => { + const buildXhr = () => ({ setRequestHeader: vi.fn() }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sets the five session auth headers from the auth cookie', () => { + Auth.getAuthData.mockReturnValue({ + 'access-token': 'token-123', + 'token-type': 'Bearer', + client: 'client-123', + expiry: '9999', + uid: 'agent@example.com', + }); + const xhr = buildXhr(); + + setDirectUploadAuthHeaders(xhr); + + expect(xhr.setRequestHeader).toHaveBeenCalledTimes(5); + expect(xhr.setRequestHeader).toHaveBeenCalledWith( + 'access-token', + 'token-123' + ); + expect(xhr.setRequestHeader).toHaveBeenCalledWith('token-type', 'Bearer'); + expect(xhr.setRequestHeader).toHaveBeenCalledWith('client', 'client-123'); + expect(xhr.setRequestHeader).toHaveBeenCalledWith('expiry', '9999'); + expect(xhr.setRequestHeader).toHaveBeenCalledWith( + 'uid', + 'agent@example.com' + ); + }); + + it('does not set any header when there is no auth data', () => { + Auth.getAuthData.mockReturnValue(false); + const xhr = buildXhr(); + + setDirectUploadAuthHeaders(xhr); + + expect(xhr.setRequestHeader).not.toHaveBeenCalled(); + }); + + it('does not set any header when the access token is missing', () => { + Auth.getAuthData.mockReturnValue({ + client: 'client-123', + uid: 'agent@example.com', + }); + const xhr = buildXhr(); + + setDirectUploadAuthHeaders(xhr); + + expect(xhr.setRequestHeader).not.toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/dashboard/mixins/fileUploadMixin.js b/app/javascript/dashboard/mixins/fileUploadMixin.js index 965846401..e4a2c9dce 100644 --- a/app/javascript/dashboard/mixins/fileUploadMixin.js +++ b/app/javascript/dashboard/mixins/fileUploadMixin.js @@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { getMaxUploadSizeByChannel } from '@chatwoot/utils'; import { DirectUpload } from 'activestorage'; +import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper'; import { DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE, resolveMaximumFileUploadSize, @@ -77,10 +78,7 @@ export default { `/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`, { directUploadWillCreateBlobWithXHR: xhr => { - xhr.setRequestHeader( - 'api_access_token', - this.currentUser.access_token - ); + setDirectUploadAuthHeaders(xhr); }, } ); diff --git a/config/locales/en.yml b/config/locales/en.yml index 011957fb4..1b66699f2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -75,6 +75,7 @@ en: errors: account: + not_authorized: You are not authorized to access this account reporting_timezone: invalid: is not a valid timezone support_email: diff --git a/spec/controllers/api/v1/accounts/conversations/direct_uploads_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/direct_uploads_controller_spec.rb index 089b16b59..406f2e07c 100644 --- a/spec/controllers/api/v1/accounts/conversations/direct_uploads_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/direct_uploads_controller_spec.rb @@ -7,27 +7,120 @@ RSpec.describe '/api/v1/accounts/:account_id/conversations/:conversation_id/dire let(:contact) { create(:contact, account: account, email: nil) } let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) } + let(:blob_params) do + { + blob: { + filename: 'avatar.png', + byte_size: '1234', + checksum: 'dsjbsdhbfif3874823mnsdbf', + content_type: 'image/png' + } + } + end + + def create_direct_upload(headers) + post api_v1_account_conversation_direct_uploads_path(account_id: account.id, conversation_id: conversation.display_id), + params: blob_params, + headers: headers, + as: :json + end describe 'POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads' do - context 'when post request is made' do - it 'creates attachment message in conversation' do - contact + context 'when it is an unauthenticated request' do + it 'returns unauthorized without any credentials' do + create_direct_upload({}) - post api_v1_account_conversation_direct_uploads_path(account_id: account.id, conversation_id: conversation.display_id), - params: { - blob: { - filename: 'avatar.png', - byte_size: '1234', - checksum: 'dsjbsdhbfif3874823mnsdbf', - content_type: 'image/png' - } - }, - headers: { api_access_token: agent.access_token.token }, - as: :json + expect(response).to have_http_status(:unauthorized) + end + + it 'returns unauthorized with an empty api_access_token header' do + create_direct_upload({ api_access_token: '' }) + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns unauthorized with an invalid api_access_token header' do + create_direct_upload({ api_access_token: 'invalid-token' }) + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated request with an api access token' do + it 'creates the blob for the direct upload' do + create_direct_upload({ api_access_token: agent.access_token.token }) expect(response).to have_http_status(:success) - json_response = response.parsed_body - expect(json_response['content_type']).to eq('image/png') + expect(response.parsed_body['content_type']).to eq('image/png') + end + + it 'returns unauthorized for an agent of another account' do + other_agent = create(:user, account: create(:account), role: :agent) + + create_direct_upload({ api_access_token: other_agent.access_token.token }) + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns unauthorized for an agent bot token' do + agent_bot = create(:agent_bot, account: account) + + create_direct_upload({ api_access_token: agent_bot.access_token.token }) + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the account api_and_webhooks feature is disabled' do + before do + allow(Account).to receive(:find).and_call_original + allow(Account).to receive(:find).with(account.id.to_s).and_return(account) + allow(account).to receive(:api_and_webhooks_enabled?).and_return(false) + end + + it 'returns forbidden for a token-authenticated request' do + create_direct_upload({ api_access_token: agent.access_token.token }) + + expect(response).to have_http_status(:forbidden) + end + + it 'still creates the blob for a session-authenticated request' do + create_direct_upload(agent.create_new_auth_token) + + expect(response).to have_http_status(:success) + expect(response.parsed_body['content_type']).to eq('image/png') + end + end + + context 'when it is an authenticated session request' do + it 'creates the blob for the direct upload' do + create_direct_upload(agent.create_new_auth_token) + + expect(response).to have_http_status(:success) + expect(response.parsed_body['content_type']).to eq('image/png') + end + + it 'creates the blob when the serialized api access token is empty' do + create_direct_upload(agent.create_new_auth_token.merge('api_access_token' => '')) + + expect(response).to have_http_status(:success) + expect(response.parsed_body['content_type']).to eq('image/png') + end + end + + context 'when forgery protection is enabled' do + around do |example| + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + example.run + ActionController::Base.allow_forgery_protection = original + end + + it 'creates the blob for a token-authenticated request without a CSRF token' do + create_direct_upload({ api_access_token: agent.access_token.token }) + + expect(response).to have_http_status(:success) + expect(response.parsed_body['content_type']).to eq('image/png') end end end From 9749a3dc96b451657face269f0d4401474b06595 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 16 Jul 2026 18:20:44 +0530 Subject: [PATCH 02/33] feat: capture captain sessions for v2 assistant responses [CW-7485] (#14971) Records a `Captain::Session` row for every Captain V2 assistant response delivered in a conversation, so we can show how a response was generated and report on credit, FAQ, and document usage. Stacked on #14970 (the `captain_sessions` model). ## What changed - `FaqLookupTool` now records the retrieved FAQ ids (and their backing document ids) into the shared run state, accumulated across tool calls. - `AgentRunnerService` exposes the raw ai-agents run result via `last_run_result`; the `generate_response` return shape is unchanged, so the playground path is unaffected. - New `Captain::Assistant::SessionCaptureService` builds the session: scenario resolved from the answering agent name, model from `assistant.agent_model`, token usage plus the trimmed current-turn conversation history stored in `run_context`. - `ResponseBuilderJob` captures after delivery: `credits_consumed` mirrors the actual charge (1.0 for a billed response, 0.0 for handoffs, where the session points at the customer-facing handoff message). Capture runs outside the delivery transaction and swallows its own failures, so a logging bug can never block or roll back a customer reply. V1 responses and copilot are out of scope; copilot capture comes next. ## How to test On an account with `captain_integration_v2` enabled and an inbox connected to an assistant with approved FAQs, send a customer message on a pending conversation. After the assistant replies, a `Captain::Session` row should exist with the conversation as subject, the reply message as result, the FAQs/documents used, and the run context for that turn. Asking for a human agent should produce a zero-credit session pointing at the handoff message. CleanShot 2026-07-15 at 17 25
40@2x --- .../captain/conversation/message_builder.rb | 53 ++++++ .../conversation/response_builder_job.rb | 74 ++------ enterprise/app/models/concerns/agentable.rb | 14 +- .../captain/assistant/agent_runner_service.rb | 43 +---- .../captain/assistant/runner_state_helper.rb | 42 +++++ .../assistant/session_capture_service.rb | 64 +++++++ .../lib/captain/tools/faq_lookup_tool.rb | 13 +- .../conversation/response_builder_job_spec.rb | 99 +++++++++++ .../lib/captain/tools/faq_lookup_tool_spec.rb | 21 +++ .../assistant/agent_runner_service_spec.rb | 12 ++ .../assistant/session_capture_service_spec.rb | 167 ++++++++++++++++++ 11 files changed, 498 insertions(+), 104 deletions(-) create mode 100644 enterprise/app/jobs/captain/conversation/message_builder.rb create mode 100644 enterprise/app/services/captain/assistant/runner_state_helper.rb create mode 100644 enterprise/app/services/captain/assistant/session_capture_service.rb create mode 100644 spec/enterprise/services/captain/assistant/session_capture_service_spec.rb diff --git a/enterprise/app/jobs/captain/conversation/message_builder.rb b/enterprise/app/jobs/captain/conversation/message_builder.rb new file mode 100644 index 000000000..7954e9465 --- /dev/null +++ b/enterprise/app/jobs/captain/conversation/message_builder.rb @@ -0,0 +1,53 @@ +module Captain::Conversation::MessageBuilder + private + + def collect_previous_messages + @conversation + .messages + .where(message_type: [:incoming, :outgoing]) + .where(private: false) + .map do |message| + message_hash = { + content: prepare_multimodal_message_content(message), + role: determine_role(message) + } + + # Include agent_name if present in additional_attributes + message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present? + + message_hash + end + end + + def determine_role(message) + message.message_type == 'incoming' ? 'user' : 'assistant' + end + + def prepare_multimodal_message_content(message) + Captain::OpenAiMessageBuilderService.new(message: message).generate_content + end + + def create_messages + validate_message_content!(@response['response']) + create_outgoing_message(@response['response'], agent_name: @response['agent_name']) + end + + def validate_message_content!(content) + raise ArgumentError, 'Message content cannot be blank' if content.blank? + end + + def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false) + additional_attrs = {} + additional_attrs[:agent_name] = agent_name if agent_name.present? + + @conversation.messages.create!( + message_type: :outgoing, + account_id: account.id, + inbox_id: inbox.id, + sender: @assistant, + content: message_content, + additional_attributes: additional_attrs, + preserve_waiting_since: preserve_waiting_since + ) + end +end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 282d94862..fb0e72721 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -1,6 +1,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob include Captain::Conversation::V1ActionClassifier include Captain::Conversation::V1FalsePromiseHandler + include Captain::Conversation::MessageBuilder MAX_MESSAGE_LENGTH = 10_000 retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds @@ -44,9 +45,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def generate_response_with_v2 - @response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response( - message_history: collect_previous_messages_with_resolution_markers - ) + runner_service = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation) + message_history = Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform + @response = runner_service.generate_response(message_history: message_history) + @run_result = runner_service.last_run_result + process_response end @@ -65,6 +68,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob # left is the customer-facing follow-up message. process_v2_handoff end + capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0) elsif v1_handoff_requested? # V1 only signals via the response string — no state has been touched yet. If # the conversation isn't pending anymore, a human took over mid-run; bail out @@ -73,44 +77,16 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob process_v1_handoff elsif conversation_pending? + message = nil ActiveRecord::Base.transaction do - create_messages + message = create_messages Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}") account.increment_response_usage end + capture_assistant_session(result_message: message, credits_consumed: 1.0) end end - def collect_previous_messages - @conversation - .messages - .where(message_type: [:incoming, :outgoing]) - .where(private: false) - .map do |message| - message_hash = { - content: prepare_multimodal_message_content(message), - role: determine_role(message) - } - - # Include agent_name if present in additional_attributes - message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present? - - message_hash - end - end - - def collect_previous_messages_with_resolution_markers - Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform - end - - def determine_role(message) - message.message_type == 'incoming' ? 'user' : 'assistant' - end - - def prepare_multimodal_message_content(message) - Captain::OpenAiMessageBuilderService.new(message: message).generate_content - end - def v1_handoff_requested? legacy_v1_handoff_token? || classifier_v1_handoff_requested? end @@ -157,34 +133,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def create_handoff_message(preserve_waiting_since: false) - create_outgoing_message( + @handoff_message = create_outgoing_message( @assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'), preserve_waiting_since: preserve_waiting_since ) end - def create_messages - validate_message_content!(@response['response']) - create_outgoing_message(@response['response'], agent_name: @response['agent_name']) - end - - def validate_message_content!(content) - raise ArgumentError, 'Message content cannot be blank' if content.blank? - end - - def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false) - additional_attrs = {} - additional_attrs[:agent_name] = agent_name if agent_name.present? - - @conversation.messages.create!( - message_type: :outgoing, - account_id: account.id, - inbox_id: inbox.id, - sender: @assistant, - content: message_content, - additional_attributes: additional_attrs, - preserve_waiting_since: preserve_waiting_since - ) + # Capture runs outside the delivery transaction and never raises (the service + # swallows its own failures): a session-logging bug must never roll back the + # customer reply or trigger the top-level handle_error handoff on top of it. + def capture_assistant_session(result_message:, credits_consumed:) + Captain::Assistant::SessionCaptureService.new(assistant: @assistant, conversation: @conversation, run_result: @run_result, + result_message: result_message, credits_consumed: credits_consumed).capture end def handle_error(error) diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb index 086deedc1..c12e15f52 100644 --- a/enterprise/app/models/concerns/agentable.rb +++ b/enterprise/app/models/concerns/agentable.rb @@ -31,6 +31,13 @@ module Concerns::Agentable Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access) end + def agent_model + route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account) + return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2') + + installation_model.presence || route[:model] + end + private def agent_name @@ -45,13 +52,6 @@ module Concerns::Agentable [] # Default implementation, override if needed end - def agent_model - route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account) - return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2') - - installation_model.presence || route[:model] - end - def installation_model InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value end diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 09070eba6..197b2e39b 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -5,20 +5,10 @@ class Captain::Assistant::AgentRunnerService include Integrations::LlmInstrumentationConstants include Captain::Assistant::RunnerCallbacksHelper include Captain::Assistant::TracePayloadHelper + include Captain::Assistant::RunnerStateHelper - CONVERSATION_STATE_ATTRIBUTES = %i[ - id display_id inbox_id contact_id status priority - label_list custom_attributes additional_attributes - ].freeze + attr_reader :last_run_result - CONTACT_STATE_ATTRIBUTES = %i[ - id name email phone_number identifier contact_type - custom_attributes additional_attributes - ].freeze - - CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze - - CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze def initialize(assistant:, conversation: nil, callbacks: {}, source: nil) @assistant = assistant @conversation = conversation @@ -29,9 +19,9 @@ class Captain::Assistant::AgentRunnerService def generate_response(message_history: []) message_to_process, context = run_payload(message_history) - result = runner.run(message_to_process, context: context, max_turns: 10) + @last_run_result = runner.run(message_to_process, context: context, max_turns: 10) - process_agent_result(result) + process_agent_result(@last_run_result) rescue StandardError => e # In rake/local runs, conversation may not be present, so account is optional here. ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception @@ -111,31 +101,6 @@ class Captain::Assistant::AgentRunnerService } end - def build_state - state = { - account_id: @assistant.account_id, - assistant_id: @assistant.id, - assistant_config: @assistant.config, - timezone: @conversation&.inbox&.timezone.presence || 'UTC' - } - state[:source] = @source if @source.present? - - build_conversation_state(state) if @conversation - state - end - - def build_conversation_state(state) - state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES) - state[:channel_type] = @conversation.inbox&.channel_type - state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact - state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign - state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox - end - - def slice_attrs(record, keys) - record.attributes.symbolize_keys.slice(*keys) - end - def build_and_wire_agents assistant_agent = @assistant.agent scenario_agents = @assistant.scenarios.enabled.map(&:agent) diff --git a/enterprise/app/services/captain/assistant/runner_state_helper.rb b/enterprise/app/services/captain/assistant/runner_state_helper.rb new file mode 100644 index 000000000..d2d6146f8 --- /dev/null +++ b/enterprise/app/services/captain/assistant/runner_state_helper.rb @@ -0,0 +1,42 @@ +module Captain::Assistant::RunnerStateHelper + CONVERSATION_STATE_ATTRIBUTES = %i[ + id display_id inbox_id contact_id status priority + label_list custom_attributes additional_attributes + ].freeze + + CONTACT_STATE_ATTRIBUTES = %i[ + id name email phone_number identifier contact_type + custom_attributes additional_attributes + ].freeze + + CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze + + CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze + + private + + def build_state + state = { + account_id: @assistant.account_id, + assistant_id: @assistant.id, + assistant_config: @assistant.config, + timezone: @conversation&.inbox&.timezone.presence || 'UTC' + } + state[:source] = @source if @source.present? + + build_conversation_state(state) if @conversation + state + end + + def build_conversation_state(state) + state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES) + state[:channel_type] = @conversation.inbox&.channel_type + state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact + state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign + state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox + end + + def slice_attrs(record, keys) + record.attributes.symbolize_keys.slice(*keys) + end +end diff --git a/enterprise/app/services/captain/assistant/session_capture_service.rb b/enterprise/app/services/captain/assistant/session_capture_service.rb new file mode 100644 index 000000000..36af50308 --- /dev/null +++ b/enterprise/app/services/captain/assistant/session_capture_service.rb @@ -0,0 +1,64 @@ +class Captain::Assistant::SessionCaptureService + SCENARIO_AGENT_REGEX = /\A#{Captain::Scenario::HANDOFF_KEY_PREFIX}_(\d+)_/ + + def initialize(assistant:, conversation:, run_result:, result_message:, credits_consumed:) + @assistant = assistant + @conversation = conversation + @run_result = run_result + @result_message = result_message + @credits_consumed = credits_consumed + end + + def capture + # TODO: Capture failed runs once error-session semantics are defined. For now, + # only successful runs that produce a customer-facing reply or handoff are recorded. + return unless @run_result&.success? + + capture! + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception + Rails.logger.error("[CAPTAIN][SessionCaptureService] Capture failed for conversation=#{@conversation.display_id}: #{e.message}") + end + + def capture! + model = @assistant.agent_model + metadata = context.dig(:state, :cw_metadata) || {} + + Captain::AgentSession.create!( + assistant: @assistant, + session_type: :assistant, + subject: @conversation, + result: @result_message, + llm_model: "#{Llm::Models.provider_for(model)}-#{model}", + credits_consumed: @credits_consumed, + faq_ids: metadata[:faq_ids] || [], + document_ids: metadata[:document_ids] || [], + scenario_ids: scenario_ids, + run_context: current_turn_history + ) + end + + private + + def context + @run_result.context || {} + end + + def scenario_ids + ids = current_turn_history.filter_map do |message| + next unless message[:role].to_s == 'assistant' + + message[:agent_name].to_s.match(SCENARIO_AGENT_REGEX)&.[](1)&.to_i + end.uniq + + ids & @assistant.scenarios.where(id: ids).pluck(:id) + end + + # Trim to the current turn: the last user message and everything after it + # (assistant replies, tool calls/results, handoff hops). + def current_turn_history + history = Array(context[:conversation_history]) + last_user_index = history.rindex { |message| message[:role].to_s == 'user' } + last_user_index ? history[last_user_index..] : history + end +end diff --git a/enterprise/lib/captain/tools/faq_lookup_tool.rb b/enterprise/lib/captain/tools/faq_lookup_tool.rb index 93dd90259..2a16e7e99 100644 --- a/enterprise/lib/captain/tools/faq_lookup_tool.rb +++ b/enterprise/lib/captain/tools/faq_lookup_tool.rb @@ -2,11 +2,12 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool description 'Search FAQ responses using semantic similarity to find relevant answers' param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database' - def perform(_tool_context, query:) + def perform(tool_context, query:) log_tool_usage('searching', { query: query }) # Use existing vector search on approved responses responses = @assistant.responses.approved.search(query).to_a + record_retrieved_sources(tool_context, responses) if responses.empty? log_tool_usage('no_results', { query: query }) @@ -19,6 +20,16 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool private + def record_retrieved_sources(tool_context, responses) + return if responses.empty? + + metadata = tool_context.state[:cw_metadata] ||= {} + metadata[:faq_ids] = Array(metadata[:faq_ids]) | responses.map(&:id) + + document_ids = responses.filter_map { |response| response.documentable_id if response.documentable_type == 'Captain::Document' } + metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids + end + def format_responses(responses) responses.map { |response| format_response(response) }.join end diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 266954d0f..eb1cb641c 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -22,6 +22,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' }) allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service) allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' }) + allow(mock_agent_runner_service).to receive(:last_run_result).and_return(nil) allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service) allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' }) allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service) @@ -72,6 +73,12 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) end + it 'does not create a captain session' do + expect do + described_class.perform_now(conversation, assistant) + end.not_to change(Captain::AgentSession, :count) + end + it 'does not run the action classifier when the classifier feature is disabled' do expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new) @@ -490,6 +497,98 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do end end + context 'when capturing assistant sessions' do + let(:run_context) do + { + session_id: "#{account.id}_#{conversation.display_id}", + current_agent: 'Assistant', + turn_count: 1, + conversation_history: [ + { role: :user, content: 'Hello' }, + { role: :assistant, content: 'Hey, welcome to Captain V2', agent_name: 'Assistant' } + ], + state: { cw_metadata: { faq_ids: [7, 9], document_ids: [3] } } + } + end + let(:usage) do + Agents::RunContext::Usage.new.tap do |u| + u.input_tokens = 100 + u.output_tokens = 20 + u.total_tokens = 120 + end + end + let(:run_result) { Agents::RunResult.new(output: { 'response' => 'Hey, welcome to Captain V2' }, usage: usage, context: run_context) } + + before do + allow(account).to receive(:feature_enabled?).and_return(false) + allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true) + allow(mock_agent_runner_service).to receive(:last_run_result).and_return(run_result) + end + + it 'creates a session for a delivered response' do + described_class.perform_now(conversation, assistant) + + session = Captain::AgentSession.last + expect(session).to have_attributes( + account_id: account.id, + assistant_id: assistant.id, + subject_id: conversation.id, + subject_type: 'Conversation', + result_id: conversation.messages.outgoing.last.id, + result_type: 'Message', + llm_model: 'openai-gpt-5.2', + credits_consumed: 1.0, + faq_ids: [7, 9], + document_ids: [3], + scenario_ids: [], + user_id: nil + ) + expect(session).to be_session_assistant + expect(session.run_context.first).to include('role' => 'user', 'content' => 'Hello') + end + + it 'creates a zero-credit session when the handoff tool fired' do + allow(mock_agent_runner_service).to receive(:generate_response) do + conversation.update!(status: :open) + { 'response' => 'Let me connect you', 'handoff_tool_called' => true } + end + + described_class.perform_now(conversation, assistant) + + session = Captain::AgentSession.last + expect(session.credits_consumed).to eq(0.0) + expect(session.result_id).to eq(conversation.messages.outgoing.where(private: false).last.id) + expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0) + end + + it 'creates a zero-credit session when the handoff tool fired but failed to commit' do + allow(mock_agent_runner_service).to receive(:generate_response).and_return({ + 'response' => 'I tried to hand off', + 'handoff_tool_called' => true + }) + + described_class.perform_now(conversation, assistant) + + session = Captain::AgentSession.last + expect(session.credits_consumed).to eq(0.0) + expect(session.result_id).to eq(conversation.messages.outgoing.where(private: false).last.id) + end + + it 'still delivers the reply when session capture fails' do + allow(Captain::AgentSession).to receive(:create!).and_raise(StandardError, 'capture failed') + allow(ChatwootExceptionTracker).to receive(:new).and_call_original + + expect do + described_class.perform_now(conversation, assistant) + end.not_to raise_error + + expect(conversation.messages.outgoing.count).to eq(1) + expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain V2') + expect(conversation.reload.status).to eq('pending') + expect(ChatwootExceptionTracker).to have_received(:new) + end + end + # Regression (PR #13417): wrapping create_handoff_message and bot_handoff! in the # same transaction defers the message's after_create_commit until commit, at which # point it clears waiting_since (bot_response). The handoff path must stay outside diff --git a/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb index ccae44ac2..dc2b9f6c6 100644 --- a/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb @@ -80,6 +80,21 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do tool.perform(tool_context, query: 'password reset') end + + it 'records retrieved faq ids and document ids into Chatwoot metadata' do + tool.perform(tool_context, query: 'password reset') + + expect(tool_context.state.dig(:cw_metadata, :faq_ids)).to contain_exactly(response1.id, response2.id) + expect(tool_context.state.dig(:cw_metadata, :document_ids)).to contain_exactly(document.id) + end + + it 'accumulates unique ids across multiple calls' do + tool.perform(tool_context, query: 'password reset') + tool.perform(tool_context, query: 'password reset again') + + expect(tool_context.state.dig(:cw_metadata, :faq_ids)).to contain_exactly(response1.id, response2.id) + expect(tool_context.state.dig(:cw_metadata, :document_ids)).to contain_exactly(document.id) + end end context 'when no FAQs found' do @@ -99,6 +114,12 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do tool.perform(tool_context, query: 'nonexistent topic') end + + it 'leaves shared state untouched' do + tool.perform(tool_context, query: 'nonexistent topic') + + expect(tool_context.state).to eq({}) + end end context 'with blank query' do diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 6fd8d50ab..e88d040b3 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -170,6 +170,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false }) end + it 'exposes the raw run result via last_run_result' do + service.generate_response(message_history: message_history) + + expect(service.last_run_result).to eq(mock_result) + end + context 'when handoff tool was called during agent execution' do let(:runner_context) { { captain_v2_handoff_tool_called: true } } let(:mock_result) do @@ -246,6 +252,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do service.generate_response(message_history: message_history) end + it 'leaves last_run_result nil' do + service.generate_response(message_history: message_history) + + expect(service.last_run_result).to be_nil + end + context 'when conversation is nil' do subject(:service) { described_class.new(assistant: assistant, conversation: nil) } diff --git a/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb b/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb new file mode 100644 index 000000000..ef25b1e2b --- /dev/null +++ b/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb @@ -0,0 +1,167 @@ +require 'rails_helper' + +RSpec.describe Captain::Assistant::SessionCaptureService do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:conversation) { create(:conversation, account: account) } + let(:result_message) { create(:message, account: account, conversation: conversation) } + + let(:usage) do + Agents::RunContext::Usage.new.tap do |u| + u.input_tokens = 120 + u.output_tokens = 40 + u.total_tokens = 160 + end + end + + let(:conversation_history) do + [ + { role: :user, content: 'Hi, my internet is not working' }, + { role: :assistant, content: 'Let me check', agent_name: 'Assistant' }, + { role: :user, content: 'CUST001' }, + { role: :assistant, content: '', agent_name: 'Assistant', tool_calls: [{ 'id' => 'call_1', 'name' => 'faq_lookup' }] }, + { role: :tool, content: 'Restart the modem', tool_call_id: 'call_1' }, + { role: :assistant, content: 'Please restart your modem', agent_name: 'Assistant' } + ] + end + + let(:run_context) do + { + session_id: "#{account.id}_#{conversation.display_id}", + current_agent: 'Assistant', + turn_count: 2, + conversation_history: conversation_history, + state: { cw_metadata: { faq_ids: [11, 12], document_ids: [5] } } + } + end + + let(:run_result) { Agents::RunResult.new(output: { 'response' => 'Please restart your modem' }, usage: usage, context: run_context) } + + let(:service) do + described_class.new( + assistant: assistant, + conversation: conversation, + run_result: run_result, + result_message: result_message, + credits_consumed: 1.0 + ) + end + + before do + allow(assistant).to receive(:agent_model).and_return('gpt-5.2') + end + + describe '#capture' do + it 'creates the session' do + expect { service.capture }.to change(Captain::AgentSession, :count).by(1) + end + + it 'does nothing when there is no run result' do + service = described_class.new( + assistant: assistant, conversation: conversation, run_result: nil, + result_message: result_message, credits_consumed: 1.0 + ) + + expect { service.capture }.not_to change(Captain::AgentSession, :count) + end + + it 'does nothing when the run failed' do + failed_result = Agents::RunResult.new(output: nil, error: StandardError.new('run failed'), context: run_context, usage: usage) + service = described_class.new( + assistant: assistant, conversation: conversation, run_result: failed_result, + result_message: nil, credits_consumed: 0.0 + ) + + expect { service.capture }.not_to change(Captain::AgentSession, :count) + end + + it 'reports failures without raising' do + allow(Captain::AgentSession).to receive(:create!).and_raise(StandardError, 'capture failed') + allow(ChatwootExceptionTracker).to receive(:new).and_call_original + + expect { service.capture }.not_to raise_error + expect(ChatwootExceptionTracker).to have_received(:new) + end + end + + describe '#capture!' do + it 'creates an assistant session with all attributes' do + session = service.capture! + + expect(session).to have_attributes( + account_id: account.id, + assistant_id: assistant.id, + subject_id: conversation.id, + subject_type: 'Conversation', + result_id: result_message.id, + result_type: 'Message', + llm_model: 'openai-gpt-5.2', + credits_consumed: 1.0, + faq_ids: [11, 12], + document_ids: [5], + scenario_ids: [], + user_id: nil + ) + expect(session).to be_session_assistant + end + + it 'stores the trimmed current turn in run_context' do + history = service.capture!.run_context + expect(history.size).to eq(4) + expect(history.first).to include('role' => 'user', 'content' => 'CUST001') + end + + it 'stores the full history when it contains no user message' do + run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user } + + history = service.capture!.run_context + + expect(history.size).to eq(4) + end + + it 'handles a successful run result without context or usage' do + run_result = Agents::RunResult.new(output: { 'response' => 'Hello' }) + service = described_class.new( + assistant: assistant, conversation: conversation, run_result: run_result, + result_message: result_message, credits_consumed: 1.0 + ) + + session = service.capture! + + expect(session.result).to eq(result_message) + expect(session.faq_ids).to eq([]) + expect(session.document_ids).to eq([]) + expect(session.run_context).to eq([]) + end + + it 'extracts every scenario that authored a message in the current turn' do + first_scenario = create(:captain_scenario, assistant: assistant, account: account) + second_scenario = create(:captain_scenario, assistant: assistant, account: account) + run_context[:conversation_history] = [ + { role: :user, content: 'Help with my refund' }, + { role: :assistant, content: '', agent_name: first_scenario.handoff_key, tool_calls: [] }, + { role: :assistant, content: 'Checking', agent_name: second_scenario.handoff_key }, + { role: :assistant, content: 'Done', agent_name: first_scenario.handoff_key } + ] + run_context[:current_agent] = 'Assistant' + + expect(service.capture!.scenario_ids).to eq([first_scenario.id, second_scenario.id]) + end + + it 'leaves scenario ids empty for the primary assistant agent' do + run_context[:current_agent] = 'Assistant' + + expect(service.capture!.scenario_ids).to eq([]) + end + + it 'does not capture a scenario belonging to another assistant' do + scenario = create(:captain_scenario, assistant: create(:captain_assistant, account: account), account: account) + run_context[:conversation_history] = [ + { role: :user, content: 'Help' }, + { role: :assistant, content: 'No', agent_name: scenario.handoff_key } + ] + + expect(service.capture!.scenario_ids).to eq([]) + end + end +end From 5e811eab9952181788822d15c52d09b9eefc6914 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 16 Jul 2026 20:28:49 +0400 Subject: [PATCH 03/33] chore(inbox): re-enable Instagram inbox creation (#15042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instagram inbox creation is available again on Chatwoot Cloud. Users can discover and connect Instagram during onboarding or from Add Inbox, while WhatsApp restrictions and existing-inbox Instagram advisories remain unchanged. Closes https://linear.app/chatwoot/issue/CW-7549/enable-instagram ## How to test 1. On Chatwoot Cloud, open Add Inbox and confirm Instagram can be selected. 2. Confirm **Continue with Instagram** is enabled and starts the OAuth flow. 3. In onboarding, confirm Instagram is displayed and can start OAuth. 4. Confirm WhatsApp embedded signup remains restricted. ## What changed - Removed the Cloud-only Instagram filter and OAuth guard from onboarding. - Re-enabled the regular Instagram inbox creation action on Cloud. - Removed the obsolete “Instagram inbox creation is temporarily unavailable” copy. - Updated the onboarding expectation for Chatwoot Cloud. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../dashboard/i18n/locale/en/inboxMgmt.json | 1 - .../inbox-setup/useChannelConfig.js | 6 ++- .../inbox-setup/useChannelConnect.js | 7 ---- .../inbox-setup/useDetectedChannels.spec.js | 24 +++++++++++- .../settings/inbox/channels/Instagram.vue | 37 +------------------ 5 files changed, 30 insertions(+), 45 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 07506c20f..365ddff1d 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -59,7 +59,6 @@ "ERROR_AUTH": "There was an error connecting to Instagram, please try again", "NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.", "DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.", - "RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.", "SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.", "STATUS_LINK": "View status update" }, diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js index 3d19943d3..5d50cad29 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js @@ -1,4 +1,6 @@ import { useMapGetter } from 'dashboard/composables/store'; +import { useAccount } from 'dashboard/composables/useAccount'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; // OAuth/SDK channels need installation-level app credentials to be usable. When // the credential is missing the channel is "not configured" and is hidden from @@ -8,6 +10,7 @@ import { useMapGetter } from 'dashboard/composables/store'; export function useChannelConfig() { const globalConfig = useMapGetter('globalConfig/get'); const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud'); + const { isCloudFeatureEnabled } = useAccount(); const installationConfig = window.chatwootConfig || {}; const CHANNEL_CONFIGURED = { @@ -20,7 +23,8 @@ export function useChannelConfig() { Boolean(installationConfig.whatsappConfigurationId), facebook: () => Boolean(installationConfig.fbAppId), instagram: () => - !isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId), + Boolean(installationConfig.instagramAppId) && + isCloudFeatureEnabled(FEATURE_FLAGS.CHANNEL_INSTAGRAM), tiktok: () => Boolean(installationConfig.tiktokAppId), gmail: () => Boolean(installationConfig.googleOAuthClientId), outlook: () => Boolean(globalConfig.value.azureAppId), diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js index 57411c43c..bd34d5f20 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js @@ -1,7 +1,6 @@ import { useI18n } from 'vue-i18n'; import { useAlert } from 'dashboard/composables'; import { useStore } from 'dashboard/composables/store'; -import { useAccount } from 'dashboard/composables/useAccount'; import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; import googleClient from 'dashboard/api/channel/googleClient'; @@ -24,17 +23,11 @@ export function useChannelConnect() { const { t } = useI18n(); const store = useStore(); const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); - const { isOnChatwootCloud } = useAccount(); const connectViaOAuth = async provider => { const client = OAUTH_CLIENTS[provider]; if (!client) return; - if (provider === 'instagram' && isOnChatwootCloud.value) { - useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING')); - return; - } - try { const { data: { url }, diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js index 7bc39adf1..9e8e5e3db 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js @@ -13,6 +13,7 @@ vi.mock('vue-router'); // channel_type, social ordering) derived from CHANNEL_LIST. const mountComposable = ({ brandInfo, + features = { channel_instagram: true }, inboxes = [], isOnChatwootCloud = false, } = {}) => { @@ -30,8 +31,11 @@ const mountComposable = ({ getters: { getAccount: () => () => ({ id: 1, + features, custom_attributes: { brand_info: brandInfo }, }), + isFeatureEnabledonAccount: () => (_accountId, feature) => + Boolean(features[feature]), }, }, inboxes: { @@ -207,7 +211,7 @@ describe('useDetectedChannels', () => { ]); }); - it('hides Instagram from onboarding on Chatwoot Cloud', () => { + it('keeps Instagram available on Chatwoot Cloud when enabled for the account', () => { const { displayedChannels } = mountComposable({ isOnChatwootCloud: true, brandInfo: { @@ -218,6 +222,24 @@ describe('useDetectedChannels', () => { }, }); + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'instagram', + 'tiktok', + ]); + }); + + it('hides Instagram when disabled for the account', () => { + const { displayedChannels } = mountComposable({ + features: { channel_instagram: false }, + isOnChatwootCloud: true, + brandInfo: { + socials: [ + { type: 'instagram', url: 'https://instagram.com/acme' }, + { type: 'tiktok', url: 'https://tiktok.com/@acme' }, + ], + }, + }); + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ 'tiktok', ]); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue index ee7abe9a9..253a41cd4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue @@ -1,23 +1,15 @@ + + diff --git a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue index 1ab9e9503..2737cb353 100644 --- a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue +++ b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue @@ -1,6 +1,8 @@ + + diff --git a/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js new file mode 100644 index 000000000..b07868863 --- /dev/null +++ b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js @@ -0,0 +1,224 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; +import ReorderableMultiSelect from '../ReorderableMultiSelect.vue'; + +const OPTIONS = [ + { value: 1, label: 'Getting started', subtitle: 'Guides' }, + { value: 2, label: 'Billing', subtitle: 'Payments' }, + { value: 3, label: 'Security' }, + { value: 4, label: 'API', icon: '🔌', iconColor: '#000' }, +]; + +// A findable dropdown stub that exposes the `focus()` the component calls on open. +const ComboBoxDropdownStub = { + name: 'ComboBoxDropdown', + props: [ + 'open', + 'options', + 'searchValue', + 'searchPlaceholder', + 'emptyState', + 'loading', + ], + emits: ['select', 'update:searchValue'], + methods: { focus() {} }, + template: '
', +}; + +// Renders a real ', +}; + +const mountSelect = (props = {}, slots = {}) => + mount(ReorderableMultiSelect, { + props: { options: OPTIONS, max: 3, ...props }, + slots, + global: { + stubs: { + Button: ButtonStub, + ComboBoxDropdown: ComboBoxDropdownStub, + Spinner: true, + Icon: true, + EmojiIcon: true, + OnClickOutside: { template: '
' }, + }, + }, + }); + +const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub); +const addTrigger = wrapper => + wrapper.findAll('button').find(button => !button.attributes('data-icon')); +const removeButtons = wrapper => + wrapper.findAll('button[data-icon="i-lucide-x"]'); +const rows = wrapper => wrapper.findAll('[draggable="true"]'); +const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0]; + +describe('ReorderableMultiSelect', () => { + describe('rendering selected rows', () => { + it('renders rows in model order with labels resolved from options', () => { + const wrapper = mountSelect({ modelValue: [2, 1] }); + + const labels = rows(wrapper).map(row => row.find('p').text()); + expect(labels).toEqual(['Billing', 'Getting started']); + }); + + it('falls back to the stringified id when an option is unknown', () => { + const wrapper = mountSelect({ modelValue: [99] }); + + expect(rows(wrapper)[0].find('p').text()).toBe('99'); + }); + + it('renders the progress dots filled up to the selection count', () => { + const wrapper = mountSelect({ + modelValue: [1, 2], + max: 3, + label: 'Tags', + }); + + const filled = wrapper.findAll('.bg-n-brand').length; + expect(filled).toBe(2); + }); + + it('exposes remaining and max to the counter slot', () => { + const wrapper = mountSelect( + { modelValue: [1], max: 3 }, + { counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) } + ); + + expect(wrapper.text()).toContain('2/3'); + }); + }); + + describe('adding options', () => { + it('appends the chosen option to the model', () => { + const wrapper = mountSelect({ modelValue: [1] }); + + dropdown(wrapper).vm.$emit('select', OPTIONS[1]); + + expect(lastModel(wrapper)).toEqual([1, 2]); + }); + + it('hides the add trigger once the model reaches max', () => { + const wrapper = mountSelect({ modelValue: [1, 2], max: 2 }); + + expect(addTrigger(wrapper)).toBeUndefined(); + expect(dropdown(wrapper).exists()).toBe(false); + }); + + it('closes the dropdown when the last slot is filled', async () => { + const wrapper = mountSelect({ modelValue: [1], max: 2 }); + await addTrigger(wrapper).trigger('click'); + expect(dropdown(wrapper).props('open')).toBe(true); + + dropdown(wrapper).vm.$emit('select', OPTIONS[1]); + await wrapper.vm.$nextTick(); + + // Reaching max removes the trigger (and its dropdown) entirely. + expect(dropdown(wrapper).exists()).toBe(false); + }); + + it('excludes already-selected options from the dropdown', () => { + const wrapper = mountSelect({ modelValue: [1] }); + + const values = dropdown(wrapper) + .props('options') + .map(option => option.value); + expect(values).toEqual([2, 3, 4]); + }); + }); + + describe('removing options', () => { + it('removes the clicked item from the model', async () => { + const wrapper = mountSelect({ modelValue: [1, 2, 3] }); + + await removeButtons(wrapper)[1].trigger('click'); + + expect(lastModel(wrapper)).toEqual([1, 3]); + }); + }); + + describe('searching', () => { + it('filters options locally by label', async () => { + const wrapper = mountSelect({ modelValue: [] }); + + dropdown(wrapper).vm.$emit('update:searchValue', 'bill'); + await wrapper.vm.$nextTick(); + + const values = dropdown(wrapper) + .props('options') + .map(option => option.value); + expect(values).toEqual([2]); + }); + + it('emits search and skips local filtering when serverSearch is set', async () => { + const wrapper = mountSelect({ modelValue: [], serverSearch: true }); + + dropdown(wrapper).vm.$emit('update:searchValue', 'bill'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('search').at(-1)).toEqual(['bill']); + // All unselected options remain; the parent owns filtering. + expect(dropdown(wrapper).props('options')).toHaveLength(4); + }); + + it('emits an empty search when the trigger opens', async () => { + const wrapper = mountSelect({ modelValue: [1] }); + + await addTrigger(wrapper).trigger('click'); + + expect(wrapper.emitted('search').at(-1)).toEqual(['']); + expect(dropdown(wrapper).props('open')).toBe(true); + }); + }); + + describe('reordering', () => { + it('moves a row to the dropped position within the model', async () => { + const wrapper = mountSelect({ modelValue: [1, 2, 3] }); + + await rows(wrapper)[0].trigger('dragstart'); + await rows(wrapper)[2].trigger('dragover'); + + expect(lastModel(wrapper)).toEqual([2, 3, 1]); + }); + }); + + describe('loading state', () => { + it('shows skeleton rows when loading a non-empty, closed selection', () => { + const wrapper = mountSelect({ modelValue: [1, 2], loading: true }); + + const skeleton = wrapper.find('[aria-busy="true"]'); + expect(skeleton.exists()).toBe(true); + expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0); + }); + + it('does not show skeletons when the selection is empty', () => { + const wrapper = mountSelect({ modelValue: [], loading: true }); + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false); + }); + + it('shows the real rows, not skeletons, while searching in an open dropdown', async () => { + // Open first (trigger is enabled), then a live search turns loading on. + const wrapper = mountSelect({ modelValue: [1, 2] }); + await addTrigger(wrapper).trigger('click'); + + await wrapper.setProps({ loading: true }); + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false); + expect(rows(wrapper)).toHaveLength(2); + }); + + it('forwards loading to the dropdown and disables the closed trigger', () => { + const wrapper = mountSelect({ modelValue: [1], loading: true }); + + expect(dropdown(wrapper).props('loading')).toBe(true); + expect(addTrigger(wrapper).attributes('disabled')).toBeDefined(); + }); + }); +}); diff --git a/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js b/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js new file mode 100644 index 000000000..1651f8e6d --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js @@ -0,0 +1,120 @@ +import { effectScope } from 'vue'; +import { useAbortableRequest } from '../useAbortableRequest'; + +// Resolves when the request "completes", rejects like axios does when the +// signal is aborted mid-flight. +const abortableRunner = + (value, { fail = false } = {}) => + signal => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + const error = new Error('canceled'); + error.name = 'CanceledError'; + reject(error); + }); + // Defer so a follow-up `run`/`abort` can supersede this one first. + Promise.resolve().then(() => { + if (signal.aborted) return; + if (fail) { + reject(new Error('boom')); + return; + } + resolve(value); + }); + }); + +describe('useAbortableRequest', () => { + it('passes a fresh signal to the runner and returns its result', async () => { + const { run } = useAbortableRequest(); + let received = null; + + const result = await run(signal => { + received = signal; + return Promise.resolve('ok'); + }); + + expect(received).toBeInstanceOf(AbortSignal); + expect(received.aborted).toBe(false); + expect(result).toBe('ok'); + }); + + it('toggles isPending around the request', async () => { + const { run, isPending } = useAbortableRequest(); + expect(isPending.value).toBe(false); + + const pending = run(() => Promise.resolve('done')); + expect(isPending.value).toBe(true); + + await pending; + expect(isPending.value).toBe(false); + }); + + it('aborts the previous request when a new one starts', async () => { + const { run } = useAbortableRequest(); + + const first = run(abortableRunner('first')); + const second = run(abortableRunner('second')); + + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBe('second'); + }); + + it('returns the onAbort value when a request is superseded', async () => { + const { run } = useAbortableRequest(); + + const first = run(abortableRunner('first'), { onAbort: null }); + const second = run(abortableRunner('second')); + + await expect(first).resolves.toBeNull(); + await expect(second).resolves.toBe('second'); + }); + + it('abort cancels the in-flight request and clears isPending', async () => { + const { run, abort, isPending } = useAbortableRequest(); + + const pending = run(abortableRunner('value')); + expect(isPending.value).toBe(true); + + abort(); + + await expect(pending).resolves.toBeUndefined(); + expect(isPending.value).toBe(false); + }); + + it('rethrows non-abort errors and clears isPending', async () => { + const { run, isPending } = useAbortableRequest(); + + await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow( + 'boom' + ); + expect(isPending.value).toBe(false); + }); + + it('aborts the in-flight request when its scope is disposed', async () => { + const scope = effectScope(); + let request; + scope.run(() => { + request = useAbortableRequest(); + }); + + const pending = request.run(abortableRunner('value')); + expect(request.isPending.value).toBe(true); + + scope.stop(); + + await expect(pending).resolves.toBeUndefined(); + expect(request.isPending.value).toBe(false); + }); + + it('keeps separate controllers per instance', async () => { + const a = useAbortableRequest(); + const b = useAbortableRequest(); + + const first = a.run(abortableRunner('a')); + // Starting b's request must not abort a's. + const second = b.run(abortableRunner('b')); + + await expect(first).resolves.toBe('a'); + await expect(second).resolves.toBe('b'); + }); +}); diff --git a/app/javascript/dashboard/composables/useAbortableRequest.js b/app/javascript/dashboard/composables/useAbortableRequest.js new file mode 100644 index 000000000..c6e33367a --- /dev/null +++ b/app/javascript/dashboard/composables/useAbortableRequest.js @@ -0,0 +1,62 @@ +import { getCurrentScope, onScopeDispose, ref } from 'vue'; + +export const isAbortError = error => + error?.name === 'AbortError' || + error?.name === 'CanceledError' || + error?.code === 'ERR_CANCELED'; + +/** + * Keeps only the latest request alive. Starting a new `run` (or calling + * `abort`) cancels the previous request through its `AbortSignal`, so + * out-of-order responses can never overwrite fresher data. + * + * @example + * const { run, abort, isPending } = useAbortableRequest(); + * const results = await run(signal => api.search(query, { signal })); + * + * @returns {{ + * run: (runner: (signal: AbortSignal) => Promise, options?: { onAbort?: any }) => Promise, + * abort: () => void, + * isPending: import('vue').Ref, + * }} + * `run` resolves with the runner's value, or `options.onAbort` (default + * `undefined`) when the request was superseded. Non-abort errors are rethrown. + */ +export function useAbortableRequest() { + const isPending = ref(false); + let controller = null; + + const abort = () => { + controller?.abort(); + controller = null; + isPending.value = false; + }; + + const run = async (runner, { onAbort } = {}) => { + controller?.abort(); + const currentController = new AbortController(); + controller = currentController; + isPending.value = true; + + try { + return await runner(currentController.signal); + } catch (error) { + if (currentController.signal.aborted || isAbortError(error)) + return onAbort; + throw error; + } finally { + // Only the latest run owns the shared state; a superseded run leaves it + // for the run that replaced it. + if (controller === currentController) { + controller = null; + isPending.value = false; + } + } + }; + + // Cancel any in-flight request when the owning scope is disposed. + // Guarded so the composable can also be used outside an effect scope. + if (getCurrentScope()) onScopeDispose(abort); + + return { run, abort, isPending }; +} diff --git a/app/javascript/dashboard/helper/portalHelper.js b/app/javascript/dashboard/helper/portalHelper.js index 89f13f8cd..2d5272dde 100644 --- a/app/javascript/dashboard/helper/portalHelper.js +++ b/app/javascript/dashboard/helper/portalHelper.js @@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = { value: 'customize-content', icon: 'i-lucide-pencil', }, + selectPopularContent: { + label: + 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT', + action: 'select-popular-content', + value: 'select-popular-content', + icon: 'i-lucide-sparkles', + }, delete: { label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE', action: 'delete', @@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { LOCALE_MENU_ITEMS.moveToDraft, ]), LOCALE_MENU_ITEMS.customizeContent, + LOCALE_MENU_ITEMS.selectPopularContent, ...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]), ]; } @@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { return [ LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.customizeContent, + LOCALE_MENU_ITEMS.selectPopularContent, LOCALE_MENU_ITEMS.delete, ]; } @@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { LOCALE_MENU_ITEMS.makeDefault, LOCALE_MENU_ITEMS.moveToDraft, LOCALE_MENU_ITEMS.customizeContent, + LOCALE_MENU_ITEMS.selectPopularContent, LOCALE_MENU_ITEMS.delete, ]; }; diff --git a/app/javascript/dashboard/helper/specs/portalHelper.spec.js b/app/javascript/dashboard/helper/specs/portalHelper.spec.js index e8d200518..1a6316520 100644 --- a/app/javascript/dashboard/helper/specs/portalHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/portalHelper.spec.js @@ -74,26 +74,34 @@ describe('PortalHelper', () => { }); describe('buildLocaleMenuItems', () => { - it('disables other actions but keeps customize enabled for the default locale', () => { + it('disables other actions but keeps content actions enabled for the default locale', () => { const items = buildLocaleMenuItems({ isDefault: true, isDraft: false }); - const customize = items.find(item => item.action === 'customize-content'); + const enabledActions = ['customize-content', 'select-popular-content']; - expect(customize).toBeTruthy(); - expect(customize.disabled).toBeFalsy(); + enabledActions.forEach(action => { + expect( + items.find(item => item.action === action)?.disabled + ).toBeFalsy(); + }); expect( items - .filter(item => item.action !== 'customize-content') + .filter(item => !enabledActions.includes(item.action)) .every(item => item.disabled) ).toBe(true); }); - it('returns publish, customize, and delete actions for draft locales', () => { + it('returns publish, customize, popular content, and delete actions for draft locales', () => { expect( buildLocaleMenuItems({ isDefault: false, isDraft: true, }).map(({ action }) => action) - ).toEqual(['publish-locale', 'customize-content', 'delete']); + ).toEqual([ + 'publish-locale', + 'customize-content', + 'select-popular-content', + 'delete', + ]); }); it('returns default, draft, customize, and delete actions for live locales', () => { @@ -106,6 +114,7 @@ describe('PortalHelper', () => { 'change-default', 'move-to-draft', 'customize-content', + 'select-popular-content', 'delete', ]); }); diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 53a745dc2..e69853f7a 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -720,9 +720,33 @@ "MOVE_TO_DRAFT": "Move to draft", "PUBLISH_LOCALE": "Publish locale", "CUSTOMIZE_CONTENT": "Localize content", + "SELECT_POPULAR_CONTENT": "Select recommended content", "DELETE": "Delete" } }, + "POPULAR_CONTENT_DIALOG": { + "TITLE": "Recommended content", + "DESCRIPTION": "Pick up to 3 categories and 6 articles to feature on this locale's help center home page. Drag them into the order you want visitors to see.", + "SEARCH": "Search...", + "EMPTY": "No matching results", + "ADD_ANOTHER": "Add another...", + "SLOTS_LEFT": "{count} slots left", + "OVERRIDING_DEFAULTS": "Overriding defaults for this locale", + "CONFIRM": "Save recommendations", + "CATEGORIES": { + "LABEL": "Recommended categories", + "ARTICLES_COUNT": "No articles | {count} article | {count} articles" + }, + "ARTICLES": { + "LABEL": "Recommended articles", + "IN_CATEGORY": "in {category}", + "UNCATEGORIZED": "Uncategorized" + }, + "API": { + "SUCCESS_MESSAGE": "Recommended content updated successfully", + "ERROR_MESSAGE": "Unable to update recommended content. Try again." + } + }, "CONTENT_DIALOG": { "TITLE": "Localize content", "DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.", diff --git a/app/models/concerns/portal_config_schema.rb b/app/models/concerns/portal_config_schema.rb index de338b830..7e506a076 100644 --- a/app/models/concerns/portal_config_schema.rb +++ b/app/models/concerns/portal_config_schema.rb @@ -14,6 +14,18 @@ module PortalConfigSchema 'additionalProperties' => false }.freeze + # Per-locale recommended content for the portal home page: an ordered list of + # `category_ids` (the hero's "Recommended topics" pills) and `article_ids` (the + # "Recommended" articles section). When empty, the portal uses its defaults. + POPULAR_CONTENT_SCHEMA = { + 'type' => 'object', + 'properties' => { + 'category_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } }, + 'article_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } } + }, + 'additionalProperties' => false + }.freeze + CONFIG_PARAMS_SCHEMA = { 'type' => 'object', 'properties' => { @@ -27,6 +39,10 @@ module PortalConfigSchema 'locale_translations' => { 'type' => %w[object null], 'additionalProperties' => LOCALE_TRANSLATION_SCHEMA + }, + 'popular_content' => { + 'type' => %w[object null], + 'additionalProperties' => POPULAR_CONTENT_SCHEMA } }, 'required' => [], diff --git a/app/models/portal.rb b/app/models/portal.rb index 9d2da6965..b1f387b49 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -53,7 +53,12 @@ class Portal < ApplicationRecord scope :active, -> { where(archived: false) } # TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config - CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze + CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations + popular_content].freeze + + # Max number of recommended categories/articles shown per locale. + POPULAR_CATEGORY_LIMIT = 3 + POPULAR_ARTICLE_LIMIT = 6 def file_base_data { @@ -115,6 +120,14 @@ class Portal < ApplicationRecord config_value('layout').presence || 'classic' end + def popular_category_ids(locale = default_locale) + Array(config.dig('popular_content', locale.to_s, 'category_ids')).first(POPULAR_CATEGORY_LIMIT) + end + + def popular_article_ids(locale = default_locale) + Array(config.dig('popular_content', locale.to_s, 'article_ids')).first(POPULAR_ARTICLE_LIMIT) + end + def social_profiles config_value('social_profiles') || {} end diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder index 93626ee36..2e4e78f1c 100644 --- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder +++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder @@ -19,6 +19,7 @@ json.config do json.layout portal.layout json.social_profiles portal.social_profiles json.locale_translations portal.config['locale_translations'] || {} + json.popular_content portal.config['popular_content'] || {} end if portal.channel_web_widget diff --git a/app/views/layouts/_portal_scripts.html.erb b/app/views/layouts/_portal_scripts.html.erb index b1479cace..2df4a02fd 100644 --- a/app/views/layouts/_portal_scripts.html.erb +++ b/app/views/layouts/_portal_scripts.html.erb @@ -67,6 +67,11 @@ html.light { #category-block:hover #category-name { color: var(--dynamic-hover-color); } +/* Recommended topic pills in the classic hero */ +.recommended-pill:hover { + border-color: var(--dynamic-hover-color); + color: var(--dynamic-hover-color); +} + + diff --git a/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue new file mode 100644 index 000000000..6eaa81b7b --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue new file mode 100644 index 000000000..b0da9c41e --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue @@ -0,0 +1,56 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue new file mode 100644 index 000000000..f57d154eb --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue new file mode 100644 index 000000000..4d01b0500 --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue @@ -0,0 +1,250 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/constants.js b/app/javascript/dashboard/components-next/Calls/constants.js new file mode 100644 index 000000000..f7399bfef --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/constants.js @@ -0,0 +1,51 @@ +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, +} from 'dashboard/components-next/message/constants'; + +export const CALL_KIND = { + ONGOING: 'ongoing', + INCOMING: 'incoming', + OUTGOING: 'outgoing', + MISSED: 'missed', + NO_REPLY: 'no_reply', + FAILED: 'failed', +}; + +// The API returns display values: status (ringing/in-progress/completed/ +// no-answer/failed) and direction (inbound/outbound). The list UI presents +// them as a single "kind" per row. +export const getCallKind = call => { + if ( + [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes( + call.status + ) + ) { + return CALL_KIND.ONGOING; + } + if ( + [VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status) + ) { + return CALL_KIND.FAILED; + } + const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND; + if (call.status === VOICE_CALL_STATUS.NO_ANSWER) { + return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY; + } + return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING; +}; + +// Filter chips map to the status/direction params supported by CallFinder. +export const CALL_ACTIVITY_PARAMS = { + missed: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.INBOUND, + }, + no_reply: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.OUTBOUND, + }, + incoming: { direction: VOICE_CALL_DIRECTION.INBOUND }, + outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND }, + in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS }, +}; diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue new file mode 100644 index 000000000..c716ffbfb --- /dev/null +++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 294e0dd5d..909a44c69 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -2,6 +2,7 @@ import { h, ref, computed, onMounted, watch } from 'vue'; import { provideSidebarContext, useSidebarResize } from './provider'; import { useAccount } from 'dashboard/composables/useAccount'; +import { useConfig } from 'dashboard/composables/useConfig'; import { useKbd } from 'dashboard/composables/utils/useKbd'; import { useMapGetter } from 'dashboard/composables/store'; import { useStore } from 'vuex'; @@ -43,7 +44,14 @@ const emit = defineEmits([ ]); const { accountScopedRoute, isOnChatwootCloud } = useAccount(); +const { isEnterprise } = useConfig(); const store = useStore(); + +// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry +// on community so it doesn't lead to a dashboard/CTA the backend can't serve. +const isCallsAvailable = computed( + () => isOnChatwootCloud.value || isEnterprise +); const searchShortcut = useKbd([`$mod`, 'k']); const { t } = useI18n(); @@ -563,6 +571,17 @@ const menuItems = computed(() => { }, ], }, + ...(isCallsAvailable.value + ? [ + { + name: 'Calls', + label: t('SIDEBAR.CALLS'), + icon: 'i-lucide-phone', + to: accountScopedRoute('calls_dashboard_index'), + activeOn: ['calls_dashboard_index'], + }, + ] + : []), { name: 'Contacts', label: t('SIDEBAR.CONTACTS'), diff --git a/app/javascript/dashboard/i18n/locale/en/calls.json b/app/javascript/dashboard/i18n/locale/en/calls.json new file mode 100644 index 000000000..0ca5441f0 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/calls.json @@ -0,0 +1,45 @@ +{ + "CALLS_PAGE": { + "HEADER": "Calls", + "ALL_CALLS": "All Calls", + "ALL_CALLS_COUNT": "All Calls ({count})", + "EMPTY_STATE": "No calls found", + "SETUP": { + "TITLE": "Make and receive calls in one place", + "SUBTITLE": "Set up a voice channel to start handling calls with your team. Every call, along with its recording, will appear here.", + "ACTION": "Set up voice channel" + }, + "FILTERS": { + "MISSED": "Missed", + "NO_REPLY": "No reply", + "OTHER_ACTIVITY": "Other activity", + "INCOMING": "Incoming", + "OUTGOING": "Outgoing", + "IN_PROGRESS": "In progress", + "ASSIGNEE": "Assignee", + "ALL_ASSIGNEES": "All assignees", + "MORE_FILTERS": "More filters", + "INBOX": "Inbox", + "ALL_INBOXES": "All inboxes" + }, + "STATUS": { + "ONGOING": "Ongoing", + "INCOMING": "Incoming", + "OUTGOING": "Outgoing", + "MISSED": "Missed", + "NO_REPLY": "No reply", + "FAILED": "Failed" + }, + "ROW": { + "PICKED_BY": "Picked by", + "DIALED_BY": "Dialed by", + "ANSWERED": "Answered", + "RINGING": "Ringing", + "IN_PROGRESS": "In progress", + "NO_AGENT": "No agent answered this call", + "NO_CONTACT_ANSWER": "Contact did not answer", + "FAILED": "This call could not be connected", + "YESTERDAY": "Yesterday" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 12db16ba7..990ab9835 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json'; import auditLogs from './auditLogs.json'; import automation from './automation.json'; import bulkActions from './bulkActions.json'; +import calls from './calls.json'; import campaign from './campaign.json'; import cannedMgmt from './cannedMgmt.json'; import chatlist from './chatlist.json'; @@ -51,6 +52,7 @@ export default { ...auditLogs, ...automation, ...bulkActions, + ...calls, ...campaign, ...cannedMgmt, ...chatlist, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index ceb0438b1..c013a177f 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -324,6 +324,7 @@ "COMPANIES": "Companies", "ALL_COMPANIES": "All Companies", "CAPTAIN": "Captain", + "CALLS": "Calls", "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_OVERVIEW": "Overview", "CAPTAIN_DOCUMENTS": "Documents", diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue new file mode 100644 index 000000000..80f8e08c5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue @@ -0,0 +1,168 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/calls/routes.js b/app/javascript/dashboard/routes/dashboard/calls/routes.js new file mode 100644 index 000000000..fa952a749 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/routes.js @@ -0,0 +1,22 @@ +import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; +import { + CONVERSATION_PERMISSIONS, + ROLES, +} from 'dashboard/constants/permissions'; +import { frontendURL } from '../../../helper/URLHelper'; +import CallsIndex from './pages/CallsIndex.vue'; + +export const routes = [ + { + path: frontendURL('accounts/:accountId/calls'), + name: 'calls_dashboard_index', + component: CallsIndex, + meta: { + permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, +]; diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 04d11c621..4611aad38 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -1,6 +1,7 @@ import settings from './settings/settings.routes'; import conversation from './conversation/conversation.routes'; import { routes as searchRoutes } from '../../modules/search/search.routes'; +import { routes as callRoutes } from './calls/routes'; import { routes as contactRoutes } from './contacts/routes'; import { routes as companyRoutes } from './companies/routes'; import { routes as notificationRoutes } from './notifications/routes'; @@ -25,6 +26,7 @@ export default { ...inboxRoutes, ...conversation.routes, ...settings.routes, + ...callRoutes, ...contactRoutes, ...companyRoutes, ...searchRoutes, diff --git a/app/javascript/dashboard/stores/callHistory.js b/app/javascript/dashboard/stores/callHistory.js new file mode 100644 index 000000000..c0a2c0fa9 --- /dev/null +++ b/app/javascript/dashboard/stores/callHistory.js @@ -0,0 +1,40 @@ +import camelcaseKeys from 'camelcase-keys'; +import CallsAPI from 'dashboard/api/calls'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import { defineStore } from 'pinia'; + +export const useCallHistoryStore = defineStore('callHistory', { + state: () => ({ + records: [], + meta: { count: 0, currentPage: 1, totalPages: 0 }, + uiFlags: { isFetching: false }, + fetchRequestToken: 0, + }), + + actions: { + async fetchCalls(params = {}) { + this.uiFlags.isFetching = true; + this.fetchRequestToken += 1; + const requestToken = this.fetchRequestToken; + try { + const { data } = await CallsAPI.get(params); + // A newer fetch (filter/page change) superseded this one; drop the result. + if (this.fetchRequestToken !== requestToken) return this.records; + this.records = camelcaseKeys(data.payload, { deep: true }); + this.meta = camelcaseKeys(data.meta); + return this.records; + } catch (error) { + // Don't surface errors from a fetch that a newer request already replaced. + if (this.fetchRequestToken !== requestToken) return this.records; + // Drop the previous results so stale rows aren't shown under the new view. + this.records = []; + this.meta = { count: 0, currentPage: 1, totalPages: 0 }; + return throwErrorMessage(error); + } finally { + if (this.fetchRequestToken === requestToken) { + this.uiFlags.isFetching = false; + } + } + }, + }, +}); diff --git a/app/javascript/dashboard/stores/specs/callHistory.spec.js b/app/javascript/dashboard/stores/specs/callHistory.spec.js new file mode 100644 index 000000000..834ae8ab5 --- /dev/null +++ b/app/javascript/dashboard/stores/specs/callHistory.spec.js @@ -0,0 +1,117 @@ +import { setActivePinia, createPinia } from 'pinia'; +import CallsAPI from 'dashboard/api/calls'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import { useCallHistoryStore } from '../callHistory'; + +vi.mock('dashboard/api/calls', () => ({ + default: { + get: vi.fn(), + }, +})); + +vi.mock('dashboard/store/utils/api', () => ({ + throwErrorMessage: vi.fn(error => error), +})); + +const createDeferred = () => { + let resolve; + const promise = new Promise(res => { + resolve = res; + }); + + return { promise, resolve }; +}; + +const buildResponse = (payload, meta) => ({ data: { payload, meta } }); + +describe('callHistory store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('fetches calls and stores camelized records and meta', async () => { + CallsAPI.get.mockResolvedValue( + buildResponse( + [{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }], + { count: 44, current_page: 1, total_pages: 2 } + ) + ); + const store = useCallHistoryStore(); + + await store.fetchCalls({ page: 1, status: 'no-answer' }); + + expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' }); + expect(store.records).toEqual([ + { id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } }, + ]); + expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 }); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('drops a superseded response that resolves after the latest one', async () => { + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + CallsAPI.get + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + const store = useCallHistoryStore(); + + const staleFetch = store.fetchCalls({ page: 1 }); + const currentFetch = store.fetchCalls({ page: 2 }); + + secondRequest.resolve( + buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 }) + ); + await currentFetch; + + firstRequest.resolve( + buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 }) + ); + await staleFetch; + + expect(store.records).toEqual([{ id: 2 }]); + expect(store.meta.count).toBe(1); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('keeps fetching state when a superseded response resolves first', async () => { + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + CallsAPI.get + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + const store = useCallHistoryStore(); + + const staleFetch = store.fetchCalls({ page: 1 }); + const currentFetch = store.fetchCalls({ page: 2 }); + + firstRequest.resolve( + buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 }) + ); + await staleFetch; + + expect(store.records).toEqual([]); + expect(store.uiFlags.isFetching).toBe(true); + + secondRequest.resolve( + buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 }) + ); + await currentFetch; + + expect(store.records).toEqual([{ id: 2 }]); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('surfaces the error and resets fetching state on failure', async () => { + const error = new Error('Request failed'); + CallsAPI.get.mockRejectedValue(error); + const store = useCallHistoryStore(); + + await store.fetchCalls(); + + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(store.records).toEqual([]); + expect(store.uiFlags.isFetching).toBe(false); + }); +}); diff --git a/app/javascript/dashboard/stores/companies.spec.js b/app/javascript/dashboard/stores/specs/companies.spec.js similarity index 99% rename from app/javascript/dashboard/stores/companies.spec.js rename to app/javascript/dashboard/stores/specs/companies.spec.js index 1c44d4292..98d9fd9b6 100644 --- a/app/javascript/dashboard/stores/companies.spec.js +++ b/app/javascript/dashboard/stores/specs/companies.spec.js @@ -1,6 +1,6 @@ import { setActivePinia, createPinia } from 'pinia'; import CompanyAPI from 'dashboard/api/companies'; -import { useCompaniesStore } from './companies'; +import { useCompaniesStore } from '../companies'; vi.mock('dashboard/api/companies', () => ({ default: { diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js index d12a42bc8..8a4b50b17 100644 --- a/app/javascript/shared/helpers/specs/timeHelper.spec.js +++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js @@ -1,11 +1,12 @@ import { - messageStamp, - messageTimestamp, - dynamicTime, dateFormat, - shortTimestamp, + dynamicTime, getDayDifferenceFromNow, hasOneDayPassed, + messageStamp, + messageTimestamp, + relativeDayTimestamp, + shortTimestamp, } from 'shared/helpers/timeHelper'; beforeEach(() => { @@ -37,6 +38,33 @@ describe('#messageTimestamp', () => { }); }); +describe('#relativeDayTimestamp', () => { + // System time is mocked to May 5, 2023 00:00 UTC. + const toUnix = date => Math.floor(date / 1000); + + it('returns the time for timestamps from today', () => { + const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0)); + expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM'); + }); + + it('returns the supplied label for timestamps from yesterday', () => { + const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0)); + expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday'); + }); + + it('returns a day and month for older timestamps in the current year', () => { + const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0)); + expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual( + 'Feb 10' + ); + }); + + it('returns a full date for timestamps from a previous year', () => { + const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0)); + expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021'); + }); +}); + describe('#dynamicTime', () => { it('returns correct value', () => { Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf()); diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js index db5609d89..cde5ccf89 100644 --- a/app/javascript/shared/helpers/timeHelper.js +++ b/app/javascript/shared/helpers/timeHelper.js @@ -1,6 +1,9 @@ import { format, isSameYear, + isThisYear, + isToday, + isYesterday, fromUnixTime, formatDistanceToNow, differenceInDays, @@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => { return messageDate; }; +/** + * Formats a Unix timestamp relative to today: the time for today, a caller- + * supplied label for yesterday, and a date otherwise. The yesterday label is + * passed in so the caller keeps ownership of translation. + * @param {number} time - Unix timestamp. + * @param {string} yesterdayLabel - Localized label shown for yesterday. + * @returns {string} Formatted timestamp string. + */ +export const relativeDayTimestamp = (time, yesterdayLabel) => { + const date = fromUnixTime(time); + if (isToday(date)) return format(date, 'h:mm a'); + if (isYesterday(date)) return yesterdayLabel; + if (isThisYear(date)) return format(date, 'MMM d'); + return format(date, 'MMM d, yyyy'); +}; + /** * Converts a Unix timestamp to a relative time string (e.g., 3 hours ago). * @param {number} time - Unix timestamp. From 08f49f5896f8d2959c10c2434553705e5668570d Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:28:48 +0530 Subject: [PATCH 19/33] fix: prevent channel list crash on hard reload (#15074) --- .../routes/dashboard/settings/inbox/ChannelList.vue | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index de0c6059b..fd509d350 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -1,5 +1,5 @@