From 1c28df49e5df0caa6bd08ca7eb46be4c91385197 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 4 Aug 2026 14:13:11 +0400 Subject: [PATCH] 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> --- .../concerns/whatsapp_health_management.rb | 19 ++++- app/services/twilio/template_sync_service.rb | 15 ++-- .../v1/accounts/inboxes_controller_spec.rb | 3 + .../twilio/template_sync_service_spec.rb | 81 ++++++++++++++----- .../application/inboxes/message_templates.yml | 15 +++- swagger/swagger.json | 19 ++++- swagger/tag_groups/application_swagger.json | 19 ++++- 7 files changed, 126 insertions(+), 45 deletions(-) diff --git a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb index 5ed8871a5..8f8f1f39c 100644 --- a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb +++ b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb @@ -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) diff --git a/app/services/twilio/template_sync_service.rb b/app/services/twilio/template_sync_service.rb index 747c50f43..f4cb3947f 100644 --- a/app/services/twilio/template_sync_service.rb +++ b/app/services/twilio/template_sync_service.rb @@ -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) diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index a98eeb684..bf0ab356b 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -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 diff --git a/spec/services/twilio/template_sync_service_spec.rb b/spec/services/twilio/template_sync_service_spec.rb index 472ae8821..66a09c151 100644 --- a/spec/services/twilio/template_sync_service_spec.rb +++ b/spec/services/twilio/template_sync_service_spec.rb @@ -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 diff --git a/swagger/paths/application/inboxes/message_templates.yml b/swagger/paths/application/inboxes/message_templates.yml index 785bbef65..ac75f1d5c 100644 --- a/swagger/paths/application/inboxes/message_templates.yml +++ b/swagger/paths/application/inboxes/message_templates.yml @@ -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: diff --git a/swagger/swagger.json b/swagger/swagger.json index 3c5f4d8a1..3358e303f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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" + } + } } } } diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index f5025ba8d..733d19a58 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -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" + } + } } } }