Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Contacts::BulkActionService do
subject(:service) { described_class.new(account: account, user: user, params: params) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
describe '#perform' do
context 'when delete action is requested via action_name' do
let(:params) { { ids: [1, 2], action_name: 'delete' } }
it 'delegates to the bulk delete service' do
bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true)
expect(Contacts::BulkDeleteService).to receive(:new)
.with(account: account, contact_ids: [1, 2])
.and_return(bulk_delete_service)
service.perform
end
end
context 'when labels are provided' do
let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } }
it 'delegates to the bulk assign labels service with permitted params' do
bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true)
expect(Contacts::BulkAssignLabelsService).to receive(:new)
.with(account: account, contact_ids: [10, 20], labels: %w[vip support])
.and_return(bulk_assign_service)
service.perform
end
end
end
end

View File

@@ -0,0 +1,48 @@
require 'rails_helper'
RSpec.describe Contacts::BulkAssignLabelsService do
subject(:service) do
described_class.new(
account: account,
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
labels: labels
)
end
let(:account) { create(:account) }
let!(:contact_one) { create(:contact, account: account) }
let!(:contact_two) { create(:contact, account: account) }
let!(:other_contact) { create(:contact) }
let(:labels) { %w[vip support] }
it 'assigns labels to the contacts that belong to the account' do
service.perform
expect(contact_one.reload.label_list).to include(*labels)
expect(contact_two.reload.label_list).to include(*labels)
end
it 'does not assign labels to contacts outside the account' do
service.perform
expect(other_contact.reload.label_list).to be_empty
end
it 'returns ids of contacts that were updated' do
result = service.perform
expect(result[:success]).to be(true)
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
end
it 'returns success with no updates when labels are blank' do
result = described_class.new(
account: account,
contact_ids: [contact_one.id],
labels: []
).perform
expect(result).to eq(success: true, updated_contact_ids: [])
expect(contact_one.reload.label_list).to be_empty
end
end

View File

@@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe Contacts::BulkDeleteService do
subject(:service) { described_class.new(account: account, contact_ids: contact_ids) }
let(:account) { create(:account) }
let!(:contact_one) { create(:contact, account: account) }
let!(:contact_two) { create(:contact, account: account) }
let(:contact_ids) { [contact_one.id, contact_two.id] }
describe '#perform' do
it 'deletes the provided contacts' do
expect { service.perform }
.to change { account.contacts.exists?(contact_one.id) }.from(true).to(false)
.and change { account.contacts.exists?(contact_two.id) }.from(true).to(false)
end
it 'returns when no contact ids are provided' do
empty_service = described_class.new(account: account, contact_ids: [])
expect { empty_service.perform }.not_to change(Contact, :count)
end
end
end

View File

@@ -0,0 +1,70 @@
require 'rails_helper'
describe Contacts::ContactableInboxesService do
before do
stub_request(:post, /graph.facebook.com/)
end
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account, email: 'contact@example.com', phone_number: '+2320000') }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_sms_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
let!(:email_channel) { create(:channel_email, account: account) }
let!(:email_inbox) { create(:inbox, channel: email_channel, account: account) }
let!(:api_channel) { create(:channel_api, account: account) }
let!(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
let!(:website_inbox) { create(:inbox, channel: create(:channel_widget, account: account), account: account) }
let!(:sms_inbox) { create(:inbox, channel: create(:channel_sms, account: account), account: account) }
describe '#get' do
it 'returns the contactable inboxes for the contact' do
contactable_inboxes = described_class.new(contact: contact).get
expect(contactable_inboxes).to include({ source_id: contact.phone_number, inbox: twilio_sms_inbox })
expect(contactable_inboxes).to include({ source_id: "whatsapp:#{contact.phone_number}", inbox: twilio_whatsapp_inbox })
expect(contactable_inboxes).to include({ source_id: contact.email, inbox: email_inbox })
expect(contactable_inboxes).to include({ source_id: contact.phone_number, inbox: sms_inbox })
end
it 'doest not return the non contactable inboxes for the contact' do
facebook_channel = create(:channel_facebook_page, account: account)
facebook_inbox = create(:inbox, channel: facebook_channel, account: account)
twitter_channel = create(:channel_twitter_profile, account: account)
twitter_inbox = create(:inbox, channel: twitter_channel, account: account)
contactable_inboxes = described_class.new(contact: contact).get
expect(contactable_inboxes.pluck(:inbox)).not_to include(website_inbox)
expect(contactable_inboxes.pluck(:inbox)).not_to include(facebook_inbox)
expect(contactable_inboxes.pluck(:inbox)).not_to include(twitter_inbox)
end
context 'when api inbox is available' do
it 'returns existing source id if contact inbox exists' do
contact_inbox = create(:contact_inbox, inbox: api_inbox, contact: contact)
contactable_inboxes = described_class.new(contact: contact).get
expect(contactable_inboxes).to include({ source_id: contact_inbox.source_id, inbox: api_inbox })
end
end
context 'when website inbox is available' do
it 'returns existing source id if contact inbox exists without any conversations' do
contact_inbox = create(:contact_inbox, inbox: website_inbox, contact: contact)
contactable_inboxes = described_class.new(contact: contact).get
expect(contactable_inboxes).to include({ source_id: contact_inbox.source_id, inbox: website_inbox })
end
it 'does not return existing source id if contact inbox exists with conversations' do
contact_inbox = create(:contact_inbox, inbox: website_inbox, contact: contact)
create(:conversation, contact: contact, inbox: website_inbox, contact_inbox: contact_inbox)
contactable_inboxes = described_class.new(contact: contact).get
expect(contactable_inboxes.pluck(:inbox)).not_to include(website_inbox)
end
end
end
end

View File

@@ -0,0 +1,374 @@
require 'rails_helper'
describe Contacts::FilterService do
subject(:filter_service) { described_class }
let!(:account) { create(:account) }
let!(:first_user) { create(:user, account: account) }
let!(:second_user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
let!(:en_contact) do
create(:contact,
account: account,
email: Faker::Internet.unique.email,
additional_attributes: { 'country_code': 'uk' })
end
let!(:el_contact) do
create(:contact,
account: account,
email: Faker::Internet.unique.email,
additional_attributes: { 'country_code': 'gr' })
end
let!(:cs_contact) do
create(:contact,
:with_phone_number,
account: account,
email: Faker::Internet.unique.email,
additional_attributes: { 'country_code': 'cz' })
end
before do
create(:inbox_member, user: first_user, inbox: inbox)
create(:inbox_member, user: second_user, inbox: inbox)
create(:conversation, account: account, inbox: inbox, assignee: first_user, contact: en_contact)
create(:conversation, account: account, inbox: inbox, contact: el_contact)
create(:custom_attribute_definition,
attribute_key: 'contact_additional_information',
account: account,
attribute_model: 'contact_attribute',
attribute_display_type: 'text')
create(:custom_attribute_definition,
attribute_key: 'customer_type',
account: account,
attribute_model: 'contact_attribute',
attribute_display_type: 'list',
attribute_values: %w[regular platinum gold])
create(:custom_attribute_definition,
attribute_key: 'signed_in_at',
account: account,
attribute_model: 'contact_attribute',
attribute_display_type: 'date')
end
describe '#perform' do
let!(:params) { { payload: [], page: 1 } }
before do
en_contact.update_labels(%w[random_label support])
cs_contact.update_labels('support')
en_contact.update!(custom_attributes: { contact_additional_information: 'test custom data' })
el_contact.update!(custom_attributes: { contact_additional_information: 'test custom data', customer_type: 'platinum' })
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
end
context 'with standard attributes - name' do
it 'filter contacts by name' do
params[:payload] = [
{
attribute_key: 'name',
filter_operator: 'equal_to',
values: [en_contact.name],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:count]).to be 1
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.name).to eq(en_contact.name)
end
end
context 'with standard attributes - phone' do
it 'filter contacts by name' do
params[:payload] = [
{
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [cs_contact.phone_number],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:count]).to be 1
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.name).to eq(cs_contact.name)
end
end
context 'with standard attributes - phone (without +)' do
it 'filter contacts by name' do
params[:payload] = [
{
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [cs_contact.phone_number[1..]],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:count]).to be 1
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.name).to eq(cs_contact.name)
end
end
context 'with standard attributes - blocked' do
it 'filter contacts by blocked' do
blocked_contact = create(
:contact,
account: account,
blocked: true,
email: Faker::Internet.unique.email
)
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'],
query_operator: nil }.with_indifferent_access] }
result = filter_service.new(account, first_user, params).perform
expect(result[:count]).to be 1
expect(result[:contacts].first.id).to eq(blocked_contact.id)
end
it 'filter contacts by not_blocked' do
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: [false],
query_operator: nil }.with_indifferent_access] }
result = filter_service.new(account, first_user, params).perform
# existing contacts are not blocked
expect(result[:count]).to be 3
end
end
context 'with standard attributes - label' do
it 'returns equal_to filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: ['support'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be 2
expect(result[:contacts].first.label_list).to include('support')
expect(result[:contacts].last.label_list).to include('support')
end
it 'returns not_equal_to filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'not_equal_to',
values: ['support'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
it 'returns is_present filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_present',
values: [],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be 2
expect(result[:contacts].first.label_list).to include('support')
expect(result[:contacts].last.label_list).to include('support')
end
it 'returns is_not_present filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_not_present',
values: [],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
it 'handles invalid query conditions' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_not_present',
values: [],
query_operator: 'INVALID'
}.with_indifferent_access
]
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
end
end
context 'with standard attributes - last_activity_at' do
before do
Time.zone = 'UTC'
el_contact.update(last_activity_at: (Time.zone.today - 4.days))
cs_contact.update(last_activity_at: (Time.zone.today - 5.days))
en_contact.update(last_activity_at: (Time.zone.today - 2.days))
end
it 'filter by last_activity_at 3_days_before and custom_attributes' do
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'days_before',
values: [3],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'contact_additional_information',
filter_operator: 'equal_to',
values: ['test custom data'],
query_operator: nil
}.with_indifferent_access
]
expected_count = Contact.where(
"last_activity_at < ? AND
custom_attributes->>'contact_additional_information' = ?",
(Time.zone.today - 3.days),
'test custom data'
).count
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].first.id).to eq(el_contact.id)
end
it 'filter by last_activity_at 2_days_before and custom_attributes' do
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'days_before',
values: [2],
query_operator: nil
}.with_indifferent_access
]
expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
end
end
context 'with additional attributes' do
let(:payload) do
[
{
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: ['uk'],
query_operator: nil
}.with_indifferent_access
]
end
it 'filter contacts by additional_attributes' do
params[:payload] = payload
result = filter_service.new(account, first_user, params).perform
expect(result[:count]).to be 1
expect(result[:contacts].first.id).to eq(en_contact.id)
end
end
context 'with custom attributes' do
it 'filter by custom_attributes and labels' do
params[:payload] = [
{
attribute_key: 'customer_type',
filter_operator: 'equal_to',
values: ['platinum'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: ['support'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'signed_in_at',
filter_operator: 'is_less_than',
values: ['2022-01-20'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq(cs_contact.id)
end
it 'filter by custom_attributes and additional_attributes' do
params[:payload] = [
{
attribute_key: 'customer_type',
filter_operator: 'equal_to',
values: ['platinum'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: ['GR'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'contact_additional_information',
filter_operator: 'equal_to',
values: ['test custom data'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq(el_contact.id)
end
it 'filter by created_at and custom_attributes' do
tomorrow = Date.tomorrow.strftime
params[:payload] = [
{
attribute_key: 'customer_type',
filter_operator: 'equal_to',
values: ['platinum'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: [tomorrow.to_s],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expected_count = Contact.where("created_at < ? AND custom_attributes->>'customer_type' = ?", Date.tomorrow, 'platinum').count
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
end
end
end
end

View File

@@ -0,0 +1,46 @@
# spec/services/contacts/sync_attributes_spec.rb
require 'rails_helper'
RSpec.describe Contacts::SyncAttributes do
describe '#perform' do
let(:contact) { create(:contact, additional_attributes: { 'city' => 'New York', 'country' => 'US' }) }
context 'when contact has neither email/phone number nor social details' do
it 'does not change contact type' do
described_class.new(contact).perform
expect(contact.reload.contact_type).to eq('visitor')
end
end
context 'when contact has email or phone number' do
it 'sets contact type to lead' do
contact.email = 'test@test.com'
contact.save
described_class.new(contact).perform
expect(contact.reload.contact_type).to eq('lead')
end
end
context 'when contact has social details' do
it 'sets contact type to lead' do
contact.additional_attributes['social_facebook_user_id'] = '123456789'
contact.save
described_class.new(contact).perform
expect(contact.reload.contact_type).to eq('lead')
end
end
context 'when location and country code are updated from additional attributes' do
it 'updates location and country code' do
described_class.new(contact).perform
# Expect location and country code to be updated
expect(contact.reload.location).to eq('New York')
expect(contact.reload.country_code).to eq('US')
end
end
end
end