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:
parent
adc0d892e0
commit
e032fc7774
@ -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') }
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -74,6 +74,10 @@ class InboxPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def convert_provider?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def on_whatsapp?
|
||||
true
|
||||
end
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user