From 62cbeae95f54673af25b453d2e61f82533448a1b Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 25 Jun 2026 14:54:07 +0530 Subject: [PATCH] feat: onboarding inboxes UI (#14565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After entering their account details, new admins land on an **Inbox setup** screen that shows what we've already set up for them and lets them connect their conversation channels without leaving onboarding. It surfaces the auto-created live chat widget (and Help Center on Enterprise), highlights channels detected from their website, and offers a **View all** dialog to connect any supported channel inline. ### Channel status | Channel | How it connects | Status | PR | |---|---|---|---| | Live chat (Website) | Auto-created during setup | ✅ Done | https://github.com/chatwoot/chatwoot/pull/14314 | | WhatsApp | Meta embedded signup | ✅ Done | https://github.com/chatwoot/chatwoot/pull/14619 | | Facebook | Login + page picker | ✅ Done | https://github.com/chatwoot/chatwoot/pull/14619 | | Instagram | OAuth redirect | ✅ Done | https://github.com/chatwoot/chatwoot/pull/14568 | | TikTok | OAuth redirect | ✅ Done | https://github.com/chatwoot/chatwoot/pull/14569 | | LINE | Inline credential form | ✅ Done | — | | Telegram | Inline credential form | ✅ Done | — | | Gmail / Outlook | OAuth (email) | ⚠️ Disabled — coming in a follow-up | https://github.com/chatwoot/chatwoot/pull/14567 | | SMS / API / Voice / Other email | — | ⛔ Unavailable | — | --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../api/v1/accounts/onboardings_controller.rb | 64 +++- .../helper/AnalyticsHelper/events.js | 3 + app/javascript/dashboard/helper/inbox.js | 18 ++ .../dashboard/i18n/locale/en/onboarding.json | 54 ++++ .../routes/dashboard/dashboard.routes.js | 9 + .../dashboard/onboarding/InboxSetup.vue | 158 +++++++++ .../routes/dashboard/onboarding/Index.vue | 166 +++------- .../OnboardingFormRow.vue | 0 .../OnboardingFormSelect.vue | 0 .../account-details/useAccountEnrichment.js | 136 ++++++++ .../onboarding/inbox-setup/ChannelRow.vue | 60 ++++ .../inbox-setup/CreationStatusRow.vue | 33 ++ .../inbox-setup/HelpCenterCreationStatus.vue | 116 +++++++ .../inbox-setup/InboxChannelForm.vue | 139 ++++++++ .../inbox-setup/InboxChannelsDialog.vue | 210 ++++++++++++ .../inbox-setup/InboxChannelsFooter.vue | 70 ++++ .../inbox-setup/InboxFacebookForm.vue | 157 +++++++++ .../inbox-setup/WebWidgetCreationStatus.vue | 44 +++ .../onboarding/inbox-setup/channelMatchers.js | 18 ++ .../onboarding/inbox-setup/constants.js | 149 +++++++++ .../inbox-setup/useChannelConfig.js | 29 ++ .../inbox-setup/useChannelConnect.js | 67 ++++ .../inbox-setup/useDetectedChannels.js | 130 ++++++++ .../{ => shared}/OnboardingLayout.vue | 39 ++- .../{ => shared}/OnboardingSection.vue | 7 +- .../onboarding/{ => shared}/constants.js | 0 .../useAccountEnrichment.spec.js | 186 +++++++++++ .../HelpCenterCreationStatus.spec.js | 123 +++++++ .../inbox-setup/InboxChannelsDialog.spec.js | 59 ++++ .../inbox-setup/InboxFacebookForm.spec.js | 158 +++++++++ .../specs/inbox-setup/channelMatchers.spec.js | 62 ++++ .../inbox-setup/useDetectedChannels.spec.js | 300 ++++++++++++++++++ app/javascript/dashboard/routes/index.js | 15 +- .../api/v1/accounts/onboardings_controller.rb | 21 ++ .../help_center_article_generation_job.rb | 10 + lib/tasks/onboarding.rake | 14 - .../accounts/onboardings_controller_spec.rb | 125 ++++++-- .../accounts/onboardings_controller_spec.rb | 25 ++ 38 files changed, 2796 insertions(+), 178 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/InboxSetup.vue rename app/javascript/dashboard/routes/dashboard/onboarding/{ => account-details}/OnboardingFormRow.vue (100%) rename app/javascript/dashboard/routes/dashboard/onboarding/{ => account-details}/OnboardingFormSelect.vue (100%) create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/account-details/useAccountEnrichment.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/ChannelRow.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js rename app/javascript/dashboard/routes/dashboard/onboarding/{ => shared}/OnboardingLayout.vue (81%) rename app/javascript/dashboard/routes/dashboard/onboarding/{ => shared}/OnboardingSection.vue (87%) rename app/javascript/dashboard/routes/dashboard/onboarding/{ => shared}/constants.js (100%) create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js delete mode 100644 lib/tasks/onboarding.rake 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/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 58d2821ef..9e1b932b0 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({ export const ONBOARDING_EVENTS = Object.freeze({ ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited', ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed', + INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited', + INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed', + INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped', }); diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index f47df9e5b..4100cfb32 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -13,6 +13,24 @@ export const INBOX_TYPES = { TIKTOK: 'Channel::Tiktok', }; +// Short channel-type slugs used to identify a channel without leaning on its +// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps. +export const CHANNEL_TYPES = { + WEBSITE: 'website', + WHATSAPP: 'whatsapp', + FACEBOOK: 'facebook', + INSTAGRAM: 'instagram', + TIKTOK: 'tiktok', + TELEGRAM: 'telegram', + LINE: 'line', + GMAIL: 'gmail', + OUTLOOK: 'outlook', + SMS: 'sms', + API: 'api', + VOICE: 'voice', + EMAIL: 'email', +}; + // Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp) export const VOICE_CALL_PROVIDERS = { TWILIO: 'twilio', diff --git a/app/javascript/dashboard/i18n/locale/en/onboarding.json b/app/javascript/dashboard/i18n/locale/en/onboarding.json index d7c960002..51d511091 100644 --- a/app/javascript/dashboard/i18n/locale/en/onboarding.json +++ b/app/javascript/dashboard/i18n/locale/en/onboarding.json @@ -30,5 +30,59 @@ "VALIDATION_ERROR": "Please fill in all required fields", "SUCCESS": "Details saved successfully", "ERROR": "Could not save details. Please try again." + }, + "ONBOARDING_INBOX_SETUP": { + "GREETING": "Let's set up a few things", + "SUBTITLE": "This will give you head-start in your workspace", + "CONTINUE": "Continue", + "SKIP": "Skip", + "ERROR": "Something went wrong. Please try again.", + "WHATSAPP_CONNECTED": "WhatsApp connected successfully", + "FACEBOOK_CONNECTED": "Facebook connected successfully", + "CREATED_FOR_YOU": { + "TITLE": "We've created the following for you", + "LIVE_CHAT": "Live Chat widget", + "LIVE_CHAT_DESCRIPTION": "Instant messenger for your website", + "LIVE_CHAT_STATUS": "Almost done…", + "LIVE_CHAT_READY": "Ready", + "HELP_CENTER": "Help Center", + "HELP_CENTER_DESCRIPTION": "Your digital encyclopedia", + "HELP_CENTER_GENERATING": "Creating your help center…", + "HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…", + "HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…", + "HELP_CENTER_CURATING_ARTICLES": "Curating articles…", + "HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles", + "HELP_CENTER_CATEGORIES": "{count} category | {count} categories", + "HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}" + }, + "CHANNELS": { + "TITLE": "Connect all your conversation channels", + "HEADER": "We found a few channels you can connect", + "CONNECT": "Connect", + "CONNECTED": "Connected", + "MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app", + "MORE_CHANNELS_EMAIL": "Email", + "MORE_CHANNELS_VOICE": "Voice", + "VIEW_ALL": "View all", + "GMAIL": "Gmail", + "OUTLOOK": "Outlook", + "OTHER_EMAIL": "Other Email Providers" + }, + "CHANNELS_DIALOG": { + "TITLE": "Connect all your channels instantly", + "SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.", + "NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.", + "CONNECT_TITLE": "Connect your {name} account", + "CONNECT_SUBTITLE": "Fill out these quick details", + "CONNECT": "Connect", + "BACK": "Back", + "SETUP_LATER": "Setup later in app", + "FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.", + "FACEBOOK_LAUNCH": "Continue with Facebook", + "FACEBOOK_SELECT_PAGE": "Select a Page to connect", + "FACEBOOK_LOADING": "Loading your Facebook Pages…", + "FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.", + "FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again." + } } } diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 87bae7d11..04d11c621 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue'; import Suspended from './suspended/Index.vue'; import NoAccounts from './noAccounts/Index.vue'; import OnboardingAccountDetails from './onboarding/Index.vue'; +import OnboardingInboxSetup from './onboarding/InboxSetup.vue'; export default { routes: [ @@ -40,6 +41,14 @@ export default { }, component: OnboardingAccountDetails, }, + { + path: frontendURL('accounts/:accountId/onboarding/inbox-setup'), + name: 'onboarding_inbox_setup', + meta: { + permissions: ['administrator', 'agent', 'custom_role'], + }, + component: OnboardingInboxSetup, + }, { path: frontendURL('accounts/:accountId/suspended'), name: 'account_suspended', diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/InboxSetup.vue b/app/javascript/dashboard/routes/dashboard/onboarding/InboxSetup.vue new file mode 100644 index 000000000..34dda961e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/InboxSetup.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue index 3a1d65bc6..7e4fbc84e 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue @@ -1,21 +1,22 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue new file mode 100644 index 000000000..c0157398e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue new file mode 100644 index 000000000..8c0e1decc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue @@ -0,0 +1,116 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue new file mode 100644 index 000000000..45f170137 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue @@ -0,0 +1,139 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue new file mode 100644 index 000000000..a3631649d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue @@ -0,0 +1,210 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue new file mode 100644 index 000000000..b5c138c65 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue @@ -0,0 +1,70 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue new file mode 100644 index 000000000..48be07821 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue @@ -0,0 +1,157 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue new file mode 100644 index 000000000..aeb40e24c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue @@ -0,0 +1,44 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js new file mode 100644 index 000000000..0ebdd728f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js @@ -0,0 +1,18 @@ +import { INBOX_TYPES } from 'dashboard/helper/inbox'; + +// A detected channel maps to a real inbox when they share a channel_type. Gmail +// and Outlook both use Channel::Email, so for email we also match on provider. +// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox). + +// Returns the matching inbox (not a boolean) so callers can show the connected +// account's real name rather than the detected handle. +export const findConnectedInbox = (inboxes, stub) => + inboxes.find( + inbox => + inbox.channel_type === stub?.channel_type && + (stub?.channel_type !== INBOX_TYPES.EMAIL || + inbox.provider === stub?.provider) + ); + +export const isChannelConnected = (inboxes, stub) => + Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub)); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js new file mode 100644 index 000000000..91e800d06 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js @@ -0,0 +1,149 @@ +import { CHANNEL_TYPES } from 'dashboard/helper/inbox'; + +// Channels whose connect flow opens the channels dialog preselected to their +// in-dialog step — Facebook (page picker) and the credential-form channels +// (Telegram, Line) — rather than redirecting through OAuth. +export const DIALOG_CHANNELS = [ + CHANNEL_TYPES.FACEBOOK, + CHANNEL_TYPES.TELEGRAM, + CHANNEL_TYPES.LINE, +]; + +// Suggested channels (in priority order) to offer as rows when nothing is +// detected, so the step isn't empty. The mainstream OAuth channels show on +// configured installs, while credential-free Telegram/LINE keep the list +// non-empty on a bare self-host. +export const DEFAULT_CHANNEL_TYPES = [ + CHANNEL_TYPES.WHATSAPP, + CHANNEL_TYPES.FACEBOOK, + CHANNEL_TYPES.INSTAGRAM, + CHANNEL_TYPES.TELEGRAM, + CHANNEL_TYPES.LINE, +]; + +// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped +// like a real inbox so ChannelIcon can resolve the icon from the shared provider. +// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one +// exists and falls back to the monochrome glyph otherwise, so no per-channel +// style flag is needed. Entries without a channel type (Voice, Other Email +// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an +// inline credential form; `setupLater: true` defers the channel to in-app setup +// for this phase. `labelKey` is an i18n key — most reuse the shared channel +// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the +// names translate without duplicating strings; resolve it with `t()` at display. +export const CHANNEL_LIST = [ + { + type: 'website', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE', + inbox: { channel_type: 'Channel::WebWidget' }, + }, + { + type: 'whatsapp', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE', + inbox: { channel_type: 'Channel::Whatsapp' }, + }, + { + type: 'instagram', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE', + inbox: { channel_type: 'Channel::Instagram' }, + }, + { + type: 'facebook', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE', + inbox: { channel_type: 'Channel::FacebookPage' }, + }, + { + type: 'tiktok', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE', + inbox: { channel_type: 'Channel::Tiktok' }, + }, + { + type: 'telegram', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE', + inbox: { channel_type: 'Channel::Telegram' }, + form: true, + }, + { + type: 'line', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE', + inbox: { channel_type: 'Channel::Line' }, + form: true, + }, + // Email channels (including Gmail/Outlook OAuth) are set up later in-app for + // this phase; they will be enabled in a future PR. + { + type: 'gmail', + labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL', + inbox: { channel_type: 'Channel::Email', provider: 'google' }, + setupLater: true, + }, + { + type: 'outlook', + labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK', + inbox: { channel_type: 'Channel::Email', provider: 'microsoft' }, + setupLater: true, + }, + { + type: 'sms', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE', + inbox: { channel_type: 'Channel::Sms' }, + setupLater: true, + }, + { + type: 'api', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE', + inbox: { channel_type: 'Channel::Api' }, + setupLater: true, + }, + { + type: 'voice', + labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE', + fallbackIcon: 'i-woot-voice', + setupLater: true, + }, + { + type: 'email', + labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL', + fallbackIcon: 'i-woot-mail', + setupLater: true, + }, +]; + +const channelByType = type => + CHANNEL_LIST.find(channel => channel.type === type); + +// Icons shown next to "View all" when every detected channel is already +// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs +// aren't duplicated. +export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map( + channelByType +); + +// Social channels that detected brand_info socials map to, keyed by social type +// in the order they're offered as rows. Derived from CHANNEL_LIST so channel +// identity (label, channel_type) has a single source. Keys mirror +// SocialLinkParser::SOCIAL_DOMAIN_MAP. +const SOCIAL_PLATFORM_TYPES = [ + 'whatsapp', + 'facebook', + 'line', + 'instagram', + 'telegram', + 'tiktok', +]; + +export const SOCIAL_PLATFORMS = Object.fromEntries( + SOCIAL_PLATFORM_TYPES.map(type => { + const { labelKey, inbox } = channelByType(type); + return [type, { labelKey, channelType: inbox.channel_type }]; + }) +); + +// Mailbox providers inferred from the signup domain's MX records, keyed by +// Channel::Email#provider. Derived from CHANNEL_LIST's email entries. +export const EMAIL_PROVIDERS = Object.fromEntries( + CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [ + channel.inbox.provider, + { labelKey: channel.labelKey }, + ]) +); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js new file mode 100644 index 000000000..ba2d6f0dc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js @@ -0,0 +1,29 @@ +import { useMapGetter } from 'dashboard/composables/store'; + +// OAuth/SDK channels need installation-level app credentials to be usable. When +// the credential is missing the channel is "not configured" and is hidden from +// onboarding entirely. Channels without an entry (Website, Telegram, Line, …) +// need no installation credential and are always considered configured. +// Mirrors the availability checks in ChannelItem.vue. +export function useChannelConfig() { + const globalConfig = useMapGetter('globalConfig/get'); + const installationConfig = window.chatwootConfig || {}; + + const CHANNEL_CONFIGURED = { + // WhatsApp is onboarded only via Meta embedded signup, which needs both the + // app id (not the 'none' sentinel) and the signup configuration id. + whatsapp: () => + Boolean(installationConfig.whatsappAppId) && + installationConfig.whatsappAppId !== 'none' && + Boolean(installationConfig.whatsappConfigurationId), + facebook: () => Boolean(installationConfig.fbAppId), + instagram: () => Boolean(installationConfig.instagramAppId), + tiktok: () => Boolean(installationConfig.tiktokAppId), + gmail: () => Boolean(installationConfig.googleOAuthClientId), + outlook: () => Boolean(globalConfig.value.azureAppId), + }; + + const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true; + + return { isConfigured }; +} diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js new file mode 100644 index 000000000..bd34d5f20 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js @@ -0,0 +1,67 @@ +import { useI18n } from 'vue-i18n'; +import { useAlert } from 'dashboard/composables'; +import { useStore } from 'dashboard/composables/store'; +import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup'; +import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; +import googleClient from 'dashboard/api/channel/googleClient'; +import microsoftClient from 'dashboard/api/channel/microsoftClient'; +import instagramClient from 'dashboard/api/channel/instagramClient'; +import tiktokClient from 'dashboard/api/channel/tiktokClient'; + +// Channels that complete via an OAuth redirect. Email channels are keyed by their +// Channel::Email provider, others by channel type. The request is tagged with a +// return hint so the callback brings the user back to onboarding instead of the +// inbox settings page. +const OAUTH_CLIENTS = { + google: googleClient, + microsoft: microsoftClient, + instagram: instagramClient, + tiktok: tiktokClient, +}; + +export function useChannelConnect() { + const { t } = useI18n(); + const store = useStore(); + const { runEmbeddedSignup } = useWhatsappEmbeddedSignup(); + + const connectViaOAuth = async provider => { + const client = OAUTH_CLIENTS[provider]; + if (!client) return; + + try { + const { + data: { url }, + } = await client.generateAuthorization({ return_to: 'onboarding' }); + window.location.href = url; + } catch { + useAlert(t('ONBOARDING_INBOX_SETUP.ERROR')); + } + }; + + // WhatsApp connects via Meta's embedded-signup popup instead of the redirect + // OAuth flow above. Collect the signup credentials, exchange them for an + // inbox, and surface the result inline — then refetch so the connected state + // reflects the freshly created inbox (and renders its real channel icon). + const connectWhatsapp = async () => { + let credentials; + try { + credentials = await runEmbeddedSignup(); + } catch { + useAlert(t('ONBOARDING_INBOX_SETUP.ERROR')); + return; + } + if (!credentials) return; // user dismissed the popup + + try { + await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials); + await store.dispatch('inboxes/get'); + useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED')); + } catch (error) { + useAlert( + parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR') + ); + } + }; + + return { connectViaOAuth, connectWhatsapp }; +} diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js new file mode 100644 index 000000000..6ba68c6bf --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js @@ -0,0 +1,130 @@ +import { computed } from 'vue'; +import { useMapGetter } from 'dashboard/composables/store'; +import { useAccount } from 'dashboard/composables/useAccount'; +import { + SOCIAL_PLATFORMS, + EMAIL_PROVIDERS, + DEFAULT_CHANNEL_TYPES, +} from './constants'; +import { findConnectedInbox } from './channelMatchers'; +import { useChannelConfig } from './useChannelConfig'; + +// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES +// is config-gated like everything else, then sliced to this limit. +const DISPLAYED_CHANNEL_LIMIT = 3; + +// Pull the handle/username out of a detected social URL, formatted per channel. +const extractHandle = ({ type, url }) => { + try { + const { pathname } = new URL(url); + const path = pathname.replace(/^\/+|\/+$/g, ''); + if (type === 'whatsapp') { + const digits = path.replace(/\D/g, ''); + return digits ? `+${digits}` : ''; + } + if (type === 'line') return path; + return path.startsWith('@') ? path : `@${path}`; + } catch { + return ''; + } +}; + +// Derives the channel rows for the inbox-setup step from the account's detected +// brand_info (socials + mailbox provider) and the real connected inboxes, +// keeping InboxSetup.vue focused on layout, connect routing, and completion. +export function useDetectedChannels() { + const { currentAccount } = useAccount(); + const inboxes = useMapGetter('inboxes/getInboxes'); + const { isConfigured } = useChannelConfig(); + + const brandSocials = computed( + () => currentAccount.value?.custom_attributes?.brand_info?.socials || [] + ); + + const connectedChannels = computed(() => + brandSocials.value + .filter(social => SOCIAL_PLATFORMS[social.type] && social.url) + .map(social => ({ + type: social.type, + handle: extractHandle(social), + labelKey: SOCIAL_PLATFORMS[social.type].labelKey, + inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType }, + })) + ); + + const detectedEmailChannel = computed(() => { + const brandInfo = currentAccount.value?.custom_attributes?.brand_info; + const provider = brandInfo?.email_provider; + if (!EMAIL_PROVIDERS[provider]) return null; + + return { + type: 'email', + handle: brandInfo?.email || '', + labelKey: EMAIL_PROVIDERS[provider].labelKey, + inbox: { channel_type: 'Channel::Email', provider }, + }; + }); + + // The real inbox backing a channel, if one exists — returned (not just a + // boolean) so the row can show the connected account's real name. + const connectedInbox = channel => + findConnectedInbox(inboxes.value, channel.inbox); + + // A channel row built from a social type, with no detected handle — used for + // the default suggestions when nothing was detected. + const toChannelRow = type => ({ + type, + handle: '', + labelKey: SOCIAL_PLATFORMS[type].labelKey, + inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType }, + }); + + const detectedChannels = computed(() => + [detectedEmailChannel.value, ...connectedChannels.value] + .filter(Boolean) + // Email channels (including Gmail/Outlook OAuth) are disabled for this + // phase; they will be enabled in a future PR. + .filter(channel => channel.type !== 'email') + // Hide channels whose installation OAuth credentials are missing — their + // connect flow would only error. + .filter(channel => isConfigured(channel.type)) + ); + + const defaultChannels = computed(() => + DEFAULT_CHANNEL_TYPES.filter(isConfigured) + .slice(0, DISPLAYED_CHANNEL_LIMIT) + .map(toChannelRow) + ); + + // Show the detected channels, or fall back to the default suggestions so the + // step is never an empty list. + const displayedChannels = computed(() => + detectedChannels.value.length + ? detectedChannels.value + : defaultChannels.value + ); + + const remainingChannels = computed(() => { + // Exclude whatever is already shown as a row (detected or defaulted) so the + // footer preview doesn't duplicate it. + const shownTypes = new Set(displayedChannels.value.map(c => c.type)); + return Object.entries(SOCIAL_PLATFORMS) + .filter(([type]) => !shownTypes.has(type)) + .filter(([type]) => isConfigured(type)) + .slice(0, 3) + .map(([type, { labelKey, channelType }]) => ({ + type, + labelKey, + inbox: { channel_type: channelType }, + })); + }); + + const hasDetectedChannels = computed(() => detectedChannels.value.length > 0); + + return { + displayedChannels, + remainingChannels, + connectedInbox, + hasDetectedChannels, + }; +} diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue similarity index 81% rename from app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue index 63b3fa391..90abff58f 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue +++ b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue @@ -5,11 +5,12 @@ defineProps({ greeting: { type: String, required: true }, subtitle: { type: String, default: '' }, continueLabel: { type: String, default: 'Continue' }, + skipLabel: { type: String, default: '' }, isLoading: { type: Boolean, default: false }, disabled: { type: Boolean, default: false }, }); -defineEmits(['continue']); +defineEmits(['continue', 'skip']); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js similarity index 100% rename from app/javascript/dashboard/routes/dashboard/onboarding/constants.js rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js new file mode 100644 index 000000000..a8d85010a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js @@ -0,0 +1,186 @@ +import { defineComponent, h, ref } from 'vue'; +import { createStore } from 'vuex'; +import { mount } from '@vue/test-utils'; +import { useRoute } from 'vue-router'; +import { useAccountEnrichment } from '../../account-details/useAccountEnrichment'; + +vi.mock('vue-router'); + +const ENABLED_LANGUAGES = [ + { iso_639_1_code: 'en', name: 'English' }, + { iso_639_1_code: 'fr', name: 'French' }, +]; + +// Mounts the composable against a real store and the real useAccount/useConfig +// (only useRoute and the underlying account getter / window config are faked), +// so a change to how those resolve their data is exercised here too. `presets` +// seeds form fields as if the user had already typed them. +const mountComposable = ({ + account = {}, + enabledLanguages = ENABLED_LANGUAGES, + presets = {}, +} = {}) => { + window.chatwootConfig = { enabledLanguages }; + + const store = createStore({ + modules: { + accounts: { + namespaced: true, + getters: { getAccount: () => () => account }, + }, + }, + }); + + const fields = { + locale: ref(presets.locale || ''), + website: ref(presets.website || ''), + timezone: ref(presets.timezone || ''), + companySize: ref(presets.companySize || ''), + industry: ref(presets.industry || ''), + referralSource: ref(presets.referralSource || ''), + }; + + let api; + const Component = defineComponent({ + setup() { + api = useAccountEnrichment(fields); + return () => h('div'); + }, + }); + const wrapper = mount(Component, { global: { plugins: [store] } }); + return { ...api, fields, wrapper }; +}; + +beforeEach(() => { + useRoute.mockReturnValue({ params: { accountId: '1' } }); +}); + +afterEach(() => { + delete window.chatwootConfig; +}); + +describe('useAccountEnrichment', () => { + describe('populateFormFields', () => { + it('fills empty fields from the enriched attributes on mount', () => { + const { fields } = mountComposable({ + account: { + locale: 'en', + custom_attributes: { + website: 'https://acme.com', + timezone: 'America/New_York', + company_size: '11-50', + industry: 'Technology', + referral_source: 'google', + }, + }, + }); + + expect(fields.website.value).toBe('https://acme.com'); + expect(fields.timezone.value).toBe('America/New_York'); + expect(fields.companySize.value).toBe('11-50'); + expect(fields.industry.value).toBe('Technology'); + expect(fields.referralSource.value).toBe('google'); + }); + + it('falls back to brand_info for website and industry', () => { + const { fields } = mountComposable({ + account: { + custom_attributes: { + brand_info: { + domain: 'acme.com', + industries: [{ industry: 'Retail & E-commerce' }], + }, + }, + }, + }); + + expect(fields.website.value).toBe('acme.com'); + expect(fields.industry.value).toBe('Retail & E-commerce'); + }); + + it('does not clobber fields the user already set', () => { + const { fields } = mountComposable({ + presets: { website: 'mysite.com', industry: 'Finance' }, + account: { + custom_attributes: { + website: 'https://enriched.com', + industry: 'Technology', + }, + }, + }); + + expect(fields.website.value).toBe('mysite.com'); + expect(fields.industry.value).toBe('Finance'); + }); + + it('detects the locale from the browser, else the account locale', () => { + // jsdom reports navigator.language as 'en-US' -> base 'en' is enabled. + const { fields } = mountComposable({ account: { locale: 'de' } }); + expect(fields.locale.value).toBe('en'); + + // No enabled language matches the browser -> fall back to account locale. + const { fields: other } = mountComposable({ + account: { locale: 'de' }, + enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }], + }); + expect(other.locale.value).toBe('de'); + }); + }); + + describe('isEnriching', () => { + it('is true while the account is on the enrichment step', () => { + const { isEnriching } = mountComposable({ + account: { custom_attributes: { onboarding_step: 'enrichment' } }, + }); + expect(isEnriching.value).toBe(true); + }); + + it('is false on any other step', () => { + const { isEnriching } = mountComposable({ + account: { custom_attributes: { onboarding_step: 'account_details' } }, + }); + expect(isEnriching.value).toBe(false); + }); + + it('times out after 30s, flipping to false and populating', () => { + vi.useFakeTimers(); + try { + const { isEnriching, fields } = mountComposable({ + account: { + custom_attributes: { + onboarding_step: 'enrichment', + company_size: '51-200', + }, + }, + }); + expect(isEnriching.value).toBe(true); + + vi.advanceTimersByTime(30000); + + expect(isEnriching.value).toBe(false); + expect(fields.companySize.value).toBe('51-200'); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('getChangedFields', () => { + it('lists only enrichable fields edited after auto-fill', () => { + const { fields, getChangedFields } = mountComposable({ + account: { + custom_attributes: { + website: 'https://acme.com', + company_size: '11-50', + industry: 'Technology', + }, + }, + }); + + expect(getChangedFields()).toEqual([]); + + fields.industry.value = 'Finance'; + expect(getChangedFields()).toEqual(['industry']); + }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js new file mode 100644 index 000000000..68dca058a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js @@ -0,0 +1,123 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue'; +import OnboardingAPI from 'dashboard/api/onboarding'; + +vi.mock('dashboard/api/onboarding', () => ({ + default: { + getHelpCenterGeneration: vi.fn(), + }, +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params = {}) => { + if (key.endsWith('HELP_CENTER_CATEGORIES')) { + return `${params.count} categories`; + } + if (key.endsWith('HELP_CENTER_SUMMARY')) { + return `${params.count} articles across ${params.categories}`; + } + if (key.endsWith('HELP_CENTER_ARTICLES')) { + return `${params.count} articles`; + } + return key; + }, + }), +})); + +const mountStatus = () => + mount(HelpCenterCreationStatus, { + global: { + stubs: { + CreationStatusRow: { + props: ['ready', 'title', 'description', 'status'], + template: + '
{{ status }}
', + }, + }, + }, + }); + +describe('HelpCenterCreationStatus', () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('renders completed summary from the status endpoint', async () => { + OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({ + data: { + generation_id: 'generation-123', + state: { status: 'completed' }, + articles_count: 3, + categories_count: 2, + }, + }); + + const wrapper = mountStatus(); + await flushPromises(); + + expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe( + 'true' + ); + expect(wrapper.find('[data-test="row"]').text()).toBe( + '3 articles across 2 categories' + ); + }); + + it('hides the row when generation is skipped', async () => { + OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({ + data: { + generation_id: 'generation-123', + state: { status: 'skipped' }, + }, + }); + + const wrapper = mountStatus(); + await flushPromises(); + + expect(wrapper.find('[data-test="row"]').exists()).toBe(false); + }); + + it('polls while generating and stops after completion', async () => { + vi.useFakeTimers(); + OnboardingAPI.getHelpCenterGeneration + .mockResolvedValueOnce({ + data: { + generation_id: 'generation-123', + state: { status: 'generating' }, + articles_count: 1, + categories_count: 0, + }, + }) + .mockResolvedValueOnce({ + data: { + generation_id: 'generation-123', + state: { status: 'completed' }, + articles_count: 2, + categories_count: 1, + }, + }); + + const wrapper = mountStatus(); + await flushPromises(); + + expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles'); + + vi.advanceTimersByTime(5000); + await flushPromises(); + + expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2); + expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe( + 'true' + ); + expect(wrapper.find('[data-test="row"]').text()).toBe( + '2 articles across 1 categories' + ); + + vi.advanceTimersByTime(5000); + await flushPromises(); + + expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js new file mode 100644 index 000000000..bf150f95d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js @@ -0,0 +1,59 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue'; + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) })); +vi.mock('dashboard/composables/store', () => ({ + useMapGetter: () => ({ value: {} }), +})); +vi.mock('../../inbox-setup/useChannelConnect', () => ({ + useChannelConnect: () => ({ + connectViaOAuth: vi.fn(), + connectWhatsapp: vi.fn(), + }), +})); + +const mountDialog = () => + mount(InboxChannelsDialog, { + props: { inboxes: [] }, + global: { + stubs: { + Dialog: { + template: '
', + methods: { open() {}, close() {} }, + }, + InboxFacebookForm: { template: '
' }, + InboxChannelForm: { template: '
' }, + ChannelIcon: true, + Icon: true, + }, + }, + }); + +describe('InboxChannelsDialog Facebook gating', () => { + afterEach(() => { + delete window.chatwootConfig; + }); + + it('opens the Facebook page picker when fbAppId is configured', async () => { + window.chatwootConfig = { fbAppId: 'fb-app' }; + const wrapper = mountDialog(); + + wrapper.vm.open('facebook'); + await nextTick(); + + expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true); + }); + + it('shows the grid (not the picker) when fbAppId is missing', async () => { + window.chatwootConfig = {}; + const wrapper = mountDialog(); + + wrapper.vm.open('facebook'); + await nextTick(); + + expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false); + // The channel grid renders its cards instead. + expect(wrapper.find('button').exists()).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js new file mode 100644 index 000000000..888082f68 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js @@ -0,0 +1,158 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { ref, nextTick } from 'vue'; +import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue'; +import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect'; + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) })); +vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() })); +vi.mock('dashboard/store/utils/api', () => ({ + parseAPIErrorResponse: vi.fn(), +})); +vi.mock('dashboard/composables/useFacebookPageConnect', () => ({ + useFacebookPageConnect: vi.fn(), +})); + +const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() })); +vi.mock('dashboard/composables/store', () => ({ + useStore: () => ({ dispatch }), +})); + +const NextButtonStub = { + props: ['label', 'disabled', 'isLoading'], + emits: ['click'], + template: ``, +}; +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/index.js b/app/javascript/dashboard/routes/index.js index 3fd2aa0e0..c82029801 100644 --- a/app/javascript/dashboard/routes/index.js +++ b/app/javascript/dashboard/routes/index.js @@ -7,9 +7,12 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import AnalyticsHelper from '../helper/AnalyticsHelper'; -const ONBOARDING_STEPS = ['account_details', 'enrichment']; +const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup']; const routes = [...dashboard.routes]; +const onboardingPath = step => + step === 'inbox_setup' ? 'onboarding/inbox-setup' : 'onboarding'; + export const router = createRouter({ history: createWebHistory(), routes }); export const validateAuthenticateRoutePermission = async (to, next) => { @@ -39,12 +42,18 @@ export const validateAuthenticateRoutePermission = async (to, next) => { isActive; if (to.name === 'no_accounts' || !to.name) { - const target = needsOnboarding ? 'onboarding' : 'dashboard'; + const target = needsOnboarding + ? onboardingPath(userAccount?.onboarding_step) + : 'dashboard'; return next(frontendURL(`accounts/${routeAccountId}/${target}`)); } if (needsOnboarding && !isOnOnboardingView(to)) { - return next(frontendURL(`accounts/${routeAccountId}/onboarding`)); + return next( + frontendURL( + `accounts/${routeAccountId}/${onboardingPath(userAccount?.onboarding_step)}` + ) + ); } if (!needsOnboarding && isOnOnboardingView(to)) { return next(frontendURL(`accounts/${routeAccountId}/dashboard`)); diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb index 1311bc3fc..1b2639d39 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb @@ -6,6 +6,27 @@ module Enterprise::Api::V1::Accounts::OnboardingsController private + def create_onboarding_inboxes + super + create_help_center + end + + def complete_inbox_setup + # Drop the onboarding-only generation pointer; the OSS method's save! persists both deletions. + @account.custom_attributes.delete('help_center_generation_id') + super + end + + def create_help_center + return if website.blank? + + Onboarding::HelpCenterCreationService.new(@account, Current.user).perform + end + + def website + custom_attributes_params[:website] + end + def help_center_generation_status generation_id = help_center_generation_id return super if generation_id.blank? diff --git a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb index b5f7eb247..fb231f85f 100644 --- a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb +++ b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb @@ -20,6 +20,16 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob rescue Onboarding::HelpCenterErrors::CurationSkipped => e Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}" skip_generation(generation_id: generation_id, reason: e.message) + rescue Firecrawl::FirecrawlError + # Must propagate untouched: retry_on handles it, and recording a skipped + # state here would make the retries no-op via the state guard above. + raise + rescue StandardError => e + # Any other failure is terminal (missing LLM config, code bug). Record a + # skipped state so the onboarding status row stops polling instead of + # showing "generating" forever, then re-raise for error tracking. + skip_generation(generation_id: generation_id, reason: "#{e.class}: #{e.message}") + raise end private diff --git a/lib/tasks/onboarding.rake b/lib/tasks/onboarding.rake deleted file mode 100644 index d61a77cc3..000000000 --- a/lib/tasks/onboarding.rake +++ /dev/null @@ -1,14 +0,0 @@ -namespace :onboarding do - desc 'Reset onboarding for an account (triggers the onboarding flow again). Usage: rake onboarding:reset[account_id]' - task :reset, [:account_id] => :environment do |_task, args| - abort 'Error: Please provide an account ID' if args[:account_id].blank? - - account = Account.find_by(id: args[:account_id]) - abort "Error: Account with ID '#{args[:account_id]}' not found" unless account - - account.custom_attributes['onboarding_step'] = 'account_details' - account.save! - - puts "Onboarding has been reset for account '#{account.name}' (ID: #{account.id})" - end -end diff --git a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb index 6f118624b..6c2b48805 100644 --- a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb @@ -40,7 +40,7 @@ RSpec.describe 'Onboarding API', type: :request do it 'saves name and locale' do patch "/api/v1/accounts/#{account.id}/onboarding", - params: { name: 'Acme Inc', locale: 'fr' }, + params: { name: 'Acme Inc', locale: 'fr', onboarding_step: 'account_details' }, headers: admin.create_new_auth_token, as: :json expect(response).to have_http_status(:success) @@ -50,7 +50,7 @@ RSpec.describe 'Onboarding API', type: :request do it 'merges custom_attributes' do patch "/api/v1/accounts/#{account.id}/onboarding", - params: { website: 'acme.com', industry: 'tech', company_size: '10-50' }, + params: { website: 'acme.com', industry: 'tech', company_size: '10-50', onboarding_step: 'account_details' }, headers: admin.create_new_auth_token, as: :json attrs = account.reload.custom_attributes @@ -59,47 +59,121 @@ RSpec.describe 'Onboarding API', type: :request do expect(attrs['company_size']).to eq('10-50') end + context 'when on cloud (inbox setup is a cloud-only step)' do + before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) } + + it 'advances onboarding_step to inbox_setup' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com', onboarding_step: 'account_details' }, + headers: admin.create_new_auth_token, as: :json + + expect(account.reload.custom_attributes['onboarding_step']).to eq('inbox_setup') + end + + it 'does not create a help center portal when website is blank' do + expect do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { name: 'Acme Inc', onboarding_step: 'account_details' }, + headers: admin.create_new_auth_token, as: :json + end.not_to change(account.portals, :count) + end + + it 'is idempotent when the account_details completion is replayed' do + 2.times do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com', onboarding_step: 'account_details' }, + headers: admin.create_new_auth_token, as: :json + end + + # Replaying step 1 always lands on inbox_setup; it never skips to done. + expect(account.reload.custom_attributes['onboarding_step']).to eq('inbox_setup') + end + end + + context 'when off cloud (inbox setup is skipped)' do + before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) } + + it 'finishes onboarding instead of advancing to inbox_setup' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com', onboarding_step: 'account_details' }, + headers: admin.create_new_auth_token, as: :json + + expect(account.reload.custom_attributes).not_to have_key('onboarding_step') + end + + it 'does not auto-create onboarding inboxes' do + expect(Onboarding::WebWidgetCreationService).not_to receive(:new) + + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com', onboarding_step: 'account_details' }, + headers: admin.create_new_auth_token, as: :json + end + end + end + + context 'when replaying account_details after onboarding has finished' do + before { account.update!(custom_attributes: { 'website' => 'acme.com' }) } + + it 'does not re-enter onboarding or persist the stale payload' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'stale.com', onboarding_step: 'account_details' }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + expect(account.reload.custom_attributes).not_to have_key('onboarding_step') + expect(account.custom_attributes['website']).to eq('acme.com') + end + end + + context 'when finalizing inbox_setup' do + before { account.update!(custom_attributes: { 'onboarding_step' => 'inbox_setup' }) } + it 'clears onboarding_step' do patch "/api/v1/accounts/#{account.id}/onboarding", - params: { website: 'acme.com' }, + params: { onboarding_step: 'inbox_setup' }, headers: admin.create_new_auth_token, as: :json expect(account.reload.custom_attributes).not_to have_key('onboarding_step') end - it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do - service = instance_double(Onboarding::HelpCenterCreationService, perform: nil) - allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service) + it 'does not create another web widget inbox' do + expect(Onboarding::WebWidgetCreationService).not_to receive(:new) patch "/api/v1/accounts/#{account.id}/onboarding", - params: { website: 'acme.com' }, + params: { onboarding_step: 'inbox_setup' }, headers: admin.create_new_auth_token, as: :json - - expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user| - expect(arg_account.id).to eq(account.id) - expect(arg_user.id).to eq(admin.id) - end - expect(service).to have_received(:perform) end - it 'does not create a help center portal when website is blank' do - expect do + it 'is idempotent when the finalize request is replayed' do + 2.times do patch "/api/v1/accounts/#{account.id}/onboarding", - params: { name: 'Acme Inc' }, + params: { onboarding_step: 'inbox_setup' }, headers: admin.create_new_auth_token, as: :json - end.not_to change(account.portals, :count) + end + + expect(account.reload.custom_attributes).not_to have_key('onboarding_step') end end - context 'when onboarding_step is not account_details' do + context 'when the declared onboarding_step is missing or unknown' do before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) } - it 'does not clear onboarding_step' do + it 'rejects a request without an onboarding_step and changes nothing' do patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, headers: admin.create_new_auth_token, as: :json + expect(response).to have_http_status(:unprocessable_entity) expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team') + expect(account.custom_attributes['website']).to be_nil + end + + it 'rejects an unknown onboarding_step' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { onboarding_step: 'invite_team' }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) end it 'does not create a help center portal' do @@ -110,6 +184,19 @@ RSpec.describe 'Onboarding API', type: :request do end.not_to change(account.portals, :count) end end + + context 'when completing inbox_setup out of order' do + before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) } + + it 'does not clear onboarding_step while the account is still on account_details' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { onboarding_step: 'inbox_setup' }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + expect(account.reload.custom_attributes['onboarding_step']).to eq('account_details') + end + end end describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do diff --git a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb index 59d0564fa..5b7279eb3 100644 --- a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb @@ -4,6 +4,31 @@ RSpec.describe 'Enterprise Onboarding API', type: :request do let(:account) { create(:account, domain: 'example.com') } let(:admin) { create(:user, account: account, role: :administrator) } + describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do + context 'when finalizing account_details' do + # Inbox/help-center setup is a cloud-only step; off cloud the flow finishes at account_details. + before do + account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + end + + it 'invokes HelpCenterCreationService when website is present' do + service = instance_double(Onboarding::HelpCenterCreationService, perform: nil) + allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service) + + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com', onboarding_step: 'account_details' }, + headers: admin.create_new_auth_token, as: :json + + expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user| + expect(arg_account.id).to eq(account.id) + expect(arg_user.id).to eq(admin.id) + end + expect(service).to have_received(:perform) + end + end + end + describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do context 'when help center generation is in progress' do let(:generation_id) { 'generation-123' }