* 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
232 lines
6.7 KiB
Vue
232 lines
6.7 KiB
Vue
<script setup>
|
|
import { computed, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useStore } from 'vuex';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useVuelidate } from '@vuelidate/core';
|
|
import { useAlert } from 'dashboard/composables';
|
|
import { required } from '@vuelidate/validators';
|
|
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(isConvertMode.value ? props.inbox?.name || '' : '');
|
|
const phoneNumber = ref(
|
|
isConvertMode.value ? props.inbox?.phone_number || '' : ''
|
|
);
|
|
const instanceId = ref('');
|
|
const token = ref('');
|
|
const clientToken = ref('');
|
|
|
|
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
|
|
|
|
// NOTE: Affiliate link is left intentionally hardcoded.
|
|
const zapiAffiliateUrl =
|
|
'https://app.z-api.io/app/auth/new-account?afilliate=3E0B31343E6CB0297B567AC1D8277FBB';
|
|
|
|
const rules = computed(() => ({
|
|
inboxName: { required },
|
|
phoneNumber: { required, isPhoneE164OrEmpty },
|
|
instanceId: { required },
|
|
token: { required },
|
|
clientToken: { required },
|
|
}));
|
|
|
|
const v$ = useVuelidate(rules, {
|
|
inboxName,
|
|
phoneNumber,
|
|
instanceId,
|
|
token,
|
|
clientToken,
|
|
});
|
|
|
|
const buildProviderConfig = () => ({
|
|
instance_id: instanceId.value,
|
|
token: token.value,
|
|
client_token: clientToken.value,
|
|
});
|
|
|
|
const createChannel = async () => {
|
|
v$.value.$touch();
|
|
if (v$.value.$invalid) {
|
|
return;
|
|
}
|
|
|
|
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: buildProviderConfig(),
|
|
},
|
|
});
|
|
|
|
router.replace({
|
|
name: 'settings_inboxes_add_agents',
|
|
params: {
|
|
page: 'new',
|
|
inbox_id: whatsappChannel.id,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
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 v-if="!isConvertMode" class="w-full mb-6">
|
|
<PromoBanner
|
|
:title="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.TITLE')"
|
|
:description="
|
|
$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.DESCRIPTION')
|
|
"
|
|
variant="success"
|
|
logo-src="/assets/images/dashboard/channels/z-api/z-api-dark-green.png"
|
|
logo-alt="Z-API"
|
|
:cta-text="$t('INBOX_MGMT.ADD.WHATSAPP.ZAPI_PROMO.SETUP_BANNER.CTA')"
|
|
cta-external
|
|
:cta-link="zapiAffiliateUrl"
|
|
/>
|
|
</div>
|
|
|
|
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
|
<label :class="{ error: v$.inboxName.$error }">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
|
<input
|
|
v-model="inboxName"
|
|
type="text"
|
|
:disabled="isConvertMode"
|
|
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
|
|
@blur="v$.inboxName.$touch"
|
|
/>
|
|
<span v-if="v$.inboxName.$error" class="message">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.ERROR') }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
|
<label :class="{ error: v$.phoneNumber.$error }">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
|
|
<input
|
|
v-model="phoneNumber"
|
|
type="text"
|
|
:disabled="isConvertMode"
|
|
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
|
|
@blur="v$.phoneNumber.$touch"
|
|
/>
|
|
<span v-if="v$.phoneNumber.$error" class="message">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.ERROR') }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
|
<label :class="{ error: v$.instanceId.$error }">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.LABEL') }}
|
|
<input
|
|
v-model="instanceId"
|
|
type="password"
|
|
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.PLACEHOLDER')"
|
|
@blur="v$.instanceId.$touch"
|
|
/>
|
|
<span v-if="v$.instanceId.$error" class="message">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INSTANCE_ID.ERROR') }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
|
<label :class="{ error: v$.token.$error }">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.LABEL') }}
|
|
<input
|
|
v-model="token"
|
|
type="password"
|
|
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.PLACEHOLDER')"
|
|
@blur="v$.token.$touch"
|
|
/>
|
|
<span v-if="v$.token.$error" class="message">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.TOKEN.ERROR') }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
|
<label :class="{ error: v$.clientToken.$error }">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.LABEL') }}
|
|
<input
|
|
v-model="clientToken"
|
|
type="password"
|
|
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.PLACEHOLDER')"
|
|
@blur="v$.clientToken.$touch"
|
|
/>
|
|
<span v-if="v$.clientToken.$error" class="message">
|
|
{{ $t('INBOX_MGMT.ADD.WHATSAPP.CLIENT_TOKEN.ERROR') }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="w-full">
|
|
<NextButton
|
|
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
|
|
type="submit"
|
|
solid
|
|
blue
|
|
:label="
|
|
isConvertMode
|
|
? $t('INBOX_MGMT.CONVERT.SUBMIT_BUTTON')
|
|
: $t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')
|
|
"
|
|
/>
|
|
</div>
|
|
</form>
|
|
</template>
|