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:
Muhsin Keloth
2026-09-29 15:46:00 +04:00
committed by GitHub
parent 8c80026a79
commit a851d63cf2
7 changed files with 83 additions and 46 deletions

View File

@@ -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

View File

@@ -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`);
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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?

View File

@@ -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

View File

@@ -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]