diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index e6270c807..86e21d9fd 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -24,9 +24,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def search render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return - contacts = resolved_contacts.where( - 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search - OR contacts.additional_attributes->>\'company_name\' ILIKE :search', + contacts = Current.account.contacts.where( + 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', search: "%#{params[:q].strip}%" ) @contacts = fetch_contacts(contacts) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 7467c2fa8..e9d2f6d08 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -1271,7 +1271,7 @@ export default { key="copilot-bottom-panel" :is-generating-content="copilot.isButtonDisabled.value" @submit="onSubmitCopilotReply" - @cancel="copilot.toggleEditor" + @cancel="copilot.reset" /> { return actual; }); vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({ - OPEN_AI_EVENTS: { - TEST_EVENT: 'open_ai_test_event', + CAPTAIN_EVENTS: { + TEST_EVENT: 'captain_test_event', }, })); @@ -65,17 +64,6 @@ describe('useCaptain', () => { expect(draftMessage.value).toBe('Draft message'); }); - it('records analytics correctly', async () => { - const { recordAnalytics } = useCaptain(); - - await recordAnalytics('TEST_EVENT', { data: 'test' }); - - expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', { - type: 'TEST_EVENT', - data: 'test', - }); - }); - it('rewrites content', async () => { TasksAPI.rewrite.mockResolvedValue({ data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } }, diff --git a/app/javascript/dashboard/composables/useCaptain.js b/app/javascript/dashboard/composables/useCaptain.js index 507f35864..f7c72e04f 100644 --- a/app/javascript/dashboard/composables/useCaptain.js +++ b/app/javascript/dashboard/composables/useCaptain.js @@ -7,10 +7,9 @@ import { import { useAccount } from 'dashboard/composables/useAccount'; import { useConfig } from 'dashboard/composables/useConfig'; import { useCamelCase } from 'dashboard/composables/useTransformKeys'; -import { useAlert, useTrack } from 'dashboard/composables'; +import { useAlert } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; -import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import TasksAPI from 'dashboard/api/captain/tasks'; export function useCaptain() { @@ -79,23 +78,6 @@ export function useCaptain() { useAlert(errorMessage); }; - // === Analytics === - /** - * Records analytics for AI-related events. - * @param {string} type - The type of event. - * @param {Object} payload - Additional data for the event. - * @returns {Promise} - */ - const recordAnalytics = async (type, payload) => { - const event = OPEN_AI_EVENTS[type.toUpperCase()]; - if (event) { - useTrack(event, { - type, - ...payload, - }); - } - }; - // === Task Methods === /** * Rewrites content with a specific operation. @@ -234,8 +216,5 @@ export function useCaptain() { getReplySuggestion, followUp, processEvent, - - // Analytics - recordAnalytics, }; } diff --git a/app/javascript/dashboard/composables/useCopilotReply.js b/app/javascript/dashboard/composables/useCopilotReply.js index 83b541700..492bcb43e 100644 --- a/app/javascript/dashboard/composables/useCopilotReply.js +++ b/app/javascript/dashboard/composables/useCopilotReply.js @@ -1,6 +1,56 @@ import { ref, computed } from 'vue'; import { useCaptain } from 'dashboard/composables/useCaptain'; import { useUISettings } from 'dashboard/composables/useUISettings'; +import { useTrack } from 'dashboard/composables'; +import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; + +// Actions that map to REWRITE events (with operation attribute) +const REWRITE_ACTIONS = [ + 'improve', + 'fix_spelling_grammar', + 'casual', + 'professional', + 'expand', + 'shorten', + 'rephrase', + 'make_friendly', + 'make_formal', + 'simplify', +]; + +/** + * Gets the event key suffix based on action type. + * @param {string} action - The action type + * @returns {string} The event key prefix (REWRITE, SUMMARIZE, or REPLY_SUGGESTION) + */ +function getEventPrefix(action) { + if (action === 'summarize') return 'SUMMARIZE'; + if (action === 'reply_suggestion') return 'REPLY_SUGGESTION'; + return 'REWRITE'; +} + +/** + * Builds the analytics payload based on action type. + * @param {string} action - The action type + * @param {number} conversationId - The conversation ID + * @param {number} [followUpCount] - Optional follow-up count + * @returns {Object} The payload object + */ +function buildPayload(action, conversationId, followUpCount = undefined) { + const payload = { conversationId }; + + // Add operation for rewrite actions + if (REWRITE_ACTIONS.includes(action)) { + payload.operation = action; + } + + // Add followUpCount if provided + if (followUpCount !== undefined) { + payload.followUpCount = followUpCount; + } + + return payload; +} /** * Composable for managing Copilot reply generation state and actions. @@ -9,7 +59,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings'; * @returns {Object} Copilot reply state and methods */ export function useCopilotReply() { - const { processEvent, followUp } = useCaptain(); + const { processEvent, followUp, currentChat } = useCaptain(); const { updateUISettings } = useUISettings(); const showEditor = ref(false); @@ -19,6 +69,13 @@ export function useCopilotReply() { const followUpContext = ref(null); const abortController = ref(null); + // Tracking state + const currentAction = ref(null); + const followUpCount = ref(0); + const trackedConversationId = ref(null); + + const conversationId = computed(() => currentChat.value?.id); + const isActive = computed(() => showEditor.value || isGenerating.value); const isButtonDisabled = computed( () => isGenerating.value || !isContentReady.value @@ -29,8 +86,22 @@ export function useCopilotReply() { /** * Resets all copilot editor state and cancels any ongoing generation. + * @param {boolean} [trackDismiss=true] - Whether to track dismiss event */ - function reset() { + function reset(trackDismiss = true) { + // Track dismiss event if there was content and we're not accepting + if (trackDismiss && generatedContent.value && currentAction.value) { + const eventKey = `${getEventPrefix(currentAction.value)}_DISMISSED`; + useTrack( + CAPTAIN_EVENTS[eventKey], + buildPayload( + currentAction.value, + trackedConversationId.value, + followUpCount.value + ) + ); + } + if (abortController.value) { abortController.value.abort(); abortController.value = null; @@ -40,6 +111,9 @@ export function useCopilotReply() { isContentReady.value = false; generatedContent.value = ''; followUpContext.value = null; + currentAction.value = null; + followUpCount.value = 0; + trackedConversationId.value = null; } /** @@ -70,11 +144,14 @@ export function useCopilotReply() { return; } - // Reset and start new generation - reset(); + // Reset without tracking dismiss (starting new action) + reset(false); abortController.value = new AbortController(); isGenerating.value = true; isContentReady.value = false; + currentAction.value = action; + followUpCount.value = 0; + trackedConversationId.value = conversationId.value; try { const { message: content, followUpContext: newContext } = @@ -85,7 +162,15 @@ export function useCopilotReply() { if (!abortController.value?.signal.aborted) { generatedContent.value = content; followUpContext.value = newContext; - if (content) showEditor.value = true; + if (content) { + showEditor.value = true; + // Track "Used" event on successful generation + const eventKey = `${getEventPrefix(action)}_USED`; + useTrack( + CAPTAIN_EVENTS[eventKey], + buildPayload(action, trackedConversationId.value) + ); + } isGenerating.value = false; } } catch { @@ -106,6 +191,12 @@ export function useCopilotReply() { isGenerating.value = true; isContentReady.value = false; + // Track follow-up sent event + useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, { + conversationId: trackedConversationId.value, + }); + followUpCount.value += 1; + try { const { message: content, followUpContext: updatedContext } = await followUp({ @@ -137,7 +228,28 @@ export function useCopilotReply() { */ function accept() { const content = generatedContent.value; + + // Track "Applied" event + if (currentAction.value) { + const eventKey = `${getEventPrefix(currentAction.value)}_APPLIED`; + useTrack( + CAPTAIN_EVENTS[eventKey], + buildPayload( + currentAction.value, + trackedConversationId.value, + followUpCount.value + ) + ); + } + + // Reset state without tracking dismiss showEditor.value = false; + generatedContent.value = ''; + followUpContext.value = null; + currentAction.value = null; + followUpCount.value = 0; + trackedConversationId.value = null; + return content; } diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 8e28473d9..0b6e85d77 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -84,22 +84,28 @@ export const PORTALS_EVENTS = Object.freeze({ PREVIEW_ARTICLE: 'Previewed article', }); -export const OPEN_AI_EVENTS = Object.freeze({ - SUMMARIZE: 'OpenAI: Used summarize', - REPLY_SUGGESTION: 'OpenAI: Used reply suggestion', - REPHRASE: 'OpenAI: Used rephrase', - IMPROVE: 'OpenAI: Used improve', - FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar', - SHORTEN: 'OpenAI: Used shorten', - EXPAND: 'OpenAI: Used expand', - MAKE_FRIENDLY: 'OpenAI: Used make friendly', - MAKE_FORMAL: 'OpenAI: Used make formal', - SIMPLIFY: 'OpenAI: Used simplify', - APPLY_LABEL_SUGGESTION: 'OpenAI: Apply label from suggestion', - DISMISS_LABEL_SUGGESTION: 'OpenAI: Dismiss label suggestions', - ADDED_AI_INTEGRATION_VIA_CTA_BUTTON: - 'OpenAI: Added AI integration via CTA button', - DISMISS_AI_SUGGESTION: 'OpenAI: Dismiss AI suggestions', +export const CAPTAIN_EVENTS = Object.freeze({ + // Rewrite events (with operation attribute in payload) + REWRITE_USED: 'Captain: Rewrite used', + REWRITE_APPLIED: 'Captain: Rewrite applied', + REWRITE_DISMISSED: 'Captain: Rewrite dismissed', + + // Summarize events + SUMMARIZE_USED: 'Captain: Summarize used', + SUMMARIZE_APPLIED: 'Captain: Summarize applied', + SUMMARIZE_DISMISSED: 'Captain: Summarize dismissed', + + // Reply suggestion events + REPLY_SUGGESTION_USED: 'Captain: Reply suggestion used', + REPLY_SUGGESTION_APPLIED: 'Captain: Reply suggestion applied', + REPLY_SUGGESTION_DISMISSED: 'Captain: Reply suggestion dismissed', + + // Follow-up events + FOLLOW_UP_SENT: 'Captain: Follow-up sent', + + // Label suggestions + LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied', + LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed', }); export const COPILOT_EVENTS = Object.freeze({ diff --git a/app/presenters/messages/search_data_presenter.rb b/app/presenters/messages/search_data_presenter.rb index 7d0638add..7a6260686 100644 --- a/app/presenters/messages/search_data_presenter.rb +++ b/app/presenters/messages/search_data_presenter.rb @@ -51,7 +51,6 @@ class Messages::SearchDataPresenter < SimpleDelegator def additional_attributes_data { - campaign_id: additional_attributes&.dig('campaign_id'), automation_rule_id: content_attributes&.dig('automation_rule_id') } end diff --git a/app/services/messages/markdown_renderers/whats_app_renderer.rb b/app/services/messages/markdown_renderers/whats_app_renderer.rb index 8f98218cf..3b4517b4b 100644 --- a/app/services/messages/markdown_renderers/whats_app_renderer.rb +++ b/app/services/messages/markdown_renderers/whats_app_renderer.rb @@ -1,4 +1,9 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer + def initialize + super + @list_item_number = 0 + end + def strong(_node) out('*', :children, '*') end @@ -15,13 +20,20 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer out(node.url) end - def list(_node) + def list(node) + @list_type = node.list_type + @list_item_number = @list_type == :ordered_list ? node.list_start : 0 out(:children) cr end def list_item(_node) - out('- ', :children) + if @list_type == :ordered_list + out("#{@list_item_number}. ", :children) + @list_item_number += 1 + else + out('- ', :children) + end cr end diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 5beae6850..8bdc86bb4 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -357,18 +357,6 @@ RSpec.describe 'Contacts API', type: :request do expect(response.body).not_to include(contact1.email) end - it 'searches contacts using company name' do - contact2.update(additional_attributes: { company_name: 'acme.inc' }) - get "/api/v1/accounts/#{account.id}/contacts/search", - params: { q: 'acme.inc' }, - headers: admin.create_new_auth_token, - as: :json - - expect(response).to have_http_status(:success) - expect(response.body).to include(contact2.email) - expect(response.body).not_to include(contact1.email) - end - it 'matches the resolved contact respecting the identifier character casing' do contact_normal = create(:contact, name: 'testcontact', account: account, identifier: 'testidentifer') contact_special = create(:contact, name: 'testcontact', account: account, identifier: 'TestIdentifier') diff --git a/spec/presenters/messages/search_data_presenter_spec.rb b/spec/presenters/messages/search_data_presenter_spec.rb index 0bbe1a812..7b3d4acb9 100644 --- a/spec/presenters/messages/search_data_presenter_spec.rb +++ b/spec/presenters/messages/search_data_presenter_spec.rb @@ -61,15 +61,10 @@ RSpec.describe Messages::SearchDataPresenter do context 'with campaign and automation data' do before do message.update( - additional_attributes: { 'campaign_id' => '123' }, content_attributes: { 'automation_rule_id' => '456' } ) end - it 'includes campaign_id' do - expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123') - end - it 'includes automation_rule_id' do expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456') end diff --git a/spec/services/messages/markdown_renderer_service_spec.rb b/spec/services/messages/markdown_renderer_service_spec.rb index e43b0d6d0..8bad763b4 100644 --- a/spec/services/messages/markdown_renderer_service_spec.rb +++ b/spec/services/messages/markdown_renderer_service_spec.rb @@ -53,12 +53,28 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do expect(result.strip).to eq('*bold _italic_*') end - it 'converts bullet lists' do - content = "- item 1\n- item 2" + it 'preserves unordered list with dash markers' do + content = "- item 1\n- item 2\n- item 3" result = described_class.new(content, channel_type).render - expect(result.strip).to include('- item 1') - expect(result.strip).to include('- item 2') - expect(result).to include("- item 1\n- item 2") + expect(result).to include('- item 1') + expect(result).to include('- item 2') + expect(result).to include('- item 3') + end + + it 'converts asterisk unordered lists to dash markers' do + content = "* item 1\n* item 2\n* item 3" + result = described_class.new(content, channel_type).render + expect(result).to include('- item 1') + expect(result).to include('- item 2') + expect(result).to include('- item 3') + end + + it 'preserves ordered list markers with numbering' do + content = "1. first step\n2. second step\n3. third step" + result = described_class.new(content, channel_type).render + expect(result).to include('1. first step') + expect(result).to include('2. second step') + expect(result).to include('3. third step') end it 'preserves newlines in plain text without list markers' do @@ -432,6 +448,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do expect(result).to include("Line 1\nLine 2\nLine 3") end + it 'preserves ordered list markers with numbering in Twilio WhatsApp' do + content = "1. first step\n2. second step\n3. third step" + channel = instance_double(Channel::TwilioSms, whatsapp?: true) + result = described_class.new(content, channel_type, channel).render + expect(result).to include('1. first step') + expect(result).to include('2. second step') + expect(result).to include('3. third step') + end + + it 'preserves unordered list markers in Twilio WhatsApp' do + content = "- item 1\n- item 2\n- item 3" + channel = instance_double(Channel::TwilioSms, whatsapp?: true) + result = described_class.new(content, channel_type, channel).render + expect(result).to include('- item 1') + expect(result).to include('- item 2') + expect(result).to include('- item 3') + end + it 'backwards compatible when channel is not provided' do content = '**bold** _italic_' result = described_class.new(content, channel_type).render @@ -483,12 +517,12 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do context 'when testing all formatting types' do let(:channel_type) { 'Channel::Whatsapp' } - it 'handles ordered lists' do + it 'handles ordered lists with proper numbering' do content = "1. first\n2. second\n3. third" result = described_class.new(content, channel_type).render - expect(result).to include('first') - expect(result).to include('second') - expect(result).to include('third') + expect(result).to include('1. first') + expect(result).to include('2. second') + expect(result).to include('3. third') end end