feat(whatsapp): convert inbox between WhatsApp providers (#268)

* feat(whatsapp): allow converting inbox between WhatsApp providers

Adds a Convert flow to switch a WhatsApp inbox between the four
supported providers (default/360dialog, whatsapp_cloud, baileys, zapi)
without losing conversations, agents, or history.

- Channel::Whatsapp#convert_provider! runs inside a transaction:
  disconnects the old provider, clears provider_connection and
  message_templates, assigns the new provider/config, and triggers
  webhook setup plus template resync on the new service.
- New POST /api/v1/accounts/:id/inboxes/:id/convert_provider endpoint
  guarded by InboxPolicy#convert_provider? (admin only).
- UI adds a Convert button on the inbox Settings page with a
  type-to-confirm ConvertInboxModal that lists the effects before
  redirecting to a dedicated route reusing the WhatsApp provider
  wizard in convert mode (phone number locked, current provider
  hidden from the picker).

* chore(whatsapp): polish convert UI colors and expand specs

- Settings: use slate for the Convert trigger and ruby for the modal
  confirm to mirror the delete gate instead of the less conventional
  amber variant.
- Drop the redundant "current provider is hidden from the list"
  sentence from the convert wizard description.
- Add specs for the post-conversion webhook setup path (triggered and
  skipped branches) and the sync_templates error-rescue behaviour.

* fix: address CodeRabbit review on convert-provider flow

- Whitelist provider_config keys in the convert endpoint via permit
  rather than permit!, and default to an empty hash when omitted so
  the request no longer crashes.
- Pre-validate the new provider config before disconnecting the old
  session so a bad target config no longer terminates the existing
  provider; also keep the disconnect bound to the old provider_url.
- Guard ConvertInboxModal's submit handler so pressing Enter cannot
  bypass the type-to-confirm gate, and migrate it to <script setup>.
- Reject invalid ?provider= query values in convert mode so hidden
  providers (Twilio, the current provider) cannot be reached via URL.
- Await the inbox fetch in InboxConvert before running the route guard
  so directly opening the route for a non-WhatsApp inbox redirects.
- Remove the unreachable second CloudWhatsapp branch in Whatsapp.vue.

* fix: address second CodeRabbit round on convert-provider flow

- Unify provider picker validation so create mode also rejects
  unknown ?provider= values, with a single helper that accepts
  available providers plus the whatsapp_manual fallback.
- Simplify the pre-validation rollback in convert_provider!: the
  errors snapshot/merge dance was redundant because assign_attributes
  does not clear errors.
- Follow the repo convention of asserting on error.class.name so the
  rollback spec stays stable under reloading/parallel environments.
- Strengthen the controller success spec with provider_connection and
  message_templates cleanup invariants, and set Content-Type on the
  templates stub so HTTParty parses the empty data array correctly.

* fix: address third CodeRabbit round on convert-provider flow

- Add 360Dialog entry to the Whatsapp provider catalog, keep it hidden
  from the create picker (preserving the existing fork behavior) but
  expose it in the convert picker where it is a valid target. Restore
  URL reachability for ?provider=360dialog in create mode.
- Scope the WHATSAPP_MANUAL allowance to create mode only: the manual
  fallback flow is not reachable in convert mode.
- Redirect to the inboxes list in InboxConvert when the inbox is still
  absent after the store fetch, so the page no longer stays blank.
- Use an explicit allowlist of WhatsApp providers to gate the Convert
  button instead of negating Twilio, so adding a new WhatsApp channel
  type will not silently expose the flow.
- Bind the disabled provider display field with :value instead of
  v-model, since the underlying computed is getter-only.
- Add Content-Type: application/json to the templates stub in the
  model spec so HTTParty parses the empty data array.

* fix: address fourth CodeRabbit round on convert-provider flow

- Reject no-op conversions that target the same provider as the one
  already configured, so the endpoint no longer wipes provider
  connection and message templates on a request that changes nothing.
- Call the provider service's disconnect directly so failures abort
  the conversion instead of being silently swallowed; otherwise the
  old external session could remain live while the inbox flips to
  the new provider.
- Cover both behaviors with specs.

* fix: address fifth CodeRabbit round on convert-provider flow

- Reset the Vuelidate state when closing ConvertInboxModal so reopening
  the gate does not surface stale validation errors.
- Call teardown_webhooks before converting away from whatsapp_cloud so
  the Meta webhook subscription is removed for embedded_signup channels,
  mirroring the destroy-time cleanup (manual-setup channels keep the
  existing no-op behavior). Swallow teardown failures so a flaky Meta
  call does not abort the swap.
- Switch the rollback specs to compare message_templates counts instead
  of the boolean be_present matcher so they remain meaningful if the
  fixture happens to have an empty templates list.

* fix: address sixth CodeRabbit round on convert-provider flow

- Derive the convert header's current-provider label from the shared
  PROVIDER_CATALOG so the picker and header stay in sync.
- Assert the full Cloud provider_config payload and the absence of the
  Baileys-only provider_url key on both the controller success spec
  and the model atomic-swap spec.
- In the sync-error spec, reload and assert that the record was
  actually flipped to the new provider before the sync rescue fires,
  so the test can't pass on a pre-save failure.

* test: pin 422 error payload on convert_provider negative paths

The unsupported-conversion and invalid-config specs only checked the
status code, so they would have stayed green if the 422 started coming
from a different branch. Pin the response body so each example actually
covers the failure case it names.

* fix(baileys): save custom host as provider_url, not url

The Baileys form was writing the custom endpoint to
provider_config['url'] while the backend reads
provider_config['provider_url']. That silently broke the custom-host
feature for newly created or converted Baileys inboxes: they always
fell back to BAILEYS_PROVIDER_DEFAULT_URL. Align the key on both ends.

* fix(whatsapp): skip second validation pass in convert_provider!

The transaction's save! was re-running validate_provider_config after
the old provider's session had already been disconnected, so a transient
Graph API failure on the second check could roll back the swap while
leaving the external session terminated — the exact inconsistency the
pre-flight valid? was meant to rule out.

Capture the validated provider_config snapshot after valid? (so fields
populated by before_validation callbacks like webhook_verify_token are
preserved) and switch the final persist to save!(validate: false) so the
earlier check stays authoritative.

* fix: normalize provider-conversion failures and pass accountId

- The convert_provider action only rescued ActiveRecord::RecordInvalid,
  so disconnect/teardown failures bubbled up as 500 with no stable
  payload. Catch StandardError, log the class + message, and return a
  422 with a generic user-facing message so the dashboard can surface
  the error consistently.
- Nested settings routes live under /accounts/:accountId, so the
  router push from Settings.vue must include accountId alongside
  inboxId. Mirrors how sibling pages navigate to settings_inbox_show.

* fix: report missing :provider as 400 and sync modal v-model

- The generic rescue StandardError on convert_provider was masking
  ActionController::ParameterMissing behind a misleading
  provider-conversion error message. Catch it explicitly before the
  generic rescue and return 400 with the parameter-missing message.
- ConvertInboxModal's closeModal now drives localShow to false so
  parents using v-model:show stay in sync on every close path,
  not only when the explicit onClose listener flips the flag.

* fix(whatsapp): serialize concurrent convert_provider calls with_lock

Without a per-record lock, two admin requests against the same inbox
could both pass the pre-flight validation, race the disconnect/save,
and then run setup_webhooks/sync_templates in arbitrary order, leaving
the persisted provider out of sync with the external configuration.

Wrap the whole convert flow in with_lock so the loser blocks until the
winner commits; the subsequent no-op guard then rejects a second
conversion request targeting the provider the first one just set.

* test: harden convert_provider policy + controller failure specs

- Pass accountId explicitly in InboxConvert redirects so the route
  navigation mirrors how Settings.vue reaches settings_inbox_convert.
- Add a spec that assigns the agent to the inbox and still expects 401,
  so a future regression in InboxPolicy#convert_provider? can no longer
  slip past on the show policy alone.
- Add a spec that stubs convert_provider! to raise StandardError and
  asserts the controller's generic-failure 422 payload, pinning the
  dashboard contract for provider-side failures.

* test: pin convert_provider success response payload

Parse the rendered body and assert provider + provider_config so the
spec catches regressions where the DB is updated correctly but the
serialized response drifts (dashboard store commits response.data).

* fix(whatsapp): reset teardown guard after pre-conversion webhook cleanup

teardown_webhooks memoizes @webhook_teardown_initiated = true to prevent
double execution during destroy. Calling it from convert_provider!
leaves that flag set, so a subsequent destroy! or follow-up conversion
on the same instance would skip webhook removal silently. Reset the
flag in an ensure block so the destroy-time guard stays scoped to
destroy only.

* fix: include accountId in post-conversion redirect params

* test: pin same-provider convert returns 422

* fix(whatsapp): reset template columns when post-conversion sync fails

* fix(convert): enforce provider allowlist in InboxConvert route guard

* test: broaden Cloud templates stub to match account-scoped path

* test(whatsapp): cover cloud to baileys conversion branch
This commit is contained in:
Gabriel Jablonski 2026-04-18 20:57:27 -03:00 committed by GitHub
parent adc0d892e0
commit e032fc7774
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1153 additions and 86 deletions

View File

@ -98,6 +98,28 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
channel.update_provider_connection!(connection: 'close') if channel.respond_to?(:update_provider_connection!)
end
def convert_provider
channel = @inbox.channel
unless channel.respond_to?(:convert_provider!)
render json: { error: 'Channel does not support provider conversion' }, status: :unprocessable_entity and return
end
new_provider = params.require(:provider)
new_provider_config = (params.permit(provider_config: {})[:provider_config] || {}).to_h
channel.convert_provider!(new_provider: new_provider, new_provider_config: new_provider_config)
render :show
rescue ActionController::ParameterMissing => e
render json: { message: e.message }, status: :bad_request
rescue ActiveRecord::RecordInvalid => e
render json: { message: e.record.errors.full_messages.join(', ') }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Provider conversion failed for inbox #{@inbox.id}: #{e.class}: #{e.message}"
render json: { message: 'Provider conversion failed. Please check your credentials and the previous provider session, then try again.' },
status: :unprocessable_entity
end
def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }

View File

@ -72,6 +72,13 @@ class Inboxes extends CacheEnabledApiClient {
disconnectChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/disconnect_channel_provider`);
}
convertProvider(inboxId, { provider, providerConfig }) {
return axios.post(`${this.url}/${inboxId}/convert_provider`, {
provider,
provider_config: providerConfig,
});
}
}
export default new Inboxes();

View File

@ -0,0 +1,108 @@
<script setup>
import { computed, ref } from 'vue';
import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Modal from '../../Modal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: { type: Boolean, default: false },
inboxName: { type: String, required: true },
currentProvider: { type: String, default: '' },
});
const emit = defineEmits(['onClose', 'onConfirm', 'update:show']);
const value = ref('');
const validations = {
value: {
required,
isEqual(input) {
return (input || '').trim() === (props.inboxName || '').trim();
},
},
};
const v$ = useVuelidate(validations, { value });
const localShow = computed({
get: () => props.show,
set: next => emit('update:show', next),
});
const closeModal = () => {
value.value = '';
v$.value.$reset();
localShow.value = false;
emit('onClose');
};
const onConfirm = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
emit('onConfirm');
};
</script>
<template>
<Modal v-model:show="localShow" :on-close="closeModal">
<woot-modal-header :header-title="$t('INBOX_MGMT.CONVERT.CONFIRM.TITLE')" />
<div class="px-8 pt-4">
<p class="text-sm leading-5 text-n-slate-11">
{{
$t('INBOX_MGMT.CONVERT.CONFIRM.INTRO', {
inboxName: inboxName,
currentProvider: currentProvider,
})
}}
</p>
<ul
class="pl-5 mt-4 space-y-1 text-sm list-disc leading-5 text-n-slate-11"
>
<li>
{{
$t('INBOX_MGMT.CONVERT.CONFIRM.EFFECT_DISCONNECT', {
currentProvider: currentProvider,
})
}}
</li>
<li>{{ $t('INBOX_MGMT.CONVERT.CONFIRM.EFFECT_TEMPLATES') }}</li>
<li>{{ $t('INBOX_MGMT.CONVERT.CONFIRM.EFFECT_CONNECTION') }}</li>
<li>{{ $t('INBOX_MGMT.CONVERT.CONFIRM.EFFECT_PRESERVED') }}</li>
<li>{{ $t('INBOX_MGMT.CONVERT.CONFIRM.EFFECT_IDENTITY') }}</li>
</ul>
<p class="mt-4 text-sm leading-5 text-n-slate-11">
{{ $t('INBOX_MGMT.CONVERT.CONFIRM.CONFIRM_PROMPT') }}
</p>
</div>
<form class="px-8 pb-6" @submit.prevent="onConfirm">
<woot-input
v-model="value"
type="text"
:class="{ error: v$.value.$error }"
:placeholder="
$t('INBOX_MGMT.CONVERT.CONFIRM.PLACE_HOLDER', {
inboxName: inboxName,
})
"
@blur="v$.value.$touch"
/>
<div class="flex items-center justify-end gap-2">
<NextButton
faded
slate
type="reset"
:label="$t('INBOX_MGMT.CONVERT.CONFIRM.CANCEL')"
@click.prevent="closeModal"
/>
<NextButton
ruby
type="submit"
:label="$t('INBOX_MGMT.CONVERT.CONFIRM.CONTINUE')"
:disabled="v$.value.$invalid"
/>
</div>
</form>
</Modal>
</template>

View File

@ -248,6 +248,7 @@
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog",
"360_DIALOG_DESC": "Connect via 360Dialog credentials",
"BAILEYS": "Baileys",
"BAILEYS_DESC": "Connect via non-official API Baileys",
"ZAPI": "Z-API",
@ -682,6 +683,29 @@
"AVATAR_ERROR_MESSAGE": "Could not delete the inbox avatar. Please try again later."
}
},
"CONVERT": {
"BUTTON": "Convert",
"CONFIRM": {
"TITLE": "Convert inbox provider",
"INTRO": "You are about to change the WhatsApp provider for '{inboxName}' (currently {currentProvider}). Before continuing, review what will happen:",
"EFFECT_DISCONNECT": "The current session with {currentProvider} will be disconnected.",
"EFFECT_TEMPLATES": "Message templates will be cleared and must be resynced on the new provider.",
"EFFECT_CONNECTION": "Provider connection state will be reset.",
"EFFECT_PRESERVED": "Conversations, messages, contacts, agent assignments and history are preserved.",
"EFFECT_IDENTITY": "Inbox name and phone number stay the same.",
"CONFIRM_PROMPT": "To confirm, type the inbox name below:",
"PLACE_HOLDER": "Type {inboxName} to continue",
"CONTINUE": "Continue to convert",
"CANCEL": "Cancel"
},
"SELECT_PROVIDER_TITLE": "Select the new provider",
"SELECT_PROVIDER_DESCRIPTION": "Converting '{inboxName}' from {currentProvider}.",
"SUBMIT_BUTTON": "Convert inbox",
"API": {
"SUCCESS_MESSAGE": "Inbox successfully converted",
"ERROR_MESSAGE": "Could not convert inbox. Please check the credentials and try again."
}
},
"TABS": {
"SETTINGS": "Settings",
"COLLABORATORS": "Collaborators",

View File

@ -248,6 +248,7 @@
"WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta",
"TWILIO_DESC": "Conectar através de credenciais Twilio",
"360_DIALOG": "360Dialog",
"360_DIALOG_DESC": "Conectar através de credenciais 360Dialog",
"BAILEYS": "Baileys",
"BAILEYS_DESC": "Conectar via API não-oficial Baileys",
"ZAPI": "Z-API",
@ -682,6 +683,29 @@
"AVATAR_ERROR_MESSAGE": "Não foi possível excluir o perfil da caixa de entrada. Por favor, tente novamente mais tarde."
}
},
"CONVERT": {
"BUTTON": "Converter",
"CONFIRM": {
"TITLE": "Converter provedor da caixa de entrada",
"INTRO": "Você está prestes a trocar o provedor de WhatsApp de '{inboxName}' (atualmente {currentProvider}). Antes de continuar, revise o que vai acontecer:",
"EFFECT_DISCONNECT": "A sessão atual com {currentProvider} será desconectada.",
"EFFECT_TEMPLATES": "Modelos de mensagem serão limpos e precisarão ser re-sincronizados no novo provedor.",
"EFFECT_CONNECTION": "O estado de conexão do provedor será resetado.",
"EFFECT_PRESERVED": "Conversas, mensagens, contatos, atribuições de agentes e histórico são preservados.",
"EFFECT_IDENTITY": "O nome da caixa de entrada e o número de telefone permanecem os mesmos.",
"CONFIRM_PROMPT": "Para confirmar, digite o nome da caixa de entrada abaixo:",
"PLACE_HOLDER": "Digite {inboxName} para continuar",
"CONTINUE": "Continuar conversão",
"CANCEL": "Cancelar"
},
"SELECT_PROVIDER_TITLE": "Selecione o novo provedor",
"SELECT_PROVIDER_DESCRIPTION": "Convertendo '{inboxName}' de {currentProvider}.",
"SUBMIT_BUTTON": "Converter caixa de entrada",
"API": {
"SUCCESS_MESSAGE": "Caixa de entrada convertida com sucesso",
"ERROR_MESSAGE": "Não foi possível converter a caixa de entrada. Verifique as credenciais e tente novamente."
}
},
"TABS": {
"SETTINGS": "Configurações",
"COLLABORATORS": "Agentes",

View File

@ -0,0 +1,61 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import Whatsapp from './channels/Whatsapp.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
// Mirrors Settings.vue's `isConvertibleWhatsAppChannel` so a direct visit to
// /convert cannot bypass the provider allowlist exposed by the Convert button.
const CONVERTIBLE_WHATSAPP_PROVIDERS = [
'whatsapp_cloud',
'default',
'baileys',
'zapi',
];
const route = useRoute();
const router = useRouter();
const store = useStore();
const inboxId = computed(() => Number(route.params.inboxId));
const inbox = computed(() => store.getters['inboxes/getInbox'](inboxId.value));
const redirectBackIfInvalid = () => {
if (!inbox.value?.id) {
// Inbox not found even after the store fetch: bounce to the inboxes list
// rather than leaving the page blank waiting for a payload that is not
// coming.
router.replace({
name: 'settings_inbox_list',
params: { accountId: route.params.accountId },
});
return;
}
const isConvertible =
inbox.value.channel_type === INBOX_TYPES.WHATSAPP &&
CONVERTIBLE_WHATSAPP_PROVIDERS.includes(inbox.value.provider);
if (!isConvertible) {
router.replace({
name: 'settings_inbox_show',
params: {
accountId: route.params.accountId,
inboxId: inboxId.value,
},
});
}
};
onMounted(async () => {
if (!inbox.value?.id) {
await store.dispatch('inboxes/get');
}
redirectBackIfInvalid();
});
</script>
<template>
<div class="w-full h-full overflow-auto">
<Whatsapp v-if="inbox?.id" mode="convert" :inbox="inbox" />
</div>
</template>

View File

@ -30,6 +30,7 @@ import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SpinnerLoader from 'dashboard/components-next/spinner/Spinner.vue';
import ConvertInboxModal from 'dashboard/components/widgets/modal/ConvertInboxModal.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@ -61,6 +62,7 @@ export default {
GoogleReauthorize,
NextButton,
SpinnerLoader,
ConvertInboxModal,
InstagramReauthorize,
TiktokReauthorize,
WhatsappReauthorize,
@ -106,6 +108,7 @@ export default {
widgetBubblePosition: 'right',
widgetBubbleType: 'standard',
widgetBubbleLauncherTitle: '',
showConvertGate: false,
};
},
computed: {
@ -139,6 +142,14 @@ export default {
}
return '';
},
isConvertibleWhatsAppChannel() {
return (
this.isAWhatsAppCloudChannel ||
this.isAWhatsAppBaileysChannel ||
this.isAWhatsAppZapiChannel ||
this.is360DialogWhatsAppChannel
);
},
tabs() {
let visibleToAllChannelTabs = [
{
@ -599,6 +610,22 @@ export default {
toggleLockToSingleConversation(value) {
this.locktoSingleConversation = value;
},
openConvertGate() {
this.showConvertGate = true;
},
closeConvertGate() {
this.showConvertGate = false;
},
goToConvert() {
this.showConvertGate = false;
this.$router.push({
name: 'settings_inbox_convert',
params: {
accountId: this.$route.params.accountId,
inboxId: this.inbox.id,
},
});
},
},
validations: {
webhookUrl: {
@ -786,12 +813,21 @@ export default {
v-if="isAWhatsAppChannel"
:label="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL')"
>
<input
v-model="whatsAppAPIProviderName"
type="text"
disabled
class="!mb-0"
/>
<div class="flex items-center gap-2 w-full">
<input
:value="whatsAppAPIProviderName"
type="text"
disabled
class="!mb-0 flex-1"
/>
<NextButton
v-if="isConvertibleWhatsAppChannel"
slate
sm
:label="$t('INBOX_MGMT.CONVERT.BUTTON')"
@click="openConvertGate"
/>
</div>
</SettingsFieldSection>
<SettingsFieldSection
@ -1244,5 +1280,13 @@ export default {
</div>
</div>
</section>
<ConvertInboxModal
v-if="showConvertGate"
v-model:show="showConvertGate"
:inbox-name="inbox.name"
:current-provider="whatsAppAPIProviderName"
@on-confirm="goToConvert"
@on-close="closeConvertGate"
/>
</div>
</template>

View File

@ -12,18 +12,38 @@ export default {
components: {
NextButton,
},
props: {
mode: {
type: String,
default: 'create',
validator: value => ['create', 'convert'].includes(value),
},
inbox: {
type: Object,
default: null,
},
},
setup() {
return { v$: useVuelidate() };
},
data() {
const isConvert = this.mode === 'convert';
return {
inboxName: '',
phoneNumber: '',
inboxName: isConvert ? this.inbox?.name || '' : '',
phoneNumber: isConvert ? this.inbox?.phone_number || '' : '',
apiKey: '',
};
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
isConvertMode() {
return this.mode === 'convert';
},
submitButtonLabel() {
return this.isConvertMode
? this.$t('INBOX_MGMT.CONVERT.SUBMIT_BUTTON')
: this.$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON');
},
},
validations: {
inboxName: { required },
@ -38,6 +58,24 @@ export default {
}
try {
if (this.isConvertMode) {
await this.$store.dispatch('inboxes/convertProvider', {
inboxId: this.inbox.id,
provider: 'default',
providerConfig: { api_key: this.apiKey },
});
useAlert(this.$t('INBOX_MGMT.CONVERT.API.SUCCESS_MESSAGE'));
router.replace({
name: 'settings_inbox_show',
params: {
accountId: router.currentRoute.value.params.accountId,
inboxId: this.inbox.id,
},
});
return;
}
const whatsappChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
@ -61,7 +99,12 @@ export default {
});
} catch (error) {
useAlert(
error.message || this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
error.message ||
this.$t(
this.isConvertMode
? 'INBOX_MGMT.CONVERT.API.ERROR_MESSAGE'
: 'INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'
)
);
}
},
@ -77,6 +120,7 @@ export default {
<input
v-model="inboxName"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="v$.inboxName.$touch"
/>
@ -92,6 +136,7 @@ export default {
<input
v-model="phoneNumber"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
@ -121,8 +166,8 @@ export default {
<div class="w-full">
<NextButton
type="submit"
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
:is-loading="uiFlags.isCreating"
:label="submitButtonLabel"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
/>
</div>
</form>

View File

@ -12,12 +12,28 @@ import { isValidURL } from '../../../../../helper/URLHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
const props = defineProps({
mode: {
type: String,
default: 'create',
validator: value => ['create', 'convert'].includes(value),
},
inbox: {
type: Object,
default: null,
},
});
const isConvertMode = computed(() => props.mode === 'convert');
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const inboxName = ref('');
const phoneNumber = ref('');
const inboxName = ref(isConvertMode.value ? props.inbox?.name || '' : '');
const phoneNumber = ref(
isConvertMode.value ? props.inbox?.phone_number || '' : ''
);
const apiKey = ref('');
const providerUrl = ref('');
const showAdvancedOptions = ref(false);
@ -43,6 +59,20 @@ const v$ = useVuelidate(rules, {
apiKey,
});
const buildProviderConfig = () => {
const providerConfig = {
mark_as_read: markAsRead.value,
presence_subscribe: presenceSubscribe.value,
};
if (apiKey.value || providerUrl.value) {
providerConfig.api_key = apiKey.value;
providerConfig.provider_url = providerUrl.value;
}
return providerConfig;
};
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
@ -50,14 +80,22 @@ const createChannel = async () => {
}
try {
const providerConfig = {
mark_as_read: markAsRead.value,
presence_subscribe: presenceSubscribe.value,
};
if (isConvertMode.value) {
await store.dispatch('inboxes/convertProvider', {
inboxId: props.inbox.id,
provider: 'baileys',
providerConfig: buildProviderConfig(),
});
if (apiKey.value || providerUrl.value) {
providerConfig.api_key = apiKey.value;
providerConfig.url = providerUrl.value;
useAlert(t('INBOX_MGMT.CONVERT.API.SUCCESS_MESSAGE'));
router.replace({
name: 'settings_inbox_show',
params: {
accountId: router.currentRoute.value.params.accountId,
inboxId: props.inbox.id,
},
});
return;
}
const whatsappChannel = await store.dispatch('inboxes/createChannel', {
@ -66,7 +104,7 @@ const createChannel = async () => {
type: 'whatsapp',
phone_number: phoneNumber.value,
provider: 'baileys',
provider_config: providerConfig,
provider_config: buildProviderConfig(),
},
});
@ -78,7 +116,14 @@ const createChannel = async () => {
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
useAlert(
error.message ||
t(
isConvertMode.value
? 'INBOX_MGMT.CONVERT.API.ERROR_MESSAGE'
: 'INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'
)
);
}
};
@ -95,6 +140,7 @@ const setShowAdvancedOptions = () => {
<input
v-model="inboxName"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="v$.inboxName.$touch"
/>
@ -110,6 +156,7 @@ const setShowAdvancedOptions = () => {
<input
v-model="phoneNumber"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
@ -186,11 +233,15 @@ const setShowAdvancedOptions = () => {
<div class="w-full">
<NextButton
:is-loading="uiFlags.isCreating"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
:label="
isConvertMode
? $t('INBOX_MGMT.CONVERT.SUBMIT_BUTTON')
: $t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')
"
/>
</div>
</form>

View File

@ -12,13 +12,25 @@ export default {
components: {
NextButton,
},
props: {
mode: {
type: String,
default: 'create',
validator: value => ['create', 'convert'].includes(value),
},
inbox: {
type: Object,
default: null,
},
},
setup() {
return { v$: useVuelidate() };
},
data() {
const isConvert = this.mode === 'convert';
return {
inboxName: '',
phoneNumber: '',
inboxName: isConvert ? this.inbox?.name || '' : '',
phoneNumber: isConvert ? this.inbox?.phone_number || '' : '',
apiKey: '',
phoneNumberId: '',
businessAccountId: '',
@ -28,6 +40,14 @@ export default {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
isConvertMode() {
return this.mode === 'convert';
},
submitButtonLabel() {
return this.isConvertMode
? this.$t('INBOX_MGMT.CONVERT.SUBMIT_BUTTON')
: this.$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON');
},
},
validations: {
inboxName: { required },
@ -37,6 +57,13 @@ export default {
businessAccountId: { required, isNumber },
},
methods: {
buildProviderConfig() {
return {
api_key: this.apiKey,
phone_number_id: this.phoneNumberId,
business_account_id: this.businessAccountId,
};
},
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) {
@ -44,6 +71,24 @@ export default {
}
try {
if (this.isConvertMode) {
await this.$store.dispatch('inboxes/convertProvider', {
inboxId: this.inbox.id,
provider: 'whatsapp_cloud',
providerConfig: this.buildProviderConfig(),
});
useAlert(this.$t('INBOX_MGMT.CONVERT.API.SUCCESS_MESSAGE'));
router.replace({
name: 'settings_inbox_show',
params: {
accountId: router.currentRoute.value.params.accountId,
inboxId: this.inbox.id,
},
});
return;
}
const whatsappChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
@ -52,11 +97,7 @@ export default {
type: 'whatsapp',
phone_number: this.phoneNumber,
provider: 'whatsapp_cloud',
provider_config: {
api_key: this.apiKey,
phone_number_id: this.phoneNumberId,
business_account_id: this.businessAccountId,
},
provider_config: this.buildProviderConfig(),
},
}
);
@ -70,7 +111,12 @@ export default {
});
} catch (error) {
useAlert(
error.message || this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
error.message ||
this.$t(
this.isConvertMode
? 'INBOX_MGMT.CONVERT.API.ERROR_MESSAGE'
: 'INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'
)
);
}
},
@ -86,6 +132,7 @@ export default {
<input
v-model="inboxName"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="v$.inboxName.$touch"
/>
@ -101,6 +148,7 @@ export default {
<input
v-model="phoneNumber"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
@ -167,11 +215,11 @@ export default {
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
:label="submitButtonLabel"
/>
</div>
</form>

View File

@ -11,6 +11,20 @@ import BaileysWhatsapp from './BaileysWhatsapp.vue';
import ZapiWhatsapp from './ZapiWhatsapp.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const props = defineProps({
mode: {
type: String,
default: 'create',
validator: value => ['create', 'convert'].includes(value),
},
inbox: {
type: Object,
default: null,
},
});
const isConvertMode = computed(() => props.mode === 'convert');
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
@ -35,41 +49,100 @@ const hasWhatsappAppId = computed(() => {
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
const INBOX_PROVIDER_TO_KEY = {
whatsapp_cloud: PROVIDER_TYPES.WHATSAPP,
default: PROVIDER_TYPES.THREE_SIXTY_DIALOG,
baileys: PROVIDER_TYPES.BAILEYS,
zapi: PROVIDER_TYPES.ZAPI,
};
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const currentProviderKey = computed(() => {
if (!props.inbox?.provider) return null;
return INBOX_PROVIDER_TO_KEY[props.inbox.provider] || null;
});
const PROVIDER_CATALOG = computed(() => [
{
key: PROVIDER_TYPES.WHATSAPP,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: 'i-woot-whatsapp',
},
{
key: PROVIDER_TYPES.TWILIO,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: 'i-woot-twilio',
},
{
key: PROVIDER_TYPES.BAILEYS,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS_DESC'),
icon: 'i-woot-baileys',
},
{
key: PROVIDER_TYPES.ZAPI,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI_DESC'),
icon: 'i-woot-zapi',
},
{
key: PROVIDER_TYPES.THREE_SIXTY_DIALOG,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.360_DIALOG'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.360_DIALOG_DESC'),
icon: 'i-woot-whatsapp',
},
]);
// Keys shown in the picker. 360Dialog is intentionally hidden in create mode
// (URL-reachable only) but offered in convert mode where it is a valid target.
const CREATE_PICKER_KEYS = [
PROVIDER_TYPES.WHATSAPP,
PROVIDER_TYPES.TWILIO,
PROVIDER_TYPES.BAILEYS,
PROVIDER_TYPES.ZAPI,
];
const CONVERT_PICKER_KEYS = [
PROVIDER_TYPES.WHATSAPP,
PROVIDER_TYPES.BAILEYS,
PROVIDER_TYPES.ZAPI,
PROVIDER_TYPES.THREE_SIXTY_DIALOG,
];
const availableProviders = computed(() => {
const providers = [
{
key: PROVIDER_TYPES.WHATSAPP,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: 'i-woot-whatsapp',
},
{
key: PROVIDER_TYPES.TWILIO,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: 'i-woot-twilio',
},
{
key: PROVIDER_TYPES.BAILEYS,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS_DESC'),
icon: 'i-woot-baileys',
},
{
key: PROVIDER_TYPES.ZAPI,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI_DESC'),
icon: 'i-woot-zapi',
},
];
return providers;
const allowed = isConvertMode.value
? CONVERT_PICKER_KEYS
: CREATE_PICKER_KEYS;
return PROVIDER_CATALOG.value
.filter(p => allowed.includes(p.key))
.filter(p => !isConvertMode.value || p.key !== currentProviderKey.value);
});
const currentProviderLabel = computed(() => {
if (!isConvertMode.value || !currentProviderKey.value) return '';
return (
PROVIDER_CATALOG.value.find(({ key }) => key === currentProviderKey.value)
?.title || ''
);
});
const isValidSelectedProvider = computed(() => {
if (!selectedProvider.value) return false;
// In create mode, allow the embedded-signup manual fallback link and the
// legacy-URL path to 360Dialog even though neither is in the picker.
if (!isConvertMode.value) {
if (selectedProvider.value === PROVIDER_TYPES.WHATSAPP_MANUAL) return true;
if (selectedProvider.value === PROVIDER_TYPES.THREE_SIXTY_DIALOG)
return true;
}
return availableProviders.value.some(
({ key }) => key === selectedProvider.value
);
});
const showProviderSelection = computed(() => !isValidSelectedProvider.value);
const showConfiguration = computed(() => isValidSelectedProvider.value);
const selectProvider = providerValue => {
router.push({
name: route.name,
@ -81,7 +154,8 @@ const selectProvider = providerValue => {
const shouldShowCloudWhatsapp = provider => {
return (
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
(provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
(provider === PROVIDER_TYPES.WHATSAPP &&
(!hasWhatsappAppId.value || isConvertMode.value))
);
};
@ -95,10 +169,21 @@ const handleManualLinkClick = () => {
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
{{
isConvertMode
? $t('INBOX_MGMT.CONVERT.SELECT_PROVIDER_TITLE')
: $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE')
}}
</h1>
<p class="text-sm leading-relaxed text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
{{
isConvertMode
? $t('INBOX_MGMT.CONVERT.SELECT_PROVIDER_DESCRIPTION', {
inboxName: inbox?.name,
currentProvider: currentProviderLabel,
})
: $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION')
}}
</p>
</div>
@ -113,7 +198,7 @@ const handleManualLinkClick = () => {
/>
</div>
<div class="mt-6 relative overflow-visible">
<div v-if="!isConvertMode" class="mt-6 relative overflow-visible">
<img
src="~dashboard/assets/images/curved-arrow.svg"
alt=""
@ -142,7 +227,9 @@ const handleManualLinkClick = () => {
<!-- Show embedded signup if app ID is configured -->
<div
v-if="
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
!isConvertMode &&
hasWhatsappAppId &&
selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
@ -172,7 +259,11 @@ const handleManualLinkClick = () => {
</div>
<!-- Show manual setup -->
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<CloudWhatsapp
v-else-if="shouldShowCloudWhatsapp(selectedProvider)"
:mode="mode"
:inbox="inbox"
/>
<!-- Other providers -->
<Twilio
@ -181,14 +272,19 @@ const handleManualLinkClick = () => {
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.WHATSAPP"
:mode="mode"
:inbox="inbox"
/>
<BaileysWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.BAILEYS"
:mode="mode"
:inbox="inbox"
/>
<ZapiWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.ZAPI"
:mode="mode"
:inbox="inbox"
/>
<ZapiWhatsapp v-else-if="selectedProvider === PROVIDER_TYPES.ZAPI" />
</div>
</div>
</div>

View File

@ -11,12 +11,28 @@ import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const props = defineProps({
mode: {
type: String,
default: 'create',
validator: value => ['create', 'convert'].includes(value),
},
inbox: {
type: Object,
default: null,
},
});
const isConvertMode = computed(() => props.mode === 'convert');
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const inboxName = ref('');
const phoneNumber = ref('');
const inboxName = ref(isConvertMode.value ? props.inbox?.name || '' : '');
const phoneNumber = ref(
isConvertMode.value ? props.inbox?.phone_number || '' : ''
);
const instanceId = ref('');
const token = ref('');
const clientToken = ref('');
@ -43,6 +59,12 @@ const v$ = useVuelidate(rules, {
clientToken,
});
const buildProviderConfig = () => ({
instance_id: instanceId.value,
token: token.value,
client_token: clientToken.value,
});
const createChannel = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
@ -50,17 +72,31 @@ const createChannel = async () => {
}
try {
if (isConvertMode.value) {
await store.dispatch('inboxes/convertProvider', {
inboxId: props.inbox.id,
provider: 'zapi',
providerConfig: buildProviderConfig(),
});
useAlert(t('INBOX_MGMT.CONVERT.API.SUCCESS_MESSAGE'));
router.replace({
name: 'settings_inbox_show',
params: {
accountId: router.currentRoute.value.params.accountId,
inboxId: props.inbox.id,
},
});
return;
}
const whatsappChannel = await store.dispatch('inboxes/createChannel', {
name: inboxName.value,
channel: {
type: 'whatsapp',
phone_number: phoneNumber.value,
provider: 'zapi',
provider_config: {
instance_id: instanceId.value,
token: token.value,
client_token: clientToken.value,
},
provider_config: buildProviderConfig(),
},
});
@ -72,14 +108,21 @@ const createChannel = async () => {
},
});
} catch (error) {
useAlert(error.message || t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
useAlert(
error.message ||
t(
isConvertMode.value
? 'INBOX_MGMT.CONVERT.API.ERROR_MESSAGE'
: 'INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'
)
);
}
};
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-full mb-6">
<div v-if="!isConvertMode" class="w-full mb-6">
<PromoBanner
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.TITLE')"
:description="
@ -100,6 +143,7 @@ const createChannel = async () => {
<input
v-model="inboxName"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="v$.inboxName.$touch"
/>
@ -115,6 +159,7 @@ const createChannel = async () => {
<input
v-model="phoneNumber"
type="text"
:disabled="isConvertMode"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
@ -171,11 +216,15 @@ const createChannel = async () => {
<div class="w-full">
<NextButton
:is-loading="uiFlags.isCreating"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
:label="
isConvertMode
? $t('INBOX_MGMT.CONVERT.SUBMIT_BUTTON')
: $t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')
"
/>
</div>
</form>

View File

@ -10,6 +10,7 @@ import InboxChannel from './InboxChannels.vue';
import ChannelList from './ChannelList.vue';
import AddAgents from './AddAgents.vue';
import FinishSetup from './FinishSetup.vue';
import InboxConvert from './InboxConvert.vue';
export default {
routes: [
@ -93,6 +94,15 @@ export default {
},
],
},
{
path: ':inboxId/convert',
name: 'settings_inbox_convert',
component: InboxConvert,
meta: {
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
permissions: ['administrator'],
},
},
{
path: ':inboxId/:tab?',
name: 'settings_inbox_show',

View File

@ -296,6 +296,24 @@ export const actions = {
throwErrorMessage(error);
}
},
convertProvider: async (
{ commit },
{ inboxId, provider, providerConfig }
) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
const response = await InboxesAPI.convertProvider(inboxId, {
provider,
providerConfig,
});
commit(types.default.EDIT_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
return throwErrorMessage(error);
}
},
updateInboxIMAP: async ({ commit }, { id, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true });
try {

View File

@ -21,7 +21,7 @@
#
# rubocop:enable Layout/LineLength
class Channel::Whatsapp < ApplicationRecord
class Channel::Whatsapp < ApplicationRecord # rubocop:disable Metrics/ClassLength
include Channelable
include Reauthorizable
@ -128,6 +128,91 @@ class Channel::Whatsapp < ApplicationRecord
Rails.logger.error "Failed to disconnect channel provider: #{e.message}"
end
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
def convert_provider!(new_provider:, new_provider_config:)
# Serialize concurrent conversions of the same inbox. Without the lock,
# two admin requests could both pass pre-validation, race the disconnect
# and save, and leave webhooks/templates mismatched with the persisted
# provider. `with_lock` issues SELECT FOR UPDATE and wraps the block in
# a transaction; the loser waits until the winner commits.
with_lock do
previous_provider = provider
previous_provider_config = provider_config.deep_dup
normalized_new_config = new_provider_config || {}
if new_provider == previous_provider
errors.add(:provider, 'must be different from the current provider')
raise ActiveRecord::RecordInvalid, self
end
# Pre-validate the new config without persisting, so we never terminate
# the current provider session for a known-bad target config.
assign_attributes(provider: new_provider, provider_config: normalized_new_config)
unless valid?
assign_attributes(provider: previous_provider, provider_config: previous_provider_config)
raise ActiveRecord::RecordInvalid, self
end
# Snapshot provider_config AFTER valid? so we keep any fields populated
# by before_validation callbacks (e.g. ensure_webhook_verify_token). The
# final persist uses save!(validate: false), so we must not rely on a
# second validation pass to replay those callbacks.
validated_new_config = provider_config.deep_dup
# Validation passed. Restore the old state briefly so the disconnect
# call talks to the correct (old) endpoint, then reapply and persist
# the new state. We call the service directly so a failed disconnect
# propagates and aborts the conversion instead of silently leaving the
# old session alive while the inbox points at the new provider.
assign_attributes(provider: previous_provider, provider_config: previous_provider_config)
# When converting away from whatsapp_cloud, mirror the destroy-time
# cleanup so the Meta webhook subscription is torn down (embedded_signup
# source); manual-setup channels follow the same no-op behavior as on
# destruction. A teardown failure on a best-effort cleanup should not
# abort the swap.
if previous_provider == 'whatsapp_cloud'
begin
teardown_webhooks
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Pre-conversion webhook teardown failed: #{e.message}"
ensure
# Reset the destroy-time guard so a later destroy! or subsequent
# conversion on the same instance doesn't skip webhook removal.
@webhook_teardown_initiated = false
end
end
provider_service.disconnect_channel_provider if provider_service.respond_to?(:disconnect_channel_provider)
assign_attributes(
provider: new_provider,
provider_config: validated_new_config,
provider_connection: {},
message_templates: {},
message_templates_last_updated: nil
)
# Skip revalidation: the pre-flight valid? above is authoritative. A
# second validate_provider_config? call here would re-hit the external
# API and a transient failure could roll back the transaction after we
# already disconnected the old session.
save!(validate: false)
setup_webhooks if should_auto_setup_webhooks?
begin
sync_templates
rescue StandardError => e
# Some provider sync_templates implementations stamp
# `message_templates_last_updated` before the remote fetch. If the
# fetch blows up, reset both columns so the inbox doesn't look
# synced with zero templates and the scheduler will retry.
update_columns(message_templates: {}, message_templates_last_updated: nil) # rubocop:disable Rails/SkipsModelValidations
Rails.logger.error "[WHATSAPP] Post-conversion template sync failed: #{e.message}"
end
end
self
end
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
def received_messages(messages, conversation)
return unless provider_service.respond_to?(:received_messages)

View File

@ -74,6 +74,10 @@ class InboxPolicy < ApplicationPolicy
@account_user.administrator?
end
def convert_provider?
@account_user.administrator?
end
def on_whatsapp?
true
end

View File

@ -276,6 +276,7 @@ Rails.application.routes.draw do
post :set_agent_bot, on: :member
post :setup_channel_provider, on: :member
post :disconnect_channel_provider, on: :member
post :convert_provider, on: :member
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member

View File

@ -1349,6 +1349,140 @@ RSpec.describe 'Inboxes API', type: :request do
end
end
describe 'POST /api/v1/accounts/:account_id/inboxes/:id/convert_provider' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
let(:inbox) { channel.inbox }
let(:new_cloud_config) do
{ api_key: 'new_cloud_key', phone_number_id: 'new_phone_id', business_account_id: 'new_waba_id' }
end
before do
stub_request(:delete, "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}")
.to_return(status: 200)
stub_request(:get, %r{graph\.facebook\.com/v\d+\.\d+/.*/message_templates.*})
.to_return(status: 200, body: { data: [] }.to_json, headers: { 'Content-Type' => 'application/json' })
webhook_setup_service = instance_double(Whatsapp::WebhookSetupService, perform: nil)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_setup_service)
end
context 'when unauthenticated' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: agent.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns unauthorized even when the agent is assigned to the inbox' do
create(:inbox_member, user: agent, inbox: inbox)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: agent.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an administrator' do
it 'converts the channel to the new provider' do # rubocop:disable RSpec/MultipleExpectations
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['provider']).to eq('whatsapp_cloud')
expect(body['provider_config']).to include(
'api_key' => 'new_cloud_key',
'phone_number_id' => 'new_phone_id',
'business_account_id' => 'new_waba_id'
)
expect(body['provider_config']).not_to have_key('provider_url')
channel.reload
expect(channel.provider).to eq('whatsapp_cloud')
expect(channel.provider_config).to include(
'api_key' => 'new_cloud_key',
'phone_number_id' => 'new_phone_id',
'business_account_id' => 'new_waba_id'
)
expect(channel.provider_config).not_to have_key('provider_url')
expect(channel.provider_connection).to be_blank
expect(channel.message_templates).to be_blank
end
it 'returns 422 when the channel does not support conversion' do
other_inbox = create(:inbox, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{other_inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to match(/does not support provider conversion/i)
end
it 'returns 400 when the provider param is missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['message']).to match(/provider/i)
end
it 'returns 422 when the new provider config is invalid' do
cloud_service = instance_double(Whatsapp::Providers::WhatsappCloudService, validate_provider_config?: false)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(cloud_service)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: { api_key: 'bad' } },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to match(/invalid credentials/i)
end
it 'returns 422 with a fallback message when conversion raises a generic error' do
allow_any_instance_of(Channel::Whatsapp).to receive(:convert_provider!).and_raise(StandardError, 'boom') # rubocop:disable RSpec/AnyInstance
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to match(/provider conversion failed/i)
end
it 'returns 422 when converting to the same provider' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: channel.provider, provider_config: {} },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to match(/must be different/i)
end
end
end
describe 'POST /api/v1/accounts/:account_id/inboxes/:id/on_whatsapp' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false) }
let(:inbox) { channel.inbox }

View File

@ -552,6 +552,242 @@ RSpec.describe Channel::Whatsapp do
end
end
describe '#convert_provider!' do
let(:channel) do
create(:channel_whatsapp,
provider: 'baileys',
provider_connection: { 'connection' => 'open' },
validate_provider_config: false,
sync_templates: false)
end
let(:new_cloud_config) do
{ 'api_key' => 'new_cloud_key', 'phone_number_id' => 'new_phone_id', 'business_account_id' => 'new_waba_id' }
end
before do
stub_request(:delete, "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}")
.to_return(status: 200)
stub_request(:get, %r{graph\.facebook\.com/v\d+\.\d+/.*message_templates})
.to_return(status: 200, body: { data: [] }.to_json, headers: { 'Content-Type' => 'application/json' })
stub_request(:delete, %r{graph\.facebook\.com/v\d+\.\d+/.*/subscribed_apps})
.to_return(status: 200, body: { success: true }.to_json, headers: { 'Content-Type' => 'application/json' })
webhook_setup_service = instance_double(Whatsapp::WebhookSetupService, perform: nil)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_setup_service)
end
it 'swaps provider and provider_config atomically' do
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
channel.reload
expect(channel.provider).to eq('whatsapp_cloud')
expect(channel.provider_config).to include(new_cloud_config)
expect(channel.provider_config).not_to have_key('provider_url')
end
it 'clears provider_connection and message_templates' do
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
channel.reload
expect(channel.provider_connection).to eq({})
expect(channel.message_templates).to eq({})
expect(channel.message_templates_last_updated).to be_nil
end
it 'calls disconnect on the old provider when supported' do
disconnect_url = "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}"
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
expect(WebMock).to have_requested(:delete, disconnect_url)
end
it 'does not raise when the old provider has no disconnect method' do
cloud_channel = create(:channel_whatsapp,
provider: 'whatsapp_cloud',
provider_config: {
'source' => 'embedded_signup',
'api_key' => 'old_key',
'phone_number_id' => 'old_phone_id',
'business_account_id' => 'old_waba_id'
},
validate_provider_config: false,
sync_templates: false)
expect do
cloud_channel.convert_provider!(
new_provider: 'baileys',
new_provider_config: { 'provider_url' => 'https://baileys.api', 'api_key' => 'k' }
)
end.not_to raise_error
end
it 'rolls back and raises when the new provider config is invalid, leaving the old provider session untouched' do
# The factory installs a singleton `validate_provider_config` stub that
# bypasses validation; reload from DB to get a clean instance.
fresh_channel = described_class.find(channel.id)
cloud_service = instance_double(Whatsapp::Providers::WhatsappCloudService, validate_provider_config?: false)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(cloud_service)
disconnect_url = "#{fresh_channel.provider_config['provider_url']}/connections/#{fresh_channel.phone_number}"
expect do
fresh_channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: { 'api_key' => 'bad' })
end.to(raise_error { |error| expect(error.class.name).to eq('ActiveRecord::RecordInvalid') })
fresh_channel.reload
expect(fresh_channel.provider).to eq('baileys')
expect(WebMock).not_to have_requested(:delete, disconnect_url)
end
it 'triggers webhook setup on the new provider when auto-setup applies' do
webhook_setup_service = instance_double(Whatsapp::WebhookSetupService, perform: nil)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_setup_service)
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
expect(Whatsapp::WebhookSetupService).to have_received(:new).with(channel, 'new_waba_id', 'new_cloud_key')
expect(webhook_setup_service).to have_received(:perform)
end
it 'does not trigger webhook setup when the new provider does not auto-setup' do
cloud_channel = create(:channel_whatsapp,
provider: 'whatsapp_cloud',
provider_config: {
'source' => 'embedded_signup',
'api_key' => 'old_key',
'phone_number_id' => 'old_phone_id',
'business_account_id' => 'old_waba_id'
},
validate_provider_config: false,
sync_templates: false)
allow(Whatsapp::WebhookSetupService).to receive(:new)
cloud_channel.convert_provider!(
new_provider: 'baileys',
new_provider_config: { 'provider_url' => 'https://baileys.api', 'api_key' => 'k' }
)
expect(Whatsapp::WebhookSetupService).not_to have_received(:new)
end
it 'rejects no-op conversions targeting the current provider' do
original_templates_count = channel.message_templates.count
expect do
channel.convert_provider!(new_provider: 'baileys', new_provider_config: channel.provider_config)
end.to(raise_error { |error| expect(error.class.name).to eq('ActiveRecord::RecordInvalid') })
channel.reload
expect(channel.provider_connection).to eq('connection' => 'open')
expect(channel.message_templates.count).to eq(original_templates_count)
end
it 'aborts and does not persist the new provider when the disconnect fails' do
original_templates_count = channel.message_templates.count
baileys_service = instance_double(Whatsapp::Providers::WhatsappBaileysService)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
allow(baileys_service).to receive(:disconnect_channel_provider).and_raise(StandardError, 'boom')
expect do
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
end.to(raise_error { |error| expect(error.class.name).to eq('StandardError') })
channel.reload
expect(channel.provider).to eq('baileys')
expect(channel.message_templates.count).to eq(original_templates_count)
end
it 'swallows and logs errors raised by post-conversion template sync' do
# Bypass both the factory's singleton `sync_templates` stub and validation,
# so we can observe the rescue branch on the real instance.
fresh_channel = described_class.find(channel.id)
cloud_service = instance_double(
Whatsapp::Providers::WhatsappCloudService,
validate_provider_config?: true
)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(cloud_service)
# Some provider services stamp `message_templates_last_updated` before
# the remote fetch; emulate that by setting the timestamp right before
# the raise, so the rescue must reset it to avoid a "synced" state.
allow(fresh_channel).to receive(:sync_templates) do
fresh_channel.mark_message_templates_updated
raise StandardError, 'boom'
end
allow(Rails.logger).to receive(:error)
expect do
fresh_channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
end.not_to raise_error
fresh_channel.reload
expect(fresh_channel.provider).to eq('whatsapp_cloud')
expect(fresh_channel.provider_connection).to eq({})
expect(fresh_channel.message_templates).to eq({})
expect(fresh_channel.message_templates_last_updated).to be_nil
expect(Rails.logger).to have_received(:error).with(/Post-conversion template sync failed.*boom/)
end
context 'when converting from whatsapp_cloud to baileys' do
let(:cloud_channel) do
create(:channel_whatsapp,
provider: 'whatsapp_cloud',
provider_config: {
'source' => 'embedded_signup',
'api_key' => 'old_key',
'phone_number_id' => 'old_phone_id',
'business_account_id' => 'old_waba_id'
},
validate_provider_config: false,
sync_templates: false)
end
let(:new_baileys_config) { { 'provider_url' => 'https://baileys.api', 'api_key' => 'new_baileys_key' } }
before do
stub_request(:delete, %r{https://baileys\.api/connections/.*})
.to_return(status: 200)
end
it 'invokes WebhookTeardownService on the old cloud channel before swapping' do
teardown_service = instance_double(Whatsapp::WebhookTeardownService, perform: nil)
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(cloud_channel).and_return(teardown_service)
cloud_channel.convert_provider!(new_provider: 'baileys', new_provider_config: new_baileys_config)
expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(cloud_channel)
expect(teardown_service).to have_received(:perform)
end
it 'swallows and logs errors raised by pre-conversion webhook teardown' do
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(cloud_channel).and_return(teardown_service)
allow(teardown_service).to receive(:perform).and_raise(StandardError, 'teardown boom')
allow(Rails.logger).to receive(:error)
expect do
cloud_channel.convert_provider!(new_provider: 'baileys', new_provider_config: new_baileys_config)
end.not_to raise_error
cloud_channel.reload
expect(cloud_channel.provider).to eq('baileys')
expect(Rails.logger).to have_received(:error).with(/Pre-conversion webhook teardown failed.*teardown boom/)
end
it 'resets the teardown guard so a subsequent destroy still tears down webhooks' do
teardown_service = instance_double(Whatsapp::WebhookTeardownService, perform: nil)
allow(Whatsapp::WebhookTeardownService).to receive(:new).and_return(teardown_service)
cloud_channel.convert_provider!(new_provider: 'baileys', new_provider_config: new_baileys_config)
# The convert path no longer matches the teardown branch (provider is
# now baileys), so destroy! hitting teardown_webhooks again proves the
# `@webhook_teardown_initiated` guard was reset by the ensure block.
cloud_channel.destroy!
# One teardown from the pre-conversion branch, one from destroy.
expect(teardown_service).to have_received(:perform).twice
end
end
end
describe '#sync_group' do
it 'delegates to provider_service when it supports sync_group' do
channel = create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false)