feat(whatsapp): expose cached Twilio templates (#15311)
The cached inbox template endpoint currently serves only native WhatsApp channels. This draft extends the same read-only endpoint to Twilio WhatsApp inboxes and adds last-sync metadata for both providers, so the template listing can use one stable contract without making provider requests. Non-WhatsApp inbox behavior remains unchanged. Related: https://linear.app/chatwoot/issue/PLA-193/add-whatsapp-template-listing-to-account-settings ### Things to know - This is stack 1 of 2 and contains only the API contract needed by the settings UI in #15312. - Template data remains cache-only; the endpoint does not call Meta or Twilio. - Native WhatsApp templates are filtered by `name`; Twilio Content Templates are filtered by `friendly_name`. ### How to test 1. Request the message templates endpoint for a native WhatsApp inbox and confirm it returns the cached templates plus `meta.last_updated_at`. 2. Request it for a Twilio WhatsApp inbox and confirm it returns cached Content Templates plus `meta.last_updated_at`. 3. Pass a template name and confirm only the matching provider template is returned. 4. Request it for a non-WhatsApp inbox and confirm the endpoint returns an unprocessable entity response. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -17,12 +17,17 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
end
|
||||
|
||||
def message_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' } unless @inbox.whatsapp?
|
||||
unless whatsapp_channel?
|
||||
return render status: :unprocessable_entity, json: { error: 'Message templates are only available for WhatsApp channels' }
|
||||
end
|
||||
|
||||
templates = @inbox.channel.message_templates.presence || []
|
||||
templates = templates.select { |template| template['name'] == params[:name] } if params[:name].present?
|
||||
templates, last_sync_attempt_at, name_key = message_template_data
|
||||
templates = templates.select { |template| template[name_key] == params[:name] } if params[:name].present?
|
||||
|
||||
render json: { payload: templates }
|
||||
render json: {
|
||||
payload: templates,
|
||||
meta: { last_sync_attempt_at: last_sync_attempt_at }
|
||||
}
|
||||
end
|
||||
|
||||
def health
|
||||
@@ -80,6 +85,12 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def message_template_data
|
||||
return [@inbox.channel.message_templates.presence || [], @inbox.channel.message_templates_last_updated, 'name'] unless @inbox.twilio_whatsapp?
|
||||
|
||||
[@inbox.channel.content_templates&.dig('templates') || [], @inbox.channel.content_templates_last_updated, 'friendly_name']
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
|
||||
@@ -2,9 +2,9 @@ class Twilio::TemplateSyncService
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def call
|
||||
mark_templates_updated
|
||||
fetch_templates_from_twilio
|
||||
update_channel_templates
|
||||
mark_templates_updated
|
||||
rescue Twilio::REST::TwilioError => e
|
||||
Rails.logger.error("Twilio template sync failed: #{e.message}")
|
||||
false
|
||||
@@ -13,16 +13,13 @@ class Twilio::TemplateSyncService
|
||||
private
|
||||
|
||||
def fetch_templates_from_twilio
|
||||
@templates = client.content.v1.contents.list(limit: 1000)
|
||||
@templates = client.content.v1.content_and_approvals.list(limit: 1000)
|
||||
end
|
||||
|
||||
def update_channel_templates
|
||||
formatted_templates = @templates.map { |template| format_template(template) }
|
||||
|
||||
channel.update!(
|
||||
content_templates: { templates: formatted_templates },
|
||||
content_templates_last_updated: Time.current
|
||||
)
|
||||
channel.update!(content_templates: { templates: formatted_templates })
|
||||
end
|
||||
|
||||
def format_template(template)
|
||||
@@ -50,10 +47,8 @@ class Twilio::TemplateSyncService
|
||||
@client ||= channel.send(:client)
|
||||
end
|
||||
|
||||
def derive_status(_template)
|
||||
# For now, assume all fetched templates are approved
|
||||
# In the future, this could check approval status from Twilio
|
||||
'approved'
|
||||
def derive_status(template)
|
||||
template.approval_requests&.dig('whatsapp', 'status')&.downcase || 'unsubmitted'
|
||||
end
|
||||
|
||||
def derive_template_type(template)
|
||||
|
||||
@@ -1275,6 +1275,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/:id/message_templates' do
|
||||
let(:last_sync_attempt_at) { 1.hour.ago.change(usec: 0) }
|
||||
let(:message_templates) do
|
||||
[
|
||||
{ 'name' => 'shipping_update', 'language' => 'en_US' },
|
||||
@@ -1287,6 +1288,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
:channel_whatsapp,
|
||||
account: account,
|
||||
message_templates: message_templates,
|
||||
message_templates_last_updated: last_sync_attempt_at,
|
||||
sync_templates: false,
|
||||
validate_provider_config: false
|
||||
)
|
||||
@@ -1319,6 +1321,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to eq(message_templates)
|
||||
expect(Time.zone.parse(response.parsed_body.dig('meta', 'last_sync_attempt_at'))).to eq(last_sync_attempt_at)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -8,17 +8,19 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:content_api) { double }
|
||||
let(:contents_list) { double }
|
||||
let(:content_and_approvals_list) { double }
|
||||
let(:approval_requests) { { 'whatsapp' => { 'status' => 'approved' } } }
|
||||
|
||||
# Mock Twilio template objects
|
||||
let(:text_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX123456789',
|
||||
friendly_name: 'hello_world',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Hello World!' } }
|
||||
)
|
||||
@@ -26,12 +28,13 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
let(:media_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX987654321',
|
||||
friendly_name: 'product_showcase',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: { '1' => 'iPhone', '2' => '$999' },
|
||||
types: {
|
||||
'twilio/media' => {
|
||||
@@ -44,12 +47,13 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
let(:quick_reply_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX555666777',
|
||||
friendly_name: 'welcome_message',
|
||||
language: 'en_US',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/quick-reply' => {
|
||||
@@ -65,12 +69,13 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
let(:catalog_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX111222333',
|
||||
friendly_name: 'product_catalog',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/catalog' => {
|
||||
@@ -83,12 +88,13 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
let(:call_to_action_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX444555666',
|
||||
friendly_name: 'payment_reminder',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/call-to-action' => {
|
||||
@@ -110,8 +116,8 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
allow(twilio_channel).to receive(:send).with(:client).and_return(twilio_client)
|
||||
allow(twilio_client).to receive(:content).and_return(content_api)
|
||||
allow(content_api).to receive(:v1).and_return(content_api)
|
||||
allow(content_api).to receive(:contents).and_return(contents_list)
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return(templates)
|
||||
allow(content_api).to receive(:content_and_approvals).and_return(content_and_approvals_list)
|
||||
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return(templates)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
@@ -121,7 +127,7 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
result = sync_service.call
|
||||
|
||||
expect(result).to be_truthy
|
||||
expect(contents_list).to have_received(:list).with(limit: 1000)
|
||||
expect(content_and_approvals_list).to have_received(:list).with(limit: 1000)
|
||||
|
||||
twilio_channel.reload
|
||||
expect(twilio_channel.content_templates).to be_present
|
||||
@@ -152,6 +158,30 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
)
|
||||
end
|
||||
|
||||
context 'when a template has a non-approved WhatsApp status' do
|
||||
let(:approval_requests) { { 'whatsapp' => { 'status' => 'rejected' } } }
|
||||
|
||||
it 'stores the provider approval status' do
|
||||
sync_service.call
|
||||
|
||||
template_data = twilio_channel.reload.content_templates['templates'].first
|
||||
|
||||
expect(template_data['status']).to eq('rejected')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a template has not been submitted to WhatsApp' do
|
||||
let(:approval_requests) { {} }
|
||||
|
||||
it 'stores the template as unsubmitted' do
|
||||
sync_service.call
|
||||
|
||||
template_data = twilio_channel.reload.content_templates['templates'].first
|
||||
|
||||
expect(template_data['status']).to eq('unsubmitted')
|
||||
end
|
||||
end
|
||||
|
||||
it 'correctly formats media templates' do
|
||||
sync_service.call
|
||||
|
||||
@@ -222,17 +252,18 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
it 'categorizes marketing templates correctly' do
|
||||
marketing_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX_MARKETING',
|
||||
friendly_name: 'promo_offer_50_off',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => '50% off sale!' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
|
||||
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
@@ -244,17 +275,18 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
it 'categorizes authentication templates correctly' do
|
||||
auth_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX_AUTH',
|
||||
friendly_name: 'otp_verification',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Your OTP is {{1}}' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([auth_template])
|
||||
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([auth_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
@@ -267,21 +299,24 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
context 'with API error' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
|
||||
allow(content_and_approvals_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'handles Twilio::REST::TwilioError gracefully' do
|
||||
result = sync_service.call
|
||||
freeze_time do
|
||||
result = sync_service.call
|
||||
|
||||
expect(result).to be_falsey
|
||||
expect(result).to be_falsey
|
||||
expect(twilio_channel.reload.content_templates_last_updated).to eq(Time.current)
|
||||
end
|
||||
expect(Rails.logger).to have_received(:error).with('Twilio template sync failed: API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with generic error' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).and_raise(StandardError, 'Connection failed')
|
||||
allow(content_and_approvals_list).to receive(:list).and_raise(StandardError, 'Connection failed')
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
@@ -292,7 +327,7 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
|
||||
context 'with empty templates list' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([])
|
||||
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([])
|
||||
end
|
||||
|
||||
it 'updates channel with empty templates array' do
|
||||
@@ -308,17 +343,18 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
describe 'template categorization behavior' do
|
||||
it 'defaults to utility category for unrecognized patterns' do
|
||||
generic_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX_GENERIC',
|
||||
friendly_name: 'order_status',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Order updated' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([generic_template])
|
||||
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([generic_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
@@ -333,12 +369,13 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
context 'with multiple type definitions' do
|
||||
let(:mixed_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
Twilio::REST::Content::V1::ContentAndApprovalsInstance,
|
||||
sid: 'HX_MIXED',
|
||||
friendly_name: 'mixed_type',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
approval_requests: approval_requests,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/media' => { 'body' => 'Media content' },
|
||||
@@ -348,7 +385,7 @@ RSpec.describe Twilio::TemplateSyncService do
|
||||
end
|
||||
|
||||
before do
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
|
||||
allow(content_and_approvals_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
|
||||
end
|
||||
|
||||
it 'prioritizes media type for type detection but text for body extraction' do
|
||||
|
||||
@@ -5,14 +5,14 @@ get:
|
||||
summary: List WhatsApp message templates
|
||||
security:
|
||||
- userApiKey: []
|
||||
description: List the cached message templates available for a WhatsApp inbox
|
||||
description: List the cached message templates available for a native or Twilio WhatsApp inbox
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/account_id'
|
||||
- name: id
|
||||
in: path
|
||||
schema:
|
||||
type: number
|
||||
description: ID of the WhatsApp inbox
|
||||
description: ID of the native or Twilio WhatsApp inbox
|
||||
required: true
|
||||
- name: name
|
||||
in: query
|
||||
@@ -30,10 +30,19 @@ get:
|
||||
properties:
|
||||
payload:
|
||||
type: array
|
||||
description: WhatsApp message templates available for the inbox
|
||||
description: Native or Twilio WhatsApp message templates available for the inbox
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
last_sync_attempt_at:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
description: Time when template synchronization was last attempted
|
||||
'404':
|
||||
description: Inbox not found
|
||||
content:
|
||||
|
||||
@@ -6106,7 +6106,7 @@
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "List the cached message templates available for a WhatsApp inbox",
|
||||
"description": "List the cached message templates available for a native or Twilio WhatsApp inbox",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
@@ -6117,7 +6117,7 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "ID of the WhatsApp inbox",
|
||||
"description": "ID of the native or Twilio WhatsApp inbox",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -6140,11 +6140,24 @@
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "array",
|
||||
"description": "WhatsApp message templates available for the inbox",
|
||||
"description": "Native or Twilio WhatsApp message templates available for the inbox",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"last_sync_attempt_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time",
|
||||
"description": "Time when template synchronization was last attempted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4649,7 +4649,7 @@
|
||||
"userApiKey": []
|
||||
}
|
||||
],
|
||||
"description": "List the cached message templates available for a WhatsApp inbox",
|
||||
"description": "List the cached message templates available for a native or Twilio WhatsApp inbox",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/account_id"
|
||||
@@ -4660,7 +4660,7 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "ID of the WhatsApp inbox",
|
||||
"description": "ID of the native or Twilio WhatsApp inbox",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -4683,11 +4683,24 @@
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "array",
|
||||
"description": "WhatsApp message templates available for the inbox",
|
||||
"description": "Native or Twilio WhatsApp message templates available for the inbox",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"last_sync_attempt_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time",
|
||||
"description": "Time when template synchronization was last attempted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user