Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b65fa2270 | ||
|
|
fd5ac2a8a3 | ||
|
|
6b7180d051 | ||
|
|
4d362da9f0 |
@@ -4,6 +4,11 @@
|
||||
|
||||
- **Setup**: `bundle install && pnpm install`
|
||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
||||
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
|
||||
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
|
||||
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
|
||||
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
|
||||
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
|
||||
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||
@@ -93,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
|
||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||
|
||||
## Branding / White-labeling note
|
||||
|
||||
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
|
||||
|
||||
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
|
||||
def create
|
||||
@user = User.from_email(params[:email])
|
||||
if @user
|
||||
@user.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
||||
else
|
||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
||||
end
|
||||
@user&.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password'), 200)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
@@ -143,6 +144,7 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
@@ -472,7 +474,7 @@ const avatarInfo = computed(() => {
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
||||
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
|
||||
}
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
|
||||
+6
-2
@@ -2,6 +2,7 @@
|
||||
// components
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
// composables
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
@@ -34,8 +35,9 @@ export default {
|
||||
},
|
||||
setup() {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return { captainTasksEnabled };
|
||||
return { captainTasksEnabled, replaceInstallationName };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -228,7 +230,9 @@ export default {
|
||||
<div class="sender--info has-tooltip" data-original-title="null">
|
||||
<Avatar
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
|
||||
content: replaceInstallationName(
|
||||
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
|
||||
),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
|
||||
@@ -47,8 +47,10 @@ class Twilio::DeliveryStatusService
|
||||
@twilio_channel ||= if params[:MessagingServiceSid].present?
|
||||
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
|
||||
elsif params[:AccountSid].present? && params[:From].present?
|
||||
::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From])
|
||||
::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From])
|
||||
end
|
||||
log_channel_not_found if @twilio_channel.blank?
|
||||
@twilio_channel
|
||||
end
|
||||
|
||||
def message
|
||||
@@ -56,4 +58,14 @@ class Twilio::DeliveryStatusService
|
||||
|
||||
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
|
||||
end
|
||||
|
||||
def log_channel_not_found
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Delivery status channel lookup failed ' \
|
||||
"account_sid=#{params[:AccountSid]} " \
|
||||
"from=#{params[:From]} " \
|
||||
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||
"message_sid=#{params[:MessageSid]}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,12 +26,23 @@ class Twilio::IncomingMessageService
|
||||
def twilio_channel
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
|
||||
if params[:AccountSid].present? && params[:To].present?
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid],
|
||||
phone_number: params[:To])
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
|
||||
phone_number: params[:To])
|
||||
end
|
||||
log_channel_not_found if @twilio_channel.blank?
|
||||
@twilio_channel
|
||||
end
|
||||
|
||||
def log_channel_not_found
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Incoming message channel lookup failed ' \
|
||||
"account_sid=#{params[:AccountSid]} " \
|
||||
"to=#{params[:To]} " \
|
||||
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||
"sms_sid=#{params[:SmsSid]}"
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= twilio_channel.inbox
|
||||
end
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
class Whatsapp::LiquidTemplateProcessorService
|
||||
pattr_initialize [:campaign!, :contact!]
|
||||
|
||||
def process_template_params(template_params)
|
||||
return template_params if template_params.blank?
|
||||
|
||||
template_params_copy = deep_copy(template_params)
|
||||
processed_params = template_params_copy['processed_params']
|
||||
|
||||
return template_params_copy if processed_params.blank?
|
||||
|
||||
process_body_params(processed_params)
|
||||
process_header_params(processed_params)
|
||||
process_button_params(processed_params)
|
||||
process_footer_params(processed_params)
|
||||
|
||||
template_params_copy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_body_params(processed_params)
|
||||
return if processed_params['body'].blank?
|
||||
|
||||
processed_params['body'].each do |key, value|
|
||||
processed_params['body'][key] = process_liquid(value) if value.is_a?(String)
|
||||
end
|
||||
end
|
||||
|
||||
def process_header_params(processed_params)
|
||||
return if processed_params['header'].blank?
|
||||
|
||||
processed_params['header'].each do |key, value|
|
||||
processed_params['header'][key] = process_liquid(value) if value.is_a?(String)
|
||||
end
|
||||
end
|
||||
|
||||
def process_button_params(processed_params)
|
||||
return if processed_params['buttons'].blank?
|
||||
|
||||
processed_params['buttons'].each do |button|
|
||||
next if button.blank?
|
||||
|
||||
button['parameter'] = process_liquid(button['parameter']) if button['parameter'].is_a?(String)
|
||||
end
|
||||
end
|
||||
|
||||
def process_footer_params(processed_params)
|
||||
return if processed_params['footer'].blank?
|
||||
|
||||
processed_params['footer'].each do |key, value|
|
||||
processed_params['footer'][key] = process_liquid(value) if value.is_a?(String)
|
||||
end
|
||||
end
|
||||
|
||||
def process_liquid(value)
|
||||
return value if value.blank?
|
||||
|
||||
liquid_service.call(value)
|
||||
end
|
||||
|
||||
def liquid_service
|
||||
@liquid_service ||= Liquid::CampaignTemplateService.new(campaign: campaign, contact: contact)
|
||||
end
|
||||
|
||||
def deep_copy(obj)
|
||||
case obj
|
||||
when Hash
|
||||
obj.transform_values { |v| deep_copy(v) }
|
||||
when Array
|
||||
obj.map { |v| deep_copy(v) }
|
||||
else
|
||||
obj.duplicable? ? obj.dup : obj
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,7 @@ class Whatsapp::OneoffCampaignService
|
||||
return
|
||||
end
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number)
|
||||
send_whatsapp_template_message(to: contact.phone_number, contact: contact)
|
||||
end
|
||||
|
||||
def process_audience(audience_labels)
|
||||
@@ -70,10 +70,14 @@ class Whatsapp::OneoffCampaignService
|
||||
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:)
|
||||
def send_whatsapp_template_message(to:, contact:)
|
||||
# Process template params with liquid variables for this contact
|
||||
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
|
||||
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
|
||||
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: campaign.template_params
|
||||
template_params: processed_template_params
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
@@ -41,8 +41,7 @@ en:
|
||||
invalid_email: 'Please enter a valid email address'
|
||||
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists.
|
||||
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
|
||||
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
|
||||
saml_not_available: SAML authentication is not available in this installation.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::LiquidTemplateProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, name: 'Agent Smith') }
|
||||
let(:inbox) { create(:inbox, account: account, name: 'Support Inbox') }
|
||||
let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') }
|
||||
let(:campaign) { create(:campaign, account: account, inbox: inbox, sender: agent, message: 'Test message') }
|
||||
let(:service) { described_class.new(campaign: campaign, contact: contact) }
|
||||
|
||||
describe '#process_template_params' do
|
||||
context 'when template_params is blank' do
|
||||
it 'returns the original template_params' do
|
||||
result = service.process_template_params(nil)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processed_params is blank' do
|
||||
let(:template_params) { { 'name' => 'test_template' } }
|
||||
|
||||
it 'returns the original template_params' do
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to eq(template_params)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with body parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'namespace' => 'test_namespace',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => '{{contact.name}}',
|
||||
'email' => '{{contact.email}}',
|
||||
'static_text' => 'Hello World'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in body parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['body']['name']).to eq(contact_drop_name)
|
||||
expect(result['processed_params']['body']['email']).to eq(contact.email)
|
||||
expect(result['processed_params']['body']['static_text']).to eq('Hello World')
|
||||
end
|
||||
|
||||
it 'does not modify the original template_params' do
|
||||
original_name_value = template_params['processed_params']['body']['name']
|
||||
service.process_template_params(template_params)
|
||||
|
||||
expect(template_params['processed_params']['body']['name']).to eq(original_name_value)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with header parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'header' => {
|
||||
'media_url' => 'https://example.com/{{contact.name}}.jpg',
|
||||
'media_name' => '{{contact.name}}_document.pdf'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in header parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['header']['media_url']).to eq("https://example.com/#{contact_drop_name}.jpg")
|
||||
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_document.pdf")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with button parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'buttons' => [
|
||||
{ 'type' => 'url', 'parameter' => '{{contact.email}}' },
|
||||
{ 'type' => 'copy_code', 'parameter' => 'CODE-{{contact.name}}' }
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in button parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
|
||||
expect(result['processed_params']['buttons'][1]['parameter']).to eq("CODE-#{contact_drop_name}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with footer parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'footer' => {
|
||||
'text' => 'From {{agent.name}} at {{account.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in footer parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
agent_drop_name = UserDrop.new(agent).name
|
||||
|
||||
expect(result['processed_params']['footer']['text']).to eq("From #{agent_drop_name} at #{account.name}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple liquid variables across different sections' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'greeting' => 'Hello {{contact.name}}',
|
||||
'agent' => 'Your agent is {{agent.name}}'
|
||||
},
|
||||
'header' => {
|
||||
'media_name' => '{{contact.name}}_file.pdf'
|
||||
},
|
||||
'buttons' => [
|
||||
{ 'parameter' => '{{contact.email}}' }
|
||||
],
|
||||
'footer' => {
|
||||
'text' => '{{inbox.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes all liquid variables correctly' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
agent_drop_name = UserDrop.new(agent).name
|
||||
|
||||
expect(result['processed_params']['body']['greeting']).to eq("Hello #{contact_drop_name}")
|
||||
expect(result['processed_params']['body']['agent']).to eq("Your agent is #{agent_drop_name}")
|
||||
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_file.pdf")
|
||||
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
|
||||
expect(result['processed_params']['footer']['text']).to eq(inbox.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank or nil values' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => nil,
|
||||
'email' => '',
|
||||
'valid' => '{{contact.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'handles blank values gracefully' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['body']['name']).to be_nil
|
||||
expect(result['processed_params']['body']['email']).to eq('')
|
||||
expect(result['processed_params']['body']['valid']).to eq(contact_drop_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom attributes' do
|
||||
let(:contact) do
|
||||
create(:contact, account: account, name: 'John Doe',
|
||||
custom_attributes: { 'company' => 'Acme Inc', 'plan' => 'Premium' })
|
||||
end
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'company' => '{{contact.custom_attribute.company}}',
|
||||
'plan' => '{{contact.custom_attribute.plan}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes custom attribute liquid variables' do
|
||||
result = service.process_template_params(template_params)
|
||||
|
||||
expect(result['processed_params']['body']['company']).to eq('Acme Inc')
|
||||
expect(result['processed_params']['body']['plan']).to eq('Premium')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid liquid syntax' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'invalid' => '{{contact.name missing braces'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns original value when liquid parsing fails' do
|
||||
result = service.process_template_params(template_params)
|
||||
|
||||
expect(result['processed_params']['body']['invalid']).to eq('{{contact.name missing braces')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -139,6 +139,49 @@ describe Whatsapp::OneoffCampaignService do
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
end
|
||||
|
||||
it 'processes liquid variables in template parameters' do
|
||||
contact = create(:contact, :with_phone_number, account: account, name: 'Jane Smith', email: 'jane@example.com')
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
campaign_with_liquid = create(:campaign, inbox: whatsapp_inbox, account: account,
|
||||
audience: [{ type: 'Label', id: label1.id }],
|
||||
template_params: {
|
||||
'name' => 'ticket_status_updated',
|
||||
'namespace' => '23423423_2342423_324234234_2343224',
|
||||
'category' => 'UTILITY',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => '{{contact.name}}',
|
||||
'ticket_id' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template).with(
|
||||
contact.phone_number,
|
||||
hash_including(
|
||||
name: 'ticket_status_updated',
|
||||
namespace: '23423423_2342423_324234234_2343224',
|
||||
lang_code: 'en',
|
||||
parameters: array_including(
|
||||
hash_including(
|
||||
type: 'body',
|
||||
parameters: array_including(
|
||||
hash_including(type: 'text', parameter_name: 'name', text: contact_drop_name),
|
||||
hash_including(type: 'text', parameter_name: 'ticket_id', text: contact.email)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
nil
|
||||
)
|
||||
|
||||
described_class.new(campaign: campaign_with_liquid).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template_params is missing' do
|
||||
|
||||
Reference in New Issue
Block a user