fix(whatsapp): restore contact info requests for bsuid conversations (#15998)
Agents can request a phone number again from eligible WhatsApp Cloud conversations identified by a BSUID. The action now checks availability when an agent opens a conversation whose contact has no phone number, and refreshes as the contact or request state changes. Eligibility is fetched separately from conversation rendering, keeping pending-request history queries out of conversation lists. Existing checks still prevent duplicate pending requests and require an approved Request Contact Info template outside the messaging window. Related: https://linear.app/chatwoot/project/whatsapp-bsuid-4df5614db3f9/issues ### Background: previous approach [PR #15826](https://github.com/chatwoot/chatwoot/pull/15826) temporarily hid the dedicated Request Contact Info action to remove pending-request message-history lookups from routine conversation rendering, including inbox lists, filtered lists, contact conversation lists, and individual conversation responses. These eligibility checks added database work to ordinary browsing. Checking only the messages loaded in the UI would also miss pending requests in older history, potentially showing an action that the backend would then reject. This PR restores the action through a separate availability request for the selected WhatsApp Cloud conversation when the contact has no phone number. The endpoint retains the full pending-request check across conversations sharing the contact-inbox identity, while keeping history lookups out of conversation rendering as in #15826. History lookups still occur for the selected conversation's eligibility check; they are no longer part of routine list rendering. ### Things to know - No database migrations or feature flags are required. The unused static `contact_info_request` field is removed from conversation responses; the dashboard reads availability from the dedicated endpoint. External clients consuming that field must use the endpoint instead. - Availability requests are cancelled when switching conversations, and a pending request remains disabled even when its message is outside the loaded message history. - The interactive request and customer phone-sharing response were manually verified end to end. The template flow outside the messaging window has not been tested live. - Existing targeted validation passed: 29 Ruby service examples, 155 frontend tests, Ruby and JavaScript linting, and focused endpoint, authorization, and concurrent-request checks. ### How to reproduce Open a WhatsApp Cloud conversation with a BSUID contact, no phone number, and an active messaging window. Before this fix, the Request Contact Info action is hidden because the conversation payload reports the capability as unavailable. ### How to test 1. Open an eligible WhatsApp Cloud conversation whose contact has no phone number. Confirm Request Contact Info appears in the composer. 2. Send the request. Confirm the action is disabled while a request is pending, including after reopening the conversation. 3. Share the phone number from WhatsApp. Confirm the contact is updated and the action disappears. 4. Switch between conversations while availability is loading. Confirm the action reflects the selected conversation. 5. Open a conversation outside the messaging window. With an approved Request Contact Info template, confirm the action opens the template picker; without one, confirm the action is hidden. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
class Api::V1::Accounts::Conversations::ContactInfoRequestsController < Api::V1::Accounts::Conversations::BaseController
|
||||
rescue_from CustomExceptions::WhatsappContactInfoRequestError do |error|
|
||||
render json: { error: error.message }, status: error.http_status
|
||||
end
|
||||
|
||||
def show
|
||||
render json: Whatsapp::ContactInfoRequestEligibilityService.new(conversation: @conversation).availability
|
||||
end
|
||||
|
||||
def create
|
||||
@message = Whatsapp::ContactInfoRequestService.new(conversation: @conversation, sender: Current.user).perform
|
||||
render 'api/v1/accounts/conversations/messages/create'
|
||||
rescue CustomExceptions::WhatsappContactInfoRequestError => e
|
||||
render json: { error: e.message }, status: e.http_status
|
||||
end
|
||||
end
|
||||
|
||||
@@ -120,6 +120,12 @@ class ConversationApi extends ApiClient {
|
||||
return axios.post(`${this.url}/${conversationId}/transcript`, { email });
|
||||
}
|
||||
|
||||
getContactInfoRequestAvailability(conversationId, { signal } = {}) {
|
||||
return axios.get(`${this.url}/${conversationId}/contact_info_request`, {
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
requestContactInfo(conversationId) {
|
||||
return axios.post(`${this.url}/${conversationId}/contact_info_request`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -15,9 +17,9 @@ const contactById = useMapGetter('contacts/getContact');
|
||||
|
||||
const isRequesting = ref(false);
|
||||
|
||||
const capability = computed(
|
||||
() => currentChat.value?.contact_info_request || {}
|
||||
);
|
||||
const capability = ref({});
|
||||
const { isAWhatsAppCloudChannel } = useInbox();
|
||||
const { run, abort, isPending } = useAbortableRequest();
|
||||
|
||||
const currentContact = computed(() => {
|
||||
const senderId = currentChat.value?.meta?.sender?.id;
|
||||
@@ -45,28 +47,78 @@ const showButton = computed(
|
||||
|
||||
const isDisabled = computed(
|
||||
() =>
|
||||
isPending.value ||
|
||||
isRequesting.value ||
|
||||
hasPendingRequest.value ||
|
||||
capability.value.reason === 'pending_request'
|
||||
);
|
||||
|
||||
const tooltip = computed(() =>
|
||||
hasPendingRequest.value
|
||||
hasPendingRequest.value || capability.value.reason === 'pending_request'
|
||||
? t('CONVERSATION.REQUEST_CONTACT_INFO.PENDING_ACTION')
|
||||
: t('CONVERSATION.REQUEST_CONTACT_INFO.ACTION')
|
||||
);
|
||||
|
||||
const requestStates = computed(() =>
|
||||
(currentChat.value?.messages || [])
|
||||
.filter(message => message.content_attributes?.whatsapp_contact_info)
|
||||
.map(
|
||||
message =>
|
||||
`${message.id}:${message.status}:${message.content_attributes.whatsapp_contact_info.state}`
|
||||
)
|
||||
.join(',')
|
||||
);
|
||||
|
||||
const fetchAvailability = async () => {
|
||||
abort();
|
||||
capability.value = {};
|
||||
const conversationId = currentChat.value?.id;
|
||||
if (
|
||||
!conversationId ||
|
||||
!isAWhatsAppCloudChannel.value ||
|
||||
currentContact.value.phone_number
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const availability = await run(signal =>
|
||||
store.dispatch('getContactInfoRequestAvailability', {
|
||||
conversationId,
|
||||
signal,
|
||||
})
|
||||
);
|
||||
if (availability) capability.value = availability;
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONVERSATION.MESSAGE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
[
|
||||
currentChat,
|
||||
isAWhatsAppCloudChannel,
|
||||
() => currentContact.value.phone_number,
|
||||
() => currentChat.value?.can_reply,
|
||||
requestStates,
|
||||
],
|
||||
fetchAvailability,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const requestContactInfo = async () => {
|
||||
if (capability.value.delivery_mode === 'template') {
|
||||
emit('requestTemplate');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationId = currentChat.value.id;
|
||||
isRequesting.value = true;
|
||||
try {
|
||||
await store.dispatch('requestContactInfo', currentChat.value.id);
|
||||
await store.dispatch('requestContactInfo', conversationId);
|
||||
} catch (error) {
|
||||
if (currentChat.value.id !== conversationId) return;
|
||||
useAlert(error?.response?.data?.error || t('CONVERSATION.MESSAGE_ERROR'));
|
||||
fetchAvailability();
|
||||
} finally {
|
||||
isRequesting.value = false;
|
||||
}
|
||||
|
||||
@@ -429,6 +429,16 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
getContactInfoRequestAvailability: async (_, { conversationId, signal }) => {
|
||||
const { data } = await ConversationApi.getContactInfoRequestAvailability(
|
||||
conversationId,
|
||||
{
|
||||
signal,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
requestContactInfo: async ({ commit }, conversationId) => {
|
||||
const { data } = await ConversationApi.requestContactInfo(conversationId);
|
||||
commit(types.ADD_MESSAGE, data);
|
||||
|
||||
@@ -1,37 +1,6 @@
|
||||
class Whatsapp::ContactInfoRequestEligibilityService
|
||||
pattr_initialize [:conversation!, :message, { delivery_mode: :any, template_params: nil, pending_request: nil, can_reply: nil }]
|
||||
|
||||
class << self
|
||||
def availability_by_conversation(conversations)
|
||||
conversations = conversations.to_a
|
||||
pending_contact_inbox_ids = pending_request_contact_inbox_ids(conversations)
|
||||
|
||||
conversations.to_h do |conversation|
|
||||
availability = new(
|
||||
conversation: conversation,
|
||||
pending_request: pending_contact_inbox_ids.include?(conversation.contact_inbox_id)
|
||||
).availability
|
||||
[conversation.id, availability]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pending_request_contact_inbox_ids(conversations)
|
||||
contact_inbox_ids = conversations.filter_map(&:contact_inbox_id).uniq
|
||||
return [] if contact_inbox_ids.empty?
|
||||
|
||||
Message.outgoing.joins(:conversation)
|
||||
.where(conversations: { contact_inbox_id: contact_inbox_ids })
|
||||
.where.not(status: :failed)
|
||||
.where("(content_attributes #>> '{}')::jsonb -> 'whatsapp_contact_info' ->> 'type' = ?", 'request')
|
||||
.where("(content_attributes #>> '{}')::jsonb -> 'whatsapp_contact_info' ->> 'state' = ?", 'pending')
|
||||
.reorder(nil)
|
||||
.distinct
|
||||
.pluck('conversations.contact_inbox_id')
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_available!
|
||||
return if reason.blank?
|
||||
|
||||
|
||||
@@ -48,12 +48,6 @@ json.additional_attributes conversation.additional_attributes
|
||||
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
|
||||
json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
|
||||
json.can_reply conversation.can_reply?
|
||||
# Temporarily disable this action to avoid message-history lookups during conversation rendering.
|
||||
json.contact_info_request do
|
||||
json.available false
|
||||
json.reason nil
|
||||
json.delivery_mode nil
|
||||
end
|
||||
json.contact_last_seen_at conversation.contact_last_seen_at.to_i
|
||||
json.custom_attributes conversation.custom_attributes
|
||||
json.inbox_id conversation.inbox_id
|
||||
|
||||
@@ -192,7 +192,7 @@ Rails.application.routes.draw do
|
||||
post :retry
|
||||
end
|
||||
end
|
||||
resource :contact_info_request, only: [:create]
|
||||
resource :contact_info_request, only: [:show, :create]
|
||||
resources :assignments, only: [:create]
|
||||
resources :labels, only: [:create, :index]
|
||||
resource :participants, only: [:show, :create, :update, :destroy]
|
||||
|
||||
Reference in New Issue
Block a user