* feat(whatsapp): allow converting inbox between WhatsApp providers Adds a Convert flow to switch a WhatsApp inbox between the four supported providers (default/360dialog, whatsapp_cloud, baileys, zapi) without losing conversations, agents, or history. - Channel::Whatsapp#convert_provider! runs inside a transaction: disconnects the old provider, clears provider_connection and message_templates, assigns the new provider/config, and triggers webhook setup plus template resync on the new service. - New POST /api/v1/accounts/:id/inboxes/:id/convert_provider endpoint guarded by InboxPolicy#convert_provider? (admin only). - UI adds a Convert button on the inbox Settings page with a type-to-confirm ConvertInboxModal that lists the effects before redirecting to a dedicated route reusing the WhatsApp provider wizard in convert mode (phone number locked, current provider hidden from the picker). * chore(whatsapp): polish convert UI colors and expand specs - Settings: use slate for the Convert trigger and ruby for the modal confirm to mirror the delete gate instead of the less conventional amber variant. - Drop the redundant "current provider is hidden from the list" sentence from the convert wizard description. - Add specs for the post-conversion webhook setup path (triggered and skipped branches) and the sync_templates error-rescue behaviour. * fix: address CodeRabbit review on convert-provider flow - Whitelist provider_config keys in the convert endpoint via permit rather than permit!, and default to an empty hash when omitted so the request no longer crashes. - Pre-validate the new provider config before disconnecting the old session so a bad target config no longer terminates the existing provider; also keep the disconnect bound to the old provider_url. - Guard ConvertInboxModal's submit handler so pressing Enter cannot bypass the type-to-confirm gate, and migrate it to <script setup>. - Reject invalid ?provider= query values in convert mode so hidden providers (Twilio, the current provider) cannot be reached via URL. - Await the inbox fetch in InboxConvert before running the route guard so directly opening the route for a non-WhatsApp inbox redirects. - Remove the unreachable second CloudWhatsapp branch in Whatsapp.vue. * fix: address second CodeRabbit round on convert-provider flow - Unify provider picker validation so create mode also rejects unknown ?provider= values, with a single helper that accepts available providers plus the whatsapp_manual fallback. - Simplify the pre-validation rollback in convert_provider!: the errors snapshot/merge dance was redundant because assign_attributes does not clear errors. - Follow the repo convention of asserting on error.class.name so the rollback spec stays stable under reloading/parallel environments. - Strengthen the controller success spec with provider_connection and message_templates cleanup invariants, and set Content-Type on the templates stub so HTTParty parses the empty data array correctly. * fix: address third CodeRabbit round on convert-provider flow - Add 360Dialog entry to the Whatsapp provider catalog, keep it hidden from the create picker (preserving the existing fork behavior) but expose it in the convert picker where it is a valid target. Restore URL reachability for ?provider=360dialog in create mode. - Scope the WHATSAPP_MANUAL allowance to create mode only: the manual fallback flow is not reachable in convert mode. - Redirect to the inboxes list in InboxConvert when the inbox is still absent after the store fetch, so the page no longer stays blank. - Use an explicit allowlist of WhatsApp providers to gate the Convert button instead of negating Twilio, so adding a new WhatsApp channel type will not silently expose the flow. - Bind the disabled provider display field with :value instead of v-model, since the underlying computed is getter-only. - Add Content-Type: application/json to the templates stub in the model spec so HTTParty parses the empty data array. * fix: address fourth CodeRabbit round on convert-provider flow - Reject no-op conversions that target the same provider as the one already configured, so the endpoint no longer wipes provider connection and message templates on a request that changes nothing. - Call the provider service's disconnect directly so failures abort the conversion instead of being silently swallowed; otherwise the old external session could remain live while the inbox flips to the new provider. - Cover both behaviors with specs. * fix: address fifth CodeRabbit round on convert-provider flow - Reset the Vuelidate state when closing ConvertInboxModal so reopening the gate does not surface stale validation errors. - Call teardown_webhooks before converting away from whatsapp_cloud so the Meta webhook subscription is removed for embedded_signup channels, mirroring the destroy-time cleanup (manual-setup channels keep the existing no-op behavior). Swallow teardown failures so a flaky Meta call does not abort the swap. - Switch the rollback specs to compare message_templates counts instead of the boolean be_present matcher so they remain meaningful if the fixture happens to have an empty templates list. * fix: address sixth CodeRabbit round on convert-provider flow - Derive the convert header's current-provider label from the shared PROVIDER_CATALOG so the picker and header stay in sync. - Assert the full Cloud provider_config payload and the absence of the Baileys-only provider_url key on both the controller success spec and the model atomic-swap spec. - In the sync-error spec, reload and assert that the record was actually flipped to the new provider before the sync rescue fires, so the test can't pass on a pre-save failure. * test: pin 422 error payload on convert_provider negative paths The unsupported-conversion and invalid-config specs only checked the status code, so they would have stayed green if the 422 started coming from a different branch. Pin the response body so each example actually covers the failure case it names. * fix(baileys): save custom host as provider_url, not url The Baileys form was writing the custom endpoint to provider_config['url'] while the backend reads provider_config['provider_url']. That silently broke the custom-host feature for newly created or converted Baileys inboxes: they always fell back to BAILEYS_PROVIDER_DEFAULT_URL. Align the key on both ends. * fix(whatsapp): skip second validation pass in convert_provider! The transaction's save! was re-running validate_provider_config after the old provider's session had already been disconnected, so a transient Graph API failure on the second check could roll back the swap while leaving the external session terminated — the exact inconsistency the pre-flight valid? was meant to rule out. Capture the validated provider_config snapshot after valid? (so fields populated by before_validation callbacks like webhook_verify_token are preserved) and switch the final persist to save!(validate: false) so the earlier check stays authoritative. * fix: normalize provider-conversion failures and pass accountId - The convert_provider action only rescued ActiveRecord::RecordInvalid, so disconnect/teardown failures bubbled up as 500 with no stable payload. Catch StandardError, log the class + message, and return a 422 with a generic user-facing message so the dashboard can surface the error consistently. - Nested settings routes live under /accounts/:accountId, so the router push from Settings.vue must include accountId alongside inboxId. Mirrors how sibling pages navigate to settings_inbox_show. * fix: report missing :provider as 400 and sync modal v-model - The generic rescue StandardError on convert_provider was masking ActionController::ParameterMissing behind a misleading provider-conversion error message. Catch it explicitly before the generic rescue and return 400 with the parameter-missing message. - ConvertInboxModal's closeModal now drives localShow to false so parents using v-model:show stay in sync on every close path, not only when the explicit onClose listener flips the flag. * fix(whatsapp): serialize concurrent convert_provider calls with_lock Without a per-record lock, two admin requests against the same inbox could both pass the pre-flight validation, race the disconnect/save, and then run setup_webhooks/sync_templates in arbitrary order, leaving the persisted provider out of sync with the external configuration. Wrap the whole convert flow in with_lock so the loser blocks until the winner commits; the subsequent no-op guard then rejects a second conversion request targeting the provider the first one just set. * test: harden convert_provider policy + controller failure specs - Pass accountId explicitly in InboxConvert redirects so the route navigation mirrors how Settings.vue reaches settings_inbox_convert. - Add a spec that assigns the agent to the inbox and still expects 401, so a future regression in InboxPolicy#convert_provider? can no longer slip past on the show policy alone. - Add a spec that stubs convert_provider! to raise StandardError and asserts the controller's generic-failure 422 payload, pinning the dashboard contract for provider-side failures. * test: pin convert_provider success response payload Parse the rendered body and assert provider + provider_config so the spec catches regressions where the DB is updated correctly but the serialized response drifts (dashboard store commits response.data). * fix(whatsapp): reset teardown guard after pre-conversion webhook cleanup teardown_webhooks memoizes @webhook_teardown_initiated = true to prevent double execution during destroy. Calling it from convert_provider! leaves that flag set, so a subsequent destroy! or follow-up conversion on the same instance would skip webhook removal silently. Reset the flag in an ensure block so the destroy-time guard stays scoped to destroy only. * fix: include accountId in post-conversion redirect params * test: pin same-provider convert returns 422 * fix(whatsapp): reset template columns when post-conversion sync fails * fix(convert): enforce provider allowlist in InboxConvert route guard * test: broaden Cloud templates stub to match account-scoped path * test(whatsapp): cover cloud to baileys conversion branch
253 lines
8.7 KiB
Ruby
253 lines
8.7 KiB
Ruby
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
|
|
include Api::V1::InboxesHelper
|
|
before_action :fetch_inbox, except: [:index, :create]
|
|
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
|
before_action :validate_limit, only: [:create]
|
|
# we are already handling the authorization in fetch inbox
|
|
# rubocop:disable Rails/LexicallyScopedActionFilter -- health is defined in WhatsappHealthManagement concern
|
|
before_action :check_authorization, except: [:show, :health, :setup_channel_provider]
|
|
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
|
# rubocop:enable Rails/LexicallyScopedActionFilter
|
|
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
|
|
|
def index
|
|
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
|
end
|
|
|
|
def show; end
|
|
|
|
# Deprecated: This API will be removed in 2.7.0
|
|
def assignable_agents
|
|
@assignable_agents = @inbox.assignable_agents
|
|
end
|
|
|
|
def campaigns
|
|
@campaigns = @inbox.campaigns
|
|
end
|
|
|
|
def avatar
|
|
@inbox.avatar.attachment.destroy! if @inbox.avatar.attached?
|
|
head :ok
|
|
end
|
|
|
|
def create
|
|
ActiveRecord::Base.transaction do
|
|
channel = create_channel
|
|
@inbox = Current.account.inboxes.build(
|
|
{
|
|
name: inbox_name(channel),
|
|
channel: channel
|
|
}.merge(
|
|
permitted_params.except(:channel)
|
|
)
|
|
)
|
|
@inbox.save!
|
|
end
|
|
end
|
|
|
|
def update
|
|
inbox_params = permitted_params.except(:channel, :csat_config)
|
|
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
|
@inbox.update!(inbox_params)
|
|
update_inbox_working_hours
|
|
update_channel if channel_update_required?
|
|
end
|
|
|
|
def agent_bot
|
|
@agent_bot = @inbox.agent_bot
|
|
end
|
|
|
|
def set_agent_bot
|
|
if @agent_bot
|
|
agent_bot_inbox = @inbox.agent_bot_inbox || AgentBotInbox.new(inbox: @inbox)
|
|
agent_bot_inbox.agent_bot = @agent_bot
|
|
agent_bot_inbox.save!
|
|
elsif @inbox.agent_bot_inbox.present?
|
|
@inbox.agent_bot_inbox.destroy!
|
|
end
|
|
head :ok
|
|
end
|
|
|
|
def reset_secret
|
|
return head :not_found unless @inbox.api?
|
|
|
|
@inbox.channel.reset_secret!
|
|
end
|
|
|
|
def setup_channel_provider
|
|
channel = @inbox.channel
|
|
|
|
unless channel.respond_to?(:setup_channel_provider)
|
|
render json: { error: 'Channel does not support setup' }, status: :unprocessable_entity and return
|
|
end
|
|
|
|
channel.setup_channel_provider
|
|
head :ok
|
|
end
|
|
|
|
def disconnect_channel_provider
|
|
channel = @inbox.channel
|
|
|
|
unless channel.respond_to?(:disconnect_channel_provider)
|
|
render json: { error: 'Channel does not support disconnect' }, status: :unprocessable_entity and return
|
|
end
|
|
|
|
channel.disconnect_channel_provider
|
|
head :ok
|
|
ensure
|
|
channel.update_provider_connection!(connection: 'close') if channel.respond_to?(:update_provider_connection!)
|
|
end
|
|
|
|
def convert_provider
|
|
channel = @inbox.channel
|
|
|
|
unless channel.respond_to?(:convert_provider!)
|
|
render json: { error: 'Channel does not support provider conversion' }, status: :unprocessable_entity and return
|
|
end
|
|
|
|
new_provider = params.require(:provider)
|
|
new_provider_config = (params.permit(provider_config: {})[:provider_config] || {}).to_h
|
|
|
|
channel.convert_provider!(new_provider: new_provider, new_provider_config: new_provider_config)
|
|
render :show
|
|
rescue ActionController::ParameterMissing => e
|
|
render json: { message: e.message }, status: :bad_request
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
render json: { message: e.record.errors.full_messages.join(', ') }, status: :unprocessable_entity
|
|
rescue StandardError => e
|
|
Rails.logger.error "[WHATSAPP] Provider conversion failed for inbox #{@inbox.id}: #{e.class}: #{e.message}"
|
|
render json: { message: 'Provider conversion failed. Please check your credentials and the previous provider session, then try again.' },
|
|
status: :unprocessable_entity
|
|
end
|
|
|
|
def destroy
|
|
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
|
end
|
|
|
|
def on_whatsapp
|
|
params.require(:phone_number)
|
|
phone_number = params[:phone_number]
|
|
channel = @inbox.channel
|
|
|
|
unless channel.respond_to?(:on_whatsapp)
|
|
render json: { error: 'Channel does not support whatsapp check' }, status: :unprocessable_entity and return
|
|
end
|
|
|
|
response = channel.on_whatsapp(phone_number)
|
|
|
|
render json: response, status: :ok
|
|
end
|
|
|
|
private
|
|
|
|
def fetch_inbox
|
|
@inbox = Current.account.inboxes.find(params[:id])
|
|
authorize @inbox, :show?
|
|
end
|
|
|
|
def fetch_agent_bot
|
|
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
|
end
|
|
|
|
def create_channel
|
|
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
|
|
|
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
|
end
|
|
|
|
def allowed_channel_types
|
|
%w[web_widget api email line telegram whatsapp sms]
|
|
end
|
|
|
|
def update_inbox_working_hours
|
|
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
|
end
|
|
|
|
def update_channel
|
|
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
|
return if permitted_params(channel_attributes)[:channel].blank?
|
|
|
|
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
|
|
|
reauthorize_and_update_channel(channel_attributes)
|
|
update_channel_feature_flags
|
|
end
|
|
|
|
def channel_update_required?
|
|
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
|
|
end
|
|
|
|
def validate_and_update_email_channel(channel_attributes)
|
|
validate_email_channel(channel_attributes)
|
|
rescue StandardError => e
|
|
render json: { message: e }, status: :unprocessable_entity and return
|
|
end
|
|
|
|
def reauthorize_and_update_channel(channel_attributes)
|
|
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
|
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
|
end
|
|
|
|
def update_channel_feature_flags
|
|
return unless @inbox.web_widget?
|
|
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
|
|
|
|
@inbox.channel.selected_feature_flags = permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel][:selected_feature_flags]
|
|
@inbox.channel.save!
|
|
end
|
|
|
|
def format_csat_config(config)
|
|
formatted = {
|
|
'display_type' => config['display_type'] || 'emoji',
|
|
'message' => config['message'] || '',
|
|
:survey_rules => {
|
|
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
|
'values' => config.dig('survey_rules', 'values') || []
|
|
},
|
|
'button_text' => config['button_text'] || 'Please rate us',
|
|
'language' => config['language'] || 'en'
|
|
}
|
|
format_template_config(config, formatted)
|
|
formatted
|
|
end
|
|
|
|
def format_template_config(config, formatted)
|
|
formatted['template'] = config['template'] if config['template'].present?
|
|
end
|
|
|
|
def inbox_attributes
|
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
|
{ survey_rules: [:operator, { values: [] }],
|
|
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
|
|
:created_at, :linked_at, :language, :source, :status, { body_variables: {} }] }] }]
|
|
end
|
|
|
|
def permitted_params(channel_attributes = [])
|
|
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
|
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
|
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
|
end
|
|
|
|
def channel_type_from_params
|
|
{
|
|
'web_widget' => Channel::WebWidget,
|
|
'api' => Channel::Api,
|
|
'email' => Channel::Email,
|
|
'line' => Channel::Line,
|
|
'telegram' => Channel::Telegram,
|
|
'whatsapp' => Channel::Whatsapp,
|
|
'sms' => Channel::Sms
|
|
}[permitted_params[:channel][:type]]
|
|
end
|
|
|
|
def get_channel_attributes(channel_type)
|
|
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
|
end
|
|
end
|
|
|
|
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|