diff --git a/.circleci/config.yml b/.circleci/config.yml index f764cb611..59702c139 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 20 + parallelism: 18 steps: - checkout - node/install: diff --git a/AGENTS.md b/AGENTS.md index 2ab6373b7..2430fae2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,18 @@ ## General Guidelines -- MVP focus: Least code change, happy-path only -- No unnecessary defensive programming -- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer the smallest production-ready change that solves the current problem. +- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. +- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. +- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks. +- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully. +- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing. +- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read. - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked +- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity. - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 24b6d9e70..c7608399e 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder def fallback_params(attachment) { - fallback_title: attachment['title'], + fallback_title: attachment['title'] || attachment.dig('payload', 'title'), external_url: attachment['url'] || attachment.dig('payload', 'url') } end # Facebook shared posts point to page URLs, not downloadable media URLs. + # Both `share` and `post` attachment types carry a page URL rather than a media file, + # so map them to `fallback` (which keeps the title/link without attempting a download). # Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling. def normalize_file_type(type) - return :fallback if type.to_sym == :share + return :fallback if [:share, :post].include?(type.to_sym) super end diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afb..0150cb677 100644 --- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC def assignment_policy_params params.require(:assignment_policy).permit( :name, :description, :assignment_order, :conversation_priority, - :fair_distribution_limit, :fair_distribution_window, :enabled + :fair_distribution_limit, :fair_distribution_window, :enabled, + :exclude_older_than_hours ) end end diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 156c031fa..a62ec115c 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -47,17 +47,15 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas end def permitted_captain_models - params.require(:captain_models).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys end def permitted_captain_features - params.require(:captain_features).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys + end + + def captain_feature_keys + Llm::Models.feature_keys.map(&:to_sym) end def features_with_account_preferences diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e..7bda1c802 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC end render_response( - dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) ) end diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb index 181e4965e..d7c49b35d 100644 --- a/app/controllers/api/v1/accounts/onboardings_controller.rb +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -1,17 +1,19 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController before_action :check_admin_authorization? + ONBOARDING_STEP_KEY = 'onboarding_step'.freeze + STEP_ACCOUNT_DETAILS = 'account_details'.freeze + STEP_INBOX_SETUP = 'inbox_setup'.freeze + ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze + def update + return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step]) + @account = Current.account - finalize = finalizing_account_details? - - @account.assign_attributes(account_params) - @account.custom_attributes.merge!(custom_attributes_params) - @account.custom_attributes.delete('onboarding_step') if finalize - @account.save! - - # TODO: re-enable when the help center generation UI is ready to surface progress - # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present? + # The client declares the step it is completing; `account_details` runs + # `complete_account_details`, and so on. The known-step guard above keeps the + # client value from `send`-ing an arbitrary method. + send("complete_#{params[:onboarding_step]}") render 'api/v1/accounts/update', format: :json end @@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll private - def finalizing_account_details? - @account.custom_attributes['onboarding_step'] == 'account_details' + def complete_account_details + # Only act while the cursor still points here, so a stale replay after + # onboarding finished can't re-enter it. + return unless current_step == STEP_ACCOUNT_DETAILS + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + + # inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded + # environment check); self-hosted finishes onboarding here. + if ChatwootApp.chatwoot_cloud? + move_to_step(STEP_INBOX_SETUP) + create_onboarding_inboxes + else + finish_onboarding + end end - def website - custom_attributes_params[:website] + def complete_inbox_setup + # Only finalize while the cursor still points here, so a stale or out-of-order + # request can't end onboarding early. Replays are no-ops. + return unless current_step == STEP_INBOX_SETUP + + finish_onboarding + end + + def current_step + @account.custom_attributes[ONBOARDING_STEP_KEY] + end + + def move_to_step(step) + @account.custom_attributes[ONBOARDING_STEP_KEY] = step + @account.save! + end + + def finish_onboarding + @account.custom_attributes.delete(ONBOARDING_STEP_KEY) + @account.save! + end + + def create_onboarding_inboxes + Onboarding::WebWidgetCreationService.new(@account, Current.user).perform end def account_params diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index e8688dcfb..6239e00eb 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController end def team_params - params.require(:team).permit(:name, :description, :allow_auto_assign) + params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color) end end diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3c..fde425b26 100644 --- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr response = dyte_processor_service.add_participant_to_meeting( @message.content_attributes['data']['meeting_id'], - @conversation.contact + @conversation.contact, + @message ) render_response(response) end diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb new file mode 100644 index 000000000..633071301 --- /dev/null +++ b/app/controllers/concerns/portal_home_data.rb @@ -0,0 +1,29 @@ +module PortalHomeData + extend ActiveSupport::Concern + + private + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index b6df015f7..a369830b6 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ class DashboardController < ActionController::Base include SwitchLocale + include PortalHomeData GLOBAL_CONFIG_KEYS = %w[ LOGO @@ -63,6 +64,10 @@ class DashboardController < ActionController::Base return unless @portal @locale = @portal.default_locale + if @portal.layout == 'documentation' + request.variant = :documentation + load_home_data + end render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 2991b84d2..323440304 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController end def switch_locale_with_portal(&) - @locale = validate_and_get_locale(params[:locale]) + # Keep @locale as the portal's own locale code (e.g. th_TH) for content queries, + # while UI translations fall back to an available I18n locale (e.g. th). + @locale = params[:locale] - I18n.with_locale(@locale, &) + I18n.with_locale(validate_and_get_locale(@locale), &) end def switch_locale_with_article(&) @@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - article_locale = if article.category.present? - article.category.locale - else - article.locale - end - @locale = validate_and_get_locale(article_locale) - I18n.with_locale(@locale, &) + @locale = if article.category.present? + article.category.locale + else + article.locale + end + I18n.with_locale(validate_and_get_locale(@locale), &) end def allow_iframe_requests diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 57db11aec..4982278d7 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,4 +1,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController + include PortalHomeData + before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal @@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end - - def load_home_data - base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) - @visible_categories = @portal.categories - .where(locale: @locale) - .joins(:articles).where(articles: { status: :published }) - .order(position: :asc) - .group('categories.id') - @popular_topics = @visible_categories.first(3) - @featured = base_articles.order_by_views.limit(6) - @category_contributors = build_category_contributors(@visible_categories) - end - - def build_category_contributors(categories) - category_ids = categories.map(&:id) - return {} if category_ids.empty? - - @portal.articles - .published - .where(locale: @locale, category_id: category_ids) - .includes(:author) - .group_by(&:category_id) - .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } - end end diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue index e6bd3c272..bca6e877b 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue @@ -20,6 +20,7 @@ const ICON_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', + [VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x', }; const COLOR_MAP = { @@ -28,13 +29,18 @@ const COLOR_MAP = { [VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11', [VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9', [VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9', + [VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9', }; const isOutbound = computed( () => props.direction === VOICE_CALL_DIRECTION.OUTBOUND ); const isFailed = computed(() => - [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status) + [ + VOICE_CALL_STATUS.NO_ANSWER, + VOICE_CALL_STATUS.FAILED, + VOICE_CALL_STATUS.REJECTED, + ].includes(props.status) ); const labelKey = computed(() => { diff --git a/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue b/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue index 2272d1b7b..1dfcc51c2 100644 --- a/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue +++ b/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue @@ -168,6 +168,7 @@ const selectEmoji = emoji => { `, +}; +const ComboBoxStub = { + props: ['modelValue', 'options'], + emits: ['update:modelValue'], + template: '
', +}; + +const PAGES = [ + { id: 'p1', name: 'Page One', access_token: 'pt1' }, + { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true }, +]; + +const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH'; +const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT'; + +let loginAndFetchPages; +let preloadSdk; + +const mountForm = () => + mount(InboxFacebookForm, { + global: { + stubs: { + NextButton: NextButtonStub, + ComboBox: ComboBoxStub, + Spinner: true, + }, + }, + }); + +const clickButton = (wrapper, label) => + wrapper + .findAll('button') + .find(button => button.text() === label) + .trigger('click'); + +beforeEach(() => { + vi.clearAllMocks(); + preloadSdk = vi.fn(); + loginAndFetchPages = vi.fn(); + useFacebookPageConnect.mockReturnValue({ + isAuthenticating: ref(false), + preloadSdk, + loginAndFetchPages, + }); + dispatch.mockResolvedValue({ id: 1 }); +}); + +describe('InboxFacebookForm', () => { + it('preloads the SDK on mount', () => { + mountForm(); + expect(preloadSdk).toHaveBeenCalled(); + }); + + it('lists only connectable pages and creates an inbox for the selected one', async () => { + loginAndFetchPages.mockResolvedValue({ + userAccessToken: 'tok', + pages: PAGES, + }); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + // p2 is already connected (exists), so only p1 is offered. + const combobox = wrapper.findComponent(ComboBoxStub); + expect(combobox.props('options')).toEqual([ + { value: 'p1', label: 'Page One' }, + ]); + + combobox.vm.$emit('update:modelValue', 'p1'); + await nextTick(); + + await clickButton(wrapper, CONNECT); + await flushPromises(); + + expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', { + user_access_token: 'tok', + page_access_token: 'pt1', + page_id: 'p1', + inbox_name: 'Page One', + }); + expect(wrapper.emitted('created')).toBeTruthy(); + }); + + it('shows the empty state when every page is already connected', async () => { + loginAndFetchPages.mockResolvedValue({ + userAccessToken: 'tok', + pages: [ + { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true }, + ], + }); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + expect(wrapper.text()).toContain( + 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES' + ); + expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false); + }); + + it('shows an error when the connection fails', async () => { + loginAndFetchPages.mockRejectedValue(new Error('boom')); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + expect(wrapper.text()).toContain( + 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR' + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('stays on the connect prompt without an error when cancelled', async () => { + loginAndFetchPages.mockResolvedValue(null); + const wrapper = mountForm(); + + await clickButton(wrapper, LAUNCH); + await flushPromises(); + await nextTick(); + + expect(wrapper.text()).not.toContain( + 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR' + ); + // Launch button is still available to retry. + expect( + wrapper.findAll('button').some(button => button.text() === LAUNCH) + ).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js new file mode 100644 index 000000000..acde1159d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js @@ -0,0 +1,62 @@ +import { + findConnectedInbox, + isChannelConnected, +} from '../../inbox-setup/channelMatchers'; + +const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' }; +const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' }; +const OUTLOOK = { + id: 3, + channel_type: 'Channel::Email', + provider: 'microsoft', +}; + +describe('channelMatchers', () => { + describe('findConnectedInbox', () => { + it('returns the inbox sharing the channel type', () => { + expect( + findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' }) + ).toBe(WHATSAPP); + }); + + it('matches email inboxes on provider', () => { + expect( + findConnectedInbox([OUTLOOK, GMAIL], { + channel_type: 'Channel::Email', + provider: 'google', + }) + ).toBe(GMAIL); + }); + + it('does not match a different email provider', () => { + expect( + findConnectedInbox([OUTLOOK], { + channel_type: 'Channel::Email', + provider: 'google', + }) + ).toBeUndefined(); + }); + + it('returns undefined when nothing matches', () => { + expect( + findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' }) + ).toBeUndefined(); + }); + }); + + describe('isChannelConnected', () => { + it('is true when a matching inbox exists', () => { + expect( + isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' }) + ).toBe(true); + }); + + it('is false when no inbox matches', () => { + expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false); + }); + + it('is false for a channel without an inbox stub', () => { + expect(isChannelConnected([WHATSAPP], undefined)).toBe(false); + }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js new file mode 100644 index 000000000..1134d4494 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js @@ -0,0 +1,300 @@ +import { defineComponent, h } from 'vue'; +import { createStore } from 'vuex'; +import { mount } from '@vue/test-utils'; +import { useRoute } from 'vue-router'; +import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels'; + +vi.mock('vue-router'); + +// Mounts the composable against a real store and the real useAccount (only +// useRoute and the underlying getters are faked), so a change to how useAccount +// resolves the current account is exercised here too. The real ./constants are +// used, so assertions validate against the actual channel identity (label keys, +// channel_type, social ordering) derived from CHANNEL_LIST. +const mountComposable = ({ brandInfo, inboxes = [] } = {}) => { + const store = createStore({ + modules: { + accounts: { + namespaced: true, + getters: { + getAccount: () => () => ({ + id: 1, + custom_attributes: { brand_info: brandInfo }, + }), + }, + }, + inboxes: { + namespaced: true, + getters: { getInboxes: () => inboxes }, + }, + }, + }); + + let result; + const Component = defineComponent({ + setup() { + result = useDetectedChannels(); + return () => h('div'); + }, + }); + mount(Component, { global: { plugins: [store] } }); + return result; +}; + +beforeEach(() => { + useRoute.mockReturnValue({ params: { accountId: '1' } }); + // Configure the installation OAuth credentials so detected channels aren't + // hidden by the config gate; individual tests clear this to assert hiding. + window.chatwootConfig = { + fbAppId: 'fb', + instagramAppId: 'ig', + tiktokAppId: 'tt', + whatsappAppId: 'wa', + whatsappConfigurationId: 'wa-config', + }; +}); + +afterEach(() => { + delete window.chatwootConfig; +}); + +describe('useDetectedChannels', () => { + describe('displayedChannels', () => { + it('maps detected socials with a url to channel rows', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' }, + { type: 'instagram', url: 'https://instagram.com/acme' }, + ], + }, + }); + + expect(displayedChannels.value).toEqual([ + { + type: 'whatsapp', + handle: '+14155552671', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', + inbox: { channel_type: 'Channel::Whatsapp' }, + }, + { + type: 'instagram', + handle: '@acme', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', + inbox: { channel_type: 'Channel::Instagram' }, + }, + ]); + }); + + it('skips socials without a url or with an unknown type', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'telegram' }, // no url + { type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown + { type: 'tiktok', url: 'https://tiktok.com/@acme' }, + ], + }, + }); + + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'tiktok', + ]); + }); + + it('uses the raw path for line and falls back to empty on a bad url', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'line', url: 'https://line.me/acme' }, + { type: 'facebook', url: 'not-a-url' }, + ], + }, + }); + + expect(displayedChannels.value).toEqual([ + { + type: 'line', + handle: 'acme', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', + inbox: { channel_type: 'Channel::Line' }, + }, + { + type: 'facebook', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', + inbox: { channel_type: 'Channel::FacebookPage' }, + }, + ]); + }); + + it('omits the detected email channel while email is disabled for this phase', () => { + const { displayedChannels } = mountComposable({ + brandInfo: { + email_provider: 'google', + email: 'support@acme.com', + socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }], + }, + }); + + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'whatsapp', + ]); + }); + + it('falls back to the default channel suggestions when nothing is detected', () => { + const { displayedChannels } = mountComposable({ brandInfo: undefined }); + + // The configured mainstream channels, with no detected handle. + expect(displayedChannels.value).toEqual([ + { + type: 'whatsapp', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', + inbox: { channel_type: 'Channel::Whatsapp' }, + }, + { + type: 'facebook', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', + inbox: { channel_type: 'Channel::FacebookPage' }, + }, + { + type: 'instagram', + handle: '', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', + inbox: { channel_type: 'Channel::Instagram' }, + }, + ]); + }); + + it('gates the default suggestions by installation config, keeping the list non-empty', () => { + window.chatwootConfig = {}; // no OAuth credentials configured + const { displayedChannels } = mountComposable({ brandInfo: undefined }); + + // Only the credential-free defaults survive (Telegram, LINE). + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'telegram', + 'line', + ]); + }); + + it('hides detected channels whose installation OAuth credentials are missing', () => { + window.chatwootConfig = {}; // nothing configured + const { displayedChannels } = mountComposable({ + brandInfo: { + socials: [ + { type: 'facebook', url: 'https://facebook.com/acme' }, + { type: 'line', url: 'https://line.me/acme' }, + ], + }, + }); + + // Facebook needs fbAppId (absent → hidden); LINE needs no install credential. + expect(displayedChannels.value.map(channel => channel.type)).toEqual([ + 'line', + ]); + }); + }); + + describe('remainingChannels', () => { + it('returns the platforms not already shown as default rows', () => { + // Nothing detected → displayed falls back to the defaults (WhatsApp, + // Facebook, Instagram), so the footer previews the remaining platforms. + const { remainingChannels } = mountComposable({ brandInfo: {} }); + + expect(remainingChannels.value).toEqual([ + { + type: 'line', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', + inbox: { channel_type: 'Channel::Line' }, + }, + { + type: 'telegram', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE', + inbox: { channel_type: 'Channel::Telegram' }, + }, + { + type: 'tiktok', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE', + inbox: { channel_type: 'Channel::Tiktok' }, + }, + ]); + }); + + it('excludes already-detected socials, preserving order', () => { + const { remainingChannels } = mountComposable({ + brandInfo: { + socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }], + }, + }); + + expect(remainingChannels.value.map(channel => channel.type)).toEqual([ + 'facebook', + 'line', + 'instagram', + ]); + }); + + it('excludes channels whose installation OAuth credentials are missing', () => { + window.chatwootConfig = {}; // nothing configured + const { remainingChannels } = mountComposable({ brandInfo: {} }); + + // The only configured channels (Telegram, LINE) are shown as default rows, + // and every other platform is gated out — so nothing remains for the footer. + expect(remainingChannels.value).toEqual([]); + }); + }); + + describe('connectedInbox', () => { + it('returns the real inbox sharing the channel type', () => { + const inbox = { + id: 1, + channel_type: 'Channel::Whatsapp', + name: 'WA Biz', + }; + const { connectedInbox } = mountComposable({ + brandInfo: {}, + inboxes: [inbox], + }); + + expect( + connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } }) + ).toBe(inbox); + }); + + it('matches email inboxes on provider', () => { + const gmail = { + id: 1, + channel_type: 'Channel::Email', + provider: 'google', + }; + const outlook = { + id: 2, + channel_type: 'Channel::Email', + provider: 'microsoft', + }; + const { connectedInbox } = mountComposable({ + brandInfo: {}, + inboxes: [outlook, gmail], + }); + + expect( + connectedInbox({ + inbox: { channel_type: 'Channel::Email', provider: 'google' }, + }) + ).toBe(gmail); + }); + + it('returns undefined when nothing matches', () => { + const { connectedInbox } = mountComposable({ + brandInfo: {}, + inboxes: [], + }); + + expect( + connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } }) + ).toBeUndefined(); + }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue index 034f40d35..54eafa59e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AutoResolve.vue @@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'; import { useAccount } from 'dashboard/composables/useAccount'; import { useAlert } from 'dashboard/composables'; import WithLabel from 'v3/components/Form/WithLabel.vue'; -import TextArea from 'next/textarea/TextArea.vue'; +import Editor from 'next/Editor/Editor.vue'; import Switch from 'next/switch/Switch.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import DurationInput from 'next/input/DurationInput.vue'; @@ -162,9 +162,13 @@ const toggleAutoResolve = async () => { :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')" :help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')" > -