* 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