diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 18538e842..af96441f8 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? # @portal.custom_domain = parsed_custom_domain process_attached_logo if params[:blob_id].present? - rescue StandardError => e - Rails.logger.error e - render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render_record_invalid(e) end end diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index e7a1f3fa6..3e7d876c3 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,8 +1,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController before_action :validate_feature_enabled! + before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } # POST /api/v1/accounts/:account_id/whatsapp/authorization - # Handles the embedded signup callback data from the Facebook SDK + # Handles both initial authorization and reauthorization + # If inbox_id is present in params, it performs reauthorization def create validate_embedded_signup_params! channel = process_embedded_signup @@ -16,21 +18,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, - code: params[:code], - business_id: params[:business_id], - waba_id: params[:waba_id], - phone_number_id: params[:phone_number_id] + params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys, + inbox_id: params[:inbox_id] ) service.perform end - def render_success_response(inbox) + def fetch_and_validate_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + validate_reauthorization_required + end + + def validate_reauthorization_required + return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? + render json: { + success: false, + message: I18n.t('inbox.reauthorization.not_required') + }, status: :unprocessable_entity + end + + def can_upgrade_to_embedded_signup? + channel = @inbox.channel + return false unless channel.provider == 'whatsapp_cloud' + + true + end + + def render_success_response(inbox) + response = { success: true, id: inbox.id, name: inbox.name, channel_type: 'whatsapp' } + response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present? + render json: response end def render_error_response(error) diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index 455828228..d607ba151 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController :NumMedia, :Latitude, :Longitude, - :MessageType + :MessageType, + :ProfileName ) end end diff --git a/app/finders/notification_finder.rb b/app/finders/notification_finder.rb index ccfe470a0..e1958827a 100644 --- a/app/finders/notification_finder.rb +++ b/app/finders/notification_finder.rb @@ -15,7 +15,13 @@ class NotificationFinder end def unread_count - @notifications.where(read_at: nil).count + if type_included?('read') + # If we're including read notifications, filter to unread + @notifications.where(read_at: nil).count + else + # Already filtered to unread notifications, just count + @notifications.count + end end def count @@ -27,7 +33,7 @@ class NotificationFinder def set_up find_all_notifications filter_snoozed_notifications - fitler_read_notifications + filter_read_notifications end def find_all_notifications @@ -38,7 +44,7 @@ class NotificationFinder @notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed') end - def fitler_read_notifications + def filter_read_notifications @notifications = @notifications.where(read_at: nil) unless type_included?('read') end diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js index e1003b123..8f51f4878 100644 --- a/app/javascript/dashboard/api/channel/whatsappChannel.js +++ b/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -9,6 +9,13 @@ class WhatsappChannel extends ApiClient { createEmbeddedSignup(params) { return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params); } + + reauthorizeWhatsApp({ inboxId, ...params }) { + return axios.post(`${this.baseUrl()}/whatsapp/authorization`, { + ...params, + inbox_id: inboxId, + }); + } } export default new WhatsappChannel(); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0932a79c7..0e893b767 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id); :src="thumbnail" :size="48" :status="availabilityStatus" + hide-offline-status rounded-full />
+ {{ + $t( + 'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION' + ) + }} +
+<%= @installation_configs[key]&.dig('description') %> diff --git a/config/agents/tools.yml b/config/agents/tools.yml index b994e93d3..c2faf75e7 100644 --- a/config/agents/tools.yml +++ b/config/agents/tools.yml @@ -24,3 +24,13 @@ title: 'Add Label to Conversation' description: 'Add a label to a conversation' icon: 'tag' + +- id: faq_lookup + title: 'FAQ Lookup' + description: 'Search FAQ responses using semantic similarity' + icon: 'search' + +- id: handoff + title: 'Handoff to Human' + description: 'Hand off the conversation to a human agent' + icon: 'user-switch' diff --git a/config/installation_config.yml b/config/installation_config.yml index 53251fc3c..01799e830 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -10,7 +10,8 @@ # locked: if you don't specify locked attribute in yaml, the default value will be true, # which means the particular config will be locked and won't be available in `super_admin/installation_configs` # premium: These values get overwritten unless the user is on a premium plan -# type: The type of the config. Default is text, boolean is also supported +# type: The type of the config. Default is text, select and boolean are also supported +# options: For select types, its required to have options for the select in the following pattern: "option_value":"Human readable option" # ------- Branding Related Config ------- # - name: INSTALLATION_NAME diff --git a/config/locales/en.yml b/config/locales/en.yml index 9faa0cd9d..1d8347679 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -31,6 +31,11 @@ en: hello: 'Hello world' + inbox: + reauthorization: + success: 'Channel reauthorized successfully' + not_required: 'Reauthorization is not required for this inbox' + invalid_channel: 'Invalid channel type for reauthorization' messages: reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions. reset_password_failure: Uh ho! We could not find any user with the specified email. @@ -67,6 +72,13 @@ en: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' + whatsapp: + token_exchange_failed: 'Failed to exchange code for access token. Please try again.' + invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.' + phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.' + reauthorization: + generic: 'Failed to reauthorize WhatsApp. Please try again.' + not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.' inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. diff --git a/db/migrate/20250805160307_add_notifications_performance_index.rb b/db/migrate/20250805160307_add_notifications_performance_index.rb new file mode 100644 index 000000000..6ab96a238 --- /dev/null +++ b/db/migrate/20250805160307_add_notifications_performance_index.rb @@ -0,0 +1,11 @@ +class AddNotificationsPerformanceIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + # Add composite index to optimize notification count queries + # This covers the common query pattern: WHERE user_id = ? AND account_id = ? AND snoozed_until IS NULL AND read_at IS NULL + add_index :notifications, [:user_id, :account_id, :snoozed_until, :read_at], + name: 'idx_notifications_performance', + algorithm: :concurrently + end +end diff --git a/db/migrate/20250806140000_create_assignment_policies.rb b/db/migrate/20250806140000_create_assignment_policies.rb new file mode 100644 index 000000000..c02e3d6de --- /dev/null +++ b/db/migrate/20250806140000_create_assignment_policies.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateAssignmentPolicies < ActiveRecord::Migration[7.1] + def change + create_table :assignment_policies do |t| + t.references :account, null: false, index: true + t.string :name, null: false, limit: 255 + t.text :description + t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced + t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting + t.integer :fair_distribution_limit, null: false, default: 100 + t.integer :fair_distribution_window, null: false, default: 3600 # seconds + t.boolean :enabled, null: false, default: true + + t.timestamps + end + + add_index :assignment_policies, [:account_id, :name], unique: true + add_index :assignment_policies, :enabled + end +end diff --git a/db/migrate/20250806140001_create_inbox_assignment_policies.rb b/db/migrate/20250806140001_create_inbox_assignment_policies.rb new file mode 100644 index 000000000..e80c67679 --- /dev/null +++ b/db/migrate/20250806140001_create_inbox_assignment_policies.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1] + def change + create_table :inbox_assignment_policies do |t| + t.references :inbox, null: false, index: true + t.references :assignment_policy, null: false, index: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250806140002_create_agent_capacity_policies.rb b/db/migrate/20250806140002_create_agent_capacity_policies.rb new file mode 100644 index 000000000..4fdeabc92 --- /dev/null +++ b/db/migrate/20250806140002_create_agent_capacity_policies.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateAgentCapacityPolicies < ActiveRecord::Migration[7.1] + def change + create_table :agent_capacity_policies do |t| + t.references :account, null: false, index: true + t.string :name, null: false, limit: 255 + t.text :description + t.jsonb :exclusion_rules, default: {}, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20250806140003_create_inbox_capacity_limits.rb b/db/migrate/20250806140003_create_inbox_capacity_limits.rb new file mode 100644 index 000000000..c107ce182 --- /dev/null +++ b/db/migrate/20250806140003_create_inbox_capacity_limits.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateInboxCapacityLimits < ActiveRecord::Migration[7.1] + def change + create_table :inbox_capacity_limits do |t| + t.references :agent_capacity_policy, null: false, index: true + t.references :inbox, null: false, index: true + t.integer :conversation_limit, null: false + + t.timestamps + end + + add_index :inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true + end +end diff --git a/db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb b/db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb new file mode 100644 index 000000000..53bc8e8f9 --- /dev/null +++ b/db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1] + def change + add_reference :account_users, :agent_capacity_policy, null: true, index: true + end +end diff --git a/db/migrate/20250806140005_create_leaves.rb b/db/migrate/20250806140005_create_leaves.rb new file mode 100644 index 000000000..982ec1181 --- /dev/null +++ b/db/migrate/20250806140005_create_leaves.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateLeaves < ActiveRecord::Migration[7.1] + def change + create_table :leaves do |t| + t.references :account, null: false + t.references :user, null: false + t.date :start_date, null: false + t.date :end_date, null: false + t.integer :leave_type, null: false, default: 0 + t.integer :status, null: false, default: 0 + t.text :reason + t.references :approved_by + t.datetime :approved_at + + t.timestamps + end + + add_index :leaves, [:account_id, :status] + end +end diff --git a/db/schema.rb b/db/schema.rb index eb7409b8b..43996ac45 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do +ActiveRecord::Schema[7.1].define(version: 2025_08_06_140005) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do t.integer "availability", default: 0, null: false t.boolean "auto_offline", default: true, null: false t.bigint "custom_role_id" + t.bigint "agent_capacity_policy_id" t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true t.index ["account_id"], name: "index_account_users_on_account_id" + t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id" t.index ["custom_role_id"], name: "index_account_users_on_custom_role_id" t.index ["user_id"], name: "index_account_users_on_user_id" end @@ -120,6 +122,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do t.index ["account_id"], name: "index_agent_bots_on_account_id" end + create_table "agent_capacity_policies", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "name", limit: 255, null: false + t.text "description" + t.jsonb "exclusion_rules", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id" + end + create_table "applied_slas", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "sla_policy_id", null: false @@ -169,6 +181,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do t.index ["views"], name: "index_articles_on_views" end + create_table "assignment_policies", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "name", limit: 255, null: false + t.text "description" + t.integer "assignment_order", default: 0, null: false + t.integer "conversation_priority", default: 0, null: false + t.integer "fair_distribution_limit", default: 100, null: false + t.integer "fair_distribution_window", default: 3600, null: false + t.boolean "enabled", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true + t.index ["account_id"], name: "index_assignment_policies_on_account_id" + t.index ["enabled"], name: "index_assignment_policies_on_enabled" + end + create_table "attachments", id: :serial, force: :cascade do |t| t.integer "file_type", default: 0 t.string "external_url" @@ -728,6 +756,26 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do t.datetime "updated_at", null: false end + create_table "inbox_assignment_policies", force: :cascade do |t| + t.bigint "inbox_id", null: false + t.bigint "assignment_policy_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_policy_id"], name: "index_inbox_assignment_policies_on_assignment_policy_id" + t.index ["inbox_id"], name: "index_inbox_assignment_policies_on_inbox_id" + end + + create_table "inbox_capacity_limits", force: :cascade do |t| + t.bigint "agent_capacity_policy_id", null: false + t.bigint "inbox_id", null: false + t.integer "conversation_limit", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["agent_capacity_policy_id", "inbox_id"], name: "idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf", unique: true + t.index ["agent_capacity_policy_id"], name: "index_inbox_capacity_limits_on_agent_capacity_policy_id" + t.index ["inbox_id"], name: "index_inbox_capacity_limits_on_inbox_id" + end + create_table "inbox_members", id: :serial, force: :cascade do |t| t.integer "user_id", null: false t.integer "inbox_id", null: false @@ -800,6 +848,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true end + create_table "leaves", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "user_id", null: false + t.date "start_date", null: false + t.date "end_date", null: false + t.integer "leave_type", default: 0, null: false + t.integer "status", default: 0, null: false + t.text "reason" + t.bigint "approved_by_id" + t.datetime "approved_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "status"], name: "index_leaves_on_account_id_and_status" + t.index ["account_id"], name: "index_leaves_on_account_id" + t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id" + t.index ["user_id"], name: "index_leaves_on_user_id" + end + create_table "macros", force: :cascade do |t| t.bigint "account_id", null: false t.string "name", null: false @@ -909,6 +975,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at" t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications" t.index ["secondary_actor_type", "secondary_actor_id"], name: "uniq_secondary_actor_per_account_notifications" + t.index ["user_id", "account_id", "snoozed_until", "read_at"], name: "idx_notifications_performance" t.index ["user_id"], name: "index_notifications_on_user_id" end diff --git a/enterprise/lib/captain/tools/faq_lookup_tool.rb b/enterprise/lib/captain/tools/faq_lookup_tool.rb new file mode 100644 index 000000000..d09b7c2d7 --- /dev/null +++ b/enterprise/lib/captain/tools/faq_lookup_tool.rb @@ -0,0 +1,39 @@ +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:) + log_tool_usage('searching', { query: query }) + + # Use existing vector search on approved responses + responses = @assistant.responses.approved.search(query).to_a + + if responses.empty? + log_tool_usage('no_results', { query: query }) + "No relevant FAQs found for: #{query}" + else + log_tool_usage('found_results', { query: query, count: responses.size }) + format_responses(responses) + end + end + + private + + def format_responses(responses) + responses.map { |response| format_response(response) }.join + end + + def format_response(response) + formatted_response = " + Question: #{response.question} + Answer: #{response.answer} + " + if response.documentable.present? && response.documentable.try(:external_link) + formatted_response += " + Source: #{response.documentable.external_link} + " + end + + formatted_response + end +end diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb new file mode 100644 index 000000000..49f7c5a65 --- /dev/null +++ b/enterprise/lib/captain/tools/handoff_tool.rb @@ -0,0 +1,52 @@ +class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool + description 'Hand off the conversation to a human agent when unable to assist further' + param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false + + def perform(tool_context, reason: nil) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + + # Log the handoff with reason + log_tool_usage('tool_handoff', { + conversation_id: conversation.id, + reason: reason || 'Agent requested handoff' + }) + + # Use existing handoff mechanism from ResponseBuilderJob + trigger_handoff(conversation, reason) + + "Conversation handed off to human support team#{" (Reason: #{reason})" if reason}" + rescue StandardError => e + ChatwootExceptionTracker.new(e).capture_exception + 'Failed to handoff conversation' + end + + private + + def trigger_handoff(conversation, reason) + # post the reason as a private note + conversation.messages.create!( + message_type: :outgoing, + private: true, + sender: @assistant, + account: conversation.account, + inbox: conversation.inbox, + content: reason + ) + + # Trigger the bot handoff (sets status to open + dispatches events) + conversation.bot_handoff! + end + + # TODO: Future enhancement - Add team assignment capability + # This tool could be enhanced to: + # 1. Accept team_id parameter for routing to specific teams + # 2. Set conversation priority based on handoff reason + # 3. Add metadata for intelligent agent assignment + # 4. Support escalation levels (L1 -> L2 -> L3) + # + # Example future signature: + # param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false + # param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false + # param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false +end diff --git a/enterprise/lib/captain/tools/update_priority_tool.rb b/enterprise/lib/captain/tools/update_priority_tool.rb index 8fc75f601..1196911fa 100644 --- a/enterprise/lib/captain/tools/update_priority_tool.rb +++ b/enterprise/lib/captain/tools/update_priority_tool.rb @@ -23,7 +23,9 @@ class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool end def normalize_priority(priority) - priority == 'nil' || priority.blank? ? nil : priority + return nil if priority == 'nil' || priority.blank? + + priority.downcase end def valid_priority?(priority) diff --git a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb index eca193c76..2e74817a7 100644 --- a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb @@ -119,19 +119,18 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do expect(Whatsapp::EmbeddedSignupService).to receive(:new).with( account: account, - code: 'test_code', - business_id: 'test_business_id', - waba_id: 'test_waba_id', - phone_number_id: 'test_phone_id' + params: { + code: 'test_code', + business_id: 'test_business_id', + waba_id: 'test_waba_id', + phone_number_id: 'test_phone_id' + }, + inbox_id: nil ).and_return(embedded_signup_service) allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel) allow(whatsapp_channel).to receive(:inbox).and_return(inbox) - - # Stub webhook setup service - webhook_service = instance_double(Whatsapp::WebhookSetupService) - allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service) - allow(webhook_service).to receive(:perform) + allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true)) post "/api/v1/accounts/#{account.id}/whatsapp/authorization", params: { @@ -151,19 +150,17 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do expect(Whatsapp::EmbeddedSignupService).to receive(:new).with( account: account, - code: 'test_code', - business_id: 'test_business_id', - waba_id: 'test_waba_id', - phone_number_id: nil + params: { + code: 'test_code', + business_id: 'test_business_id', + waba_id: 'test_waba_id' + }, + inbox_id: nil ).and_return(embedded_signup_service) allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel) allow(whatsapp_channel).to receive(:inbox).and_return(inbox) - - # Stub webhook setup service - webhook_service = instance_double(Whatsapp::WebhookSetupService) - allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service) - allow(webhook_service).to receive(:perform) + allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true)) post "/api/v1/accounts/#{account.id}/whatsapp/authorization", params: { @@ -300,4 +297,236 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/whatsapp/authorization with inbox_id (reauthorization)' do + let(:whatsapp_channel) do + channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', + provider_config: { + 'api_key' => 'test_token', + 'phone_number_id' => '123456', + 'business_account_id' => '654321', + 'source' => 'embedded_signup' + }) + allow(channel).to receive(:validate_provider_config).and_return(true) + allow(channel).to receive(:sync_templates).and_return(true) + allow(channel).to receive(:setup_webhooks).and_return(true) + channel.save! + # Call authorization_error! twice to reach the threshold + channel.authorization_error! + channel.authorization_error! + channel + end + let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) } + + context 'when user is an administrator' do + let(:administrator) { create(:user, account: account, role: :administrator) } + + before do + account.enable_features!(:whatsapp_embedded_signup) + end + + context 'with valid parameters' do + let(:valid_params) do + { + code: 'auth_code_123', + business_id: 'business_123', + waba_id: 'waba_123', + phone_number_id: 'phone_123' + } + end + + it 'reauthorizes the WhatsApp channel successfully' do + allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true) + + embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService) + allow(Whatsapp::EmbeddedSignupService).to receive(:new).with( + account: account, + params: { + code: 'auth_code_123', + business_id: 'business_123', + waba_id: 'waba_123', + phone_number_id: 'phone_123' + }, + inbox_id: whatsapp_inbox.id + ).and_return(embedded_signup_service) + allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel) + allow(whatsapp_channel).to receive(:inbox).and_return(whatsapp_inbox) + + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: valid_params.merge(inbox_id: whatsapp_inbox.id), + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['success']).to be true + expect(json_response['id']).to eq(whatsapp_inbox.id) + end + + it 'handles reauthorization failure' do + embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService) + allow(Whatsapp::EmbeddedSignupService).to receive(:new).with( + account: account, + params: { + code: 'auth_code_123', + business_id: 'business_123', + waba_id: 'waba_123', + phone_number_id: 'phone_123' + }, + inbox_id: whatsapp_inbox.id + ).and_return(embedded_signup_service) + allow(embedded_signup_service).to receive(:perform) + .and_raise(StandardError, 'Token exchange failed') + + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: valid_params.merge(inbox_id: whatsapp_inbox.id), + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['success']).to be false + expect(json_response['error']).to eq('Token exchange failed') + end + + it 'handles phone number mismatch error' do + embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService) + allow(Whatsapp::EmbeddedSignupService).to receive(:new).with( + account: account, + params: { + code: 'auth_code_123', + business_id: 'business_123', + waba_id: 'waba_123', + phone_number_id: 'phone_123' + }, + inbox_id: whatsapp_inbox.id + ).and_return(embedded_signup_service) + allow(embedded_signup_service).to receive(:perform) + .and_raise(StandardError, 'Phone number mismatch. The new phone number (+1234567890) does not match ' \ + 'the existing phone number (+15551234567). Please use the same WhatsApp ' \ + 'Business Account that was originally connected.') + + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: valid_params.merge(inbox_id: whatsapp_inbox.id), + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['success']).to be false + expect(json_response['error']).to include('Phone number mismatch') + end + end + + context 'when inbox does not exist' do + it 'returns not found error' do + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: { inbox_id: 0, code: 'test', business_id: 'test', waba_id: 'test' }, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when reauthorization is not required' do + let(:fresh_channel) do + channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', + provider_config: { + 'api_key' => 'test_token', + 'phone_number_id' => '123456', + 'business_account_id' => '654321', + 'source' => 'embedded_signup' + }) + allow(channel).to receive(:validate_provider_config).and_return(true) + allow(channel).to receive(:sync_templates).and_return(true) + allow(channel).to receive(:setup_webhooks).and_return(true) + channel.save! + # Do NOT call authorization_error! - channel is working fine + channel + end + let(:fresh_inbox) { create(:inbox, channel: fresh_channel, account: account) } + + it 'returns unprocessable entity error' do + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: { inbox_id: fresh_inbox.id }, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['success']).to be false + end + end + + context 'when channel is not WhatsApp' do + let(:facebook_channel) do + stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps') + .to_return(status: 200, body: '{}', headers: {}) + + channel = create(:channel_facebook_page, account: account) + # Call authorization_error! twice to reach the threshold + channel.authorization_error! + channel.authorization_error! + channel + end + let(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) } + + it 'returns unprocessable entity error' do + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: { inbox_id: facebook_inbox.id }, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['success']).to be false + end + end + end + + context 'when user is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + before do + account.enable_features!(:whatsapp_embedded_signup) + create(:inbox_member, inbox: whatsapp_inbox, user: agent) + end + + it 'returns unprocessable_entity error' do + allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true) + + # Stub the embedded signup service to prevent HTTP calls + embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService) + allow(Whatsapp::EmbeddedSignupService).to receive(:new).with( + account: account, + params: { + code: 'test', + business_id: 'test', + waba_id: 'test' + }, + inbox_id: whatsapp_inbox.id + ).and_return(embedded_signup_service) + allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel) + + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: { inbox_id: whatsapp_inbox.id, code: 'test', business_id: 'test', waba_id: 'test' }, + headers: agent.create_new_auth_token, + as: :json + + # Agents should get unprocessable_entity since they can find the inbox but channel doesn't need reauth + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when user is not authenticated' do + it 'returns unauthorized error' do + post "/api/v1/accounts/#{account.id}/whatsapp/authorization", + params: { inbox_id: whatsapp_inbox.id }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb new file mode 100644 index 000000000..ccae44ac2 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::FaqLookupTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:tool_context) { Struct.new(:state).new({}) } + + before do + # Create installation config for OpenAI API key to avoid errors + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + + # Mock embedding service to avoid actual API calls + embedding_service = instance_double(Captain::Llm::EmbeddingService) + allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service) + allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.1)) + end + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Search FAQ responses using semantic similarity to find relevant answers') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:query) + expect(tool.parameters[:query].name).to eq(:query) + expect(tool.parameters[:query].type).to eq('string') + expect(tool.parameters[:query].description).to eq('The question or topic to search for in the FAQ database') + end + end + + describe '#perform' do + context 'when FAQs exist' do + let(:document) { create(:captain_document, assistant: assistant) } + let!(:response1) do + create(:captain_assistant_response, + assistant: assistant, + question: 'How to reset password?', + answer: 'Click on forgot password link', + documentable: document, + status: 'approved') + end + let!(:response2) do + create(:captain_assistant_response, + assistant: assistant, + question: 'How to change email?', + answer: 'Go to settings and update email', + status: 'approved') + end + + before do + # Mock nearest_neighbors to return our test responses + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return( + Captain::AssistantResponse.where(id: [response1.id, response2.id]) + ) + end + + it 'searches FAQs and returns formatted responses' do + result = tool.perform(tool_context, query: 'password reset') + + expect(result).to include('Question: How to reset password?') + expect(result).to include('Answer: Click on forgot password link') + expect(result).to include('Question: How to change email?') + expect(result).to include('Answer: Go to settings and update email') + end + + it 'includes source link when document has external_link' do + document.update!(external_link: 'https://help.example.com/password') + + result = tool.perform(tool_context, query: 'password') + + expect(result).to include('Source: https://help.example.com/password') + end + + it 'logs tool usage for search' do + expect(tool).to receive(:log_tool_usage).with('searching', { query: 'password reset' }) + expect(tool).to receive(:log_tool_usage).with('found_results', { query: 'password reset', count: 2 }) + + tool.perform(tool_context, query: 'password reset') + end + end + + context 'when no FAQs found' do + before do + # Return empty result set + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none) + end + + it 'returns no results message' do + result = tool.perform(tool_context, query: 'nonexistent topic') + expect(result).to eq('No relevant FAQs found for: nonexistent topic') + end + + it 'logs tool usage for no results' do + expect(tool).to receive(:log_tool_usage).with('searching', { query: 'nonexistent topic' }) + expect(tool).to receive(:log_tool_usage).with('no_results', { query: 'nonexistent topic' }) + + tool.perform(tool_context, query: 'nonexistent topic') + end + end + + context 'with blank query' do + it 'handles empty query' do + # Return empty result set + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none) + + result = tool.perform(tool_context, query: '') + expect(result).to eq('No relevant FAQs found for: ') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb new file mode 100644 index 000000000..16b46c08a --- /dev/null +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -0,0 +1,166 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::HandoffTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Hand off the conversation to a human agent when unable to assist further') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:reason) + expect(tool.parameters[:reason].name).to eq(:reason) + expect(tool.parameters[:reason].type).to eq('string') + expect(tool.parameters[:reason].description).to eq('The reason why handoff is needed (optional)') + expect(tool.parameters[:reason].required).to be false + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with reason provided' do + it 'creates a private note with reason and hands off conversation' do + reason = 'Customer needs specialized support' + + expect do + result = tool.perform(tool_context, reason: reason) + expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})") + end.to change(Message, :count).by(1) + end + + it 'creates message with correct attributes' do + reason = 'Customer needs specialized support' + tool.perform(tool_context, reason: reason) + + created_message = Message.last + expect(created_message.content).to eq(reason) + expect(created_message.message_type).to eq('outgoing') + expect(created_message.private).to be true + expect(created_message.sender).to eq(assistant) + expect(created_message.account).to eq(account) + expect(created_message.inbox).to eq(inbox) + expect(created_message.conversation).to eq(conversation) + end + + it 'triggers bot handoff on conversation' do + # The tool finds the conversation by ID, so we need to mock the found conversation + found_conversation = Conversation.find(conversation.id) + scoped_conversations = Conversation.where(account_id: assistant.account_id) + allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) + allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) + expect(found_conversation).to receive(:bot_handoff!) + + tool.perform(tool_context, reason: 'Test reason') + end + + it 'logs tool usage with reason' do + reason = 'Customer needs help' + expect(tool).to receive(:log_tool_usage).with( + 'tool_handoff', + { conversation_id: conversation.id, reason: reason } + ) + + tool.perform(tool_context, reason: reason) + end + end + + context 'without reason provided' do + it 'creates a private note with nil content and hands off conversation' do + expect do + result = tool.perform(tool_context) + expect(result).to eq('Conversation handed off to human support team') + end.to change(Message, :count).by(1) + + created_message = Message.last + expect(created_message.content).to be_nil + end + + it 'logs tool usage with default reason' do + expect(tool).to receive(:log_tool_usage).with( + 'tool_handoff', + { conversation_id: conversation.id, reason: 'Agent requested handoff' } + ) + + tool.perform(tool_context) + end + end + + context 'when handoff fails' do + before do + # Mock the conversation lookup and handoff failure + found_conversation = Conversation.find(conversation.id) + scoped_conversations = Conversation.where(account_id: assistant.account_id) + allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) + allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) + allow(found_conversation).to receive(:bot_handoff!).and_raise(StandardError, 'Handoff error') + + exception_tracker = instance_double(ChatwootExceptionTracker) + allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) + allow(exception_tracker).to receive(:capture_exception) + end + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Failed to handoff conversation') + end + + it 'captures exception' do + exception_tracker = instance_double(ChatwootExceptionTracker) + expect(ChatwootExceptionTracker).to receive(:new).with(instance_of(StandardError)).and_return(exception_tracker) + expect(exception_tracker).to receive(:capture_exception) + + tool.perform(tool_context, reason: 'Test') + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + + it 'does not create a message' do + expect do + tool.perform(tool_context, reason: 'Test') + end.not_to change(Message, :count) + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/finders/notification_finder_spec.rb b/spec/finders/notification_finder_spec.rb index 962edc297..882bfee2c 100644 --- a/spec/finders/notification_finder_spec.rb +++ b/spec/finders/notification_finder_spec.rb @@ -66,14 +66,31 @@ RSpec.describe NotificationFinder do expect(subject.unread_count).to eq(3) expect(subject.count).to eq(3) end + + it 'avoids duplicate filtering in unread_count method' do + # Test the logical fix: when no 'read' filter is included, + # @notifications is already filtered to unread, so unread_count + # should just count without adding another read_at filter + + allow(subject.instance_variable_get(:@notifications)).to receive(:where).and_call_original + allow(subject.instance_variable_get(:@notifications)).to receive(:count).and_call_original + + result = subject.unread_count + + # Should return correct count without additional where clause + expect(result).to eq(3) + + # The fix ensures that when params[:includes] doesn't contain 'read', + # unread_count uses @notifications.count instead of @notifications.where(read_at: nil).count + end end context 'with filters applied' do let(:params) { { includes: %w[read snoozed] } } it 'adjusts counts based on included statuses' do - expect(subject.unread_count).to eq(4) - expect(subject.count).to eq(6) + expect(subject.unread_count).to eq(4) # 3 unread + 1 snoozed (which is unread) + expect(subject.count).to eq(6) # all notifications including read and snoozed end end end diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb index 63f721dde..d32ef59bb 100644 --- a/spec/services/twilio/incoming_message_service_spec.rb +++ b/spec/services/twilio/incoming_message_service_spec.rb @@ -327,6 +327,81 @@ describe Twilio::IncomingMessageService do contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890') expect(contact.name).to eq('1234567890') end + + it 'updates existing contact name when current name matches phone number' do + # Create contact with phone number as name + existing_contact = create(:contact, + account: twilio_channel.inbox.account, + name: '+1234567890', + phone_number: '+1234567890') + create(:contact_inbox, + contact: existing_contact, + inbox: twilio_channel.inbox, + source_id: '+1234567890') + + params = { + SmsSid: 'SMxx', + From: '+1234567890', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + Body: 'Hello', + ProfileName: 'Jane Smith' + } + + described_class.new(params: params).perform + existing_contact.reload + expect(existing_contact.name).to eq('Jane Smith') + end + + it 'does not update contact name when current name is different from phone number' do + # Create contact with human name + existing_contact = create(:contact, + account: twilio_channel.inbox.account, + name: 'John Doe', + phone_number: '+1234567890') + create(:contact_inbox, + contact: existing_contact, + inbox: twilio_channel.inbox, + source_id: '+1234567890') + + params = { + SmsSid: 'SMxx', + From: '+1234567890', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + Body: 'Hello', + ProfileName: 'Jane Smith' + } + + described_class.new(params: params).perform + existing_contact.reload + expect(existing_contact.name).to eq('John Doe') # Should not change + end + + it 'updates contact name when current name matches formatted phone number' do + # Create contact with formatted phone number as name + existing_contact = create(:contact, + account: twilio_channel.inbox.account, + name: '1234567890', + phone_number: '+1234567890') + create(:contact_inbox, + contact: existing_contact, + inbox: twilio_channel.inbox, + source_id: '+1234567890') + + params = { + SmsSid: 'SMxx', + From: '+1234567890', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + Body: 'Hello', + ProfileName: 'Alice Johnson' + } + + described_class.new(params: params).perform + existing_contact.reload + expect(existing_contact.name).to eq('Alice Johnson') + end end end end diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index 95af73523..12a4d32df 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -2,17 +2,18 @@ require 'rails_helper' describe Whatsapp::EmbeddedSignupService do let(:account) { create(:account) } - let(:code) { 'test_authorization_code' } - let(:business_id) { 'test_business_id' } - let(:waba_id) { 'test_waba_id' } - let(:phone_number_id) { 'test_phone_number_id' } + let(:params) do + { + code: 'test_authorization_code', + business_id: 'test_business_id', + waba_id: 'test_waba_id', + phone_number_id: 'test_phone_number_id' + } + end let(:service) do described_class.new( account: account, - code: code, - business_id: business_id, - waba_id: waba_id, - phone_number_id: phone_number_id + params: params ) end @@ -20,37 +21,40 @@ describe Whatsapp::EmbeddedSignupService do let(:access_token) { 'test_access_token' } let(:phone_info) do { - phone_number_id: phone_number_id, + phone_number_id: params[:phone_number_id], phone_number: '+1234567890', verified: true, business_name: 'Test Business' } end let(:channel) { instance_double(Channel::Whatsapp) } - - let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) } - let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) } - let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) } - let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) } + let(:service_doubles) do + { + token_exchange: instance_double(Whatsapp::TokenExchangeService), + phone_info: instance_double(Whatsapp::PhoneInfoService), + token_validation: instance_double(Whatsapp::TokenValidationService), + channel_creation: instance_double(Whatsapp::ChannelCreationService) + } + end before do allow(GlobalConfig).to receive(:clear_cache) - allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service) - allow(token_exchange_service).to receive(:perform).and_return(access_token) + allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(service_doubles[:token_exchange]) + allow(service_doubles[:token_exchange]).to receive(:perform).and_return(access_token) allow(Whatsapp::PhoneInfoService).to receive(:new) - .with(waba_id, phone_number_id, access_token).and_return(phone_info_service) - allow(phone_info_service).to receive(:perform).and_return(phone_info) + .with(params[:waba_id], params[:phone_number_id], access_token).and_return(service_doubles[:phone_info]) + allow(service_doubles[:phone_info]).to receive(:perform).and_return(phone_info) allow(Whatsapp::TokenValidationService).to receive(:new) - .with(access_token, waba_id).and_return(token_validation_service) - allow(token_validation_service).to receive(:perform) + .with(access_token, params[:waba_id]).and_return(service_doubles[:token_validation]) + allow(service_doubles[:token_validation]).to receive(:perform) allow(Whatsapp::ChannelCreationService).to receive(:new) - .with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token) - .and_return(channel_creation_service) - allow(channel_creation_service).to receive(:perform).and_return(channel) + .with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token) + .and_return(service_doubles[:channel_creation]) + allow(service_doubles[:channel_creation]).to receive(:perform).and_return(channel) # Webhook setup is now handled in the channel after_create callback # So we stub it at the model level @@ -60,10 +64,10 @@ describe Whatsapp::EmbeddedSignupService do end it 'orchestrates all services in the correct order' do - expect(token_exchange_service).to receive(:perform).ordered - expect(phone_info_service).to receive(:perform).ordered - expect(token_validation_service).to receive(:perform).ordered - expect(channel_creation_service).to receive(:perform).ordered + expect(service_doubles[:token_exchange]).to receive(:perform).ordered + expect(service_doubles[:phone_info]).to receive(:perform).ordered + expect(service_doubles[:token_validation]).to receive(:perform).ordered + expect(service_doubles[:channel_creation]).to receive(:perform).ordered result = service.perform expect(result).to eq(channel) @@ -73,10 +77,7 @@ describe Whatsapp::EmbeddedSignupService do it 'raises error when code is blank' do service = described_class.new( account: account, - code: '', - business_id: business_id, - waba_id: waba_id, - phone_number_id: phone_number_id + params: params.merge(code: '') ) expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/) end @@ -84,10 +85,7 @@ describe Whatsapp::EmbeddedSignupService do it 'raises error when business_id is blank' do service = described_class.new( account: account, - code: code, - business_id: '', - waba_id: waba_id, - phone_number_id: phone_number_id + params: params.merge(business_id: '') ) expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/) end @@ -95,10 +93,7 @@ describe Whatsapp::EmbeddedSignupService do it 'raises error when waba_id is blank' do service = described_class.new( account: account, - code: code, - business_id: business_id, - waba_id: '', - phone_number_id: phone_number_id + params: params.merge(waba_id: '') ) expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/) end @@ -106,10 +101,7 @@ describe Whatsapp::EmbeddedSignupService do it 'raises error when multiple parameters are blank' do service = described_class.new( account: account, - code: '', - business_id: '', - waba_id: waba_id, - phone_number_id: phone_number_id + params: params.merge(code: '', business_id: '') ) expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/) end @@ -117,11 +109,44 @@ describe Whatsapp::EmbeddedSignupService do context 'when any service fails' do it 'logs and re-raises the error' do - allow(token_exchange_service).to receive(:perform).and_raise('Token error') + allow(service_doubles[:token_exchange]).to receive(:perform).and_raise('Token error') expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error') expect { service.perform }.to raise_error('Token error') end end + + context 'when inbox_id is provided (reauthorization flow)' do + let(:inbox_id) { 123 } + let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) } + let(:service_with_inbox) do + described_class.new( + account: account, + params: params, + inbox_id: inbox_id + ) + end + + before do + allow(Whatsapp::ReauthorizationService).to receive(:new).with( + account: account, + inbox_id: inbox_id, + phone_number_id: params[:phone_number_id], + business_id: params[:business_id] + ).and_return(reauth_service) + allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel) + end + + it 'uses ReauthorizationService instead of ChannelCreationService' do + expect(service_doubles[:token_exchange]).to receive(:perform).ordered + expect(service_doubles[:phone_info]).to receive(:perform).ordered + expect(service_doubles[:token_validation]).to receive(:perform).ordered + expect(reauth_service).to receive(:perform).with(access_token, phone_info).ordered + expect(service_doubles[:channel_creation]).not_to receive(:perform) + + result = service_with_inbox.perform + expect(result).to eq(channel) + end + end end end diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb index 89beca922..7cee115c2 100644 --- a/spec/services/whatsapp/webhook_setup_service_spec.rb +++ b/spec/services/whatsapp/webhook_setup_service_spec.rb @@ -24,25 +24,19 @@ describe Whatsapp::WebhookSetupService do end describe '#perform' do - context 'when all operations succeed' do + context 'when phone number is NOT verified (should register)' do before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token') - .and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) allow(channel).to receive(:save!) end - it 'registers the phone number' do + it 'registers the phone number and sets up webhook' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) - service.perform - end - end - - it 'sets up webhook subscription' do - with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:subscribe_waba_webhook) .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') service.perform @@ -50,33 +44,71 @@ describe Whatsapp::WebhookSetupService do end end - context 'when phone registration fails' do + context 'when phone number IS verified (should NOT register)' do before do - allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) - allow(api_client).to receive(:register_phone_number) - .and_raise('Registration failed') + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) allow(api_client).to receive(:subscribe_waba_webhook) - .and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) end - it 'continues with webhook setup' do + it 'does NOT register phone, but sets up webhook' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).not_to receive(:register_phone_number) + expect(api_client).to receive(:subscribe_waba_webhook) + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + service.perform + end + end + end + + context 'when phone_number_verified? raises error' do + before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down') + allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) + allow(api_client).to receive(:register_phone_number) + allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(channel).to receive(:save!) + end + + it 'tries to register phone and proceeds with webhook setup' do + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number) expect(api_client).to receive(:subscribe_waba_webhook) expect { service.perform }.not_to raise_error end end end - context 'when webhook setup fails' do + context 'when phone registration fails (not blocking)' do before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) + allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) + allow(api_client).to receive(:register_phone_number).and_raise('Registration failed') + allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(channel).to receive(:save!) + end + + it 'continues with webhook setup even if registration fails' do + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number) + expect(api_client).to receive(:subscribe_waba_webhook) + expect { service.perform }.not_to raise_error + end + end + end + + context 'when webhook setup fails (should raise)' do + before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number) - allow(api_client).to receive(:subscribe_waba_webhook) - .and_raise('Webhook failed') + allow(api_client).to receive(:subscribe_waba_webhook).and_raise('Webhook failed') end it 'raises an error' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number) + expect(api_client).to receive(:subscribe_waba_webhook) expect { service.perform }.to raise_error(/Webhook setup failed/) end end @@ -84,24 +116,25 @@ describe Whatsapp::WebhookSetupService do context 'when required parameters are missing' do it 'raises error when channel is nil' do - service = described_class.new(nil, waba_id, access_token) - expect { service.perform }.to raise_error(ArgumentError, 'Channel is required') + service_invalid = described_class.new(nil, waba_id, access_token) + expect { service_invalid.perform }.to raise_error(ArgumentError, 'Channel is required') end it 'raises error when waba_id is blank' do - service = described_class.new(channel, '', access_token) - expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required') + service_invalid = described_class.new(channel, '', access_token) + expect { service_invalid.perform }.to raise_error(ArgumentError, 'WABA ID is required') end it 'raises error when access_token is blank' do - service = described_class.new(channel, waba_id, '') - expect { service.perform }.to raise_error(ArgumentError, 'Access token is required') + service_invalid = described_class.new(channel, waba_id, '') + expect { service_invalid.perform }.to raise_error(ArgumentError, 'Access token is required') end end context 'when PIN already exists' do before do channel.provider_config['verification_pin'] = 123_456 + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(api_client).to receive(:register_phone_number) allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) allow(channel).to receive(:save!) diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index 25d2ae374..2a7ba9fd0 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -7,6 +7,9 @@ RSpec.describe Whatsapp::WebhookTeardownService do context 'when channel is whatsapp_cloud with embedded_signup' do before do + # Stub webhook setup to prevent HTTP calls during channel update + allow(channel).to receive(:setup_webhooks).and_return(true) + channel.update!( provider: 'whatsapp_cloud', provider_config: {