Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b165b624 | ||
|
|
ca9bedfba1 | ||
|
|
4edf626af2 | ||
|
|
e86222034e | ||
|
|
2b37fef0e0 | ||
|
|
44b32eacec | ||
|
|
5b7430f376 | ||
|
|
5c00a1601a | ||
|
|
a4be38f8f3 | ||
|
|
6fcc888aee | ||
|
|
c27a5916bb |
@@ -4,23 +4,23 @@ ruby '3.4.4'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||
gem 'rails', '~> 7.1', '>= 7.1.6'
|
||||
gem 'rails', '~> 7.1'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
##-- rails application helper gems --##
|
||||
gem 'acts-as-taggable-on', '>= 13.0.0'
|
||||
gem 'acts-as-taggable-on'
|
||||
gem 'attr_extras'
|
||||
gem 'browser'
|
||||
gem 'hashie'
|
||||
gem 'jbuilder', '>= 2.12.0'
|
||||
gem 'jbuilder'
|
||||
gem 'kaminari'
|
||||
gem 'responders', '>= 3.2.0'
|
||||
gem 'responders', '>= 3.1.1'
|
||||
gem 'rest-client'
|
||||
gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data', '>= 1.2023.4'
|
||||
gem 'valid_email2', '>= 5.3.0'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
gem 'gemoji'
|
||||
# compress javascript config.assets.js_compressor
|
||||
@@ -59,43 +59,43 @@ gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||
gem 'image_processing'
|
||||
|
||||
##-- for actionmailbox --##
|
||||
gem 'aws-actionmailbox-ses', '~> 0', '>= 0.1.1'
|
||||
gem 'aws-actionmailbox-ses', '~> 0'
|
||||
|
||||
##-- gems for database --#
|
||||
gem 'groupdate', '>= 6.3.0'
|
||||
gem 'groupdate'
|
||||
gem 'pg'
|
||||
gem 'redis'
|
||||
gem 'redis-namespace'
|
||||
# super fast record imports in bulk
|
||||
gem 'activerecord-import', '>= 2.2.0'
|
||||
gem 'activerecord-import'
|
||||
|
||||
gem 'searchkick', '>= 6.0.0'
|
||||
gem 'searchkick'
|
||||
gem 'opensearch-ruby'
|
||||
gem 'faraday_middleware-aws-sigv4'
|
||||
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.1.3'
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma', '~> 7.2', '>= 7.2.1'
|
||||
gem 'vite_rails', '>= 3.11.0'
|
||||
gem 'vite_rails'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
##--- gems for authentication & authorization ---##
|
||||
gem 'devise', '>= 5.0.0'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.6'
|
||||
gem 'rails-i18n', '~> 8.0', '>= 8.0.0'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 6.2.0'
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt', '~> 2.10', '>= 2.10.3'
|
||||
gem 'pundit', '>= 2.3.1'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
gem 'administrate', '>= 1.0.0'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.4'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.10.0'
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
|
||||
@@ -124,16 +124,16 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
# loaded only when environment variables are set.
|
||||
# ref application.rb
|
||||
gem 'datadog', '~> 2.0', require: false
|
||||
gem 'elastic-apm', '>= 4.7.0', require: false
|
||||
gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
gem 'scout_apm', require: false
|
||||
gem 'sentry-rails', '>= 5.20.0', require: false
|
||||
gem 'sentry-ruby', '>= 5.20.0', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.20.0', require: false
|
||||
gem 'sentry-rails', '>= 5.19.0', require: false
|
||||
gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '~> 7.3', '>= 7.3.2'
|
||||
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 2.4.0'
|
||||
# for sidekiq healthcheck
|
||||
@@ -150,7 +150,7 @@ gem 'geocoder'
|
||||
gem 'maxminddb'
|
||||
|
||||
# to create db triggers
|
||||
gem 'hairtrigger', '>= 1.1.0'
|
||||
gem 'hairtrigger'
|
||||
|
||||
gem 'procore-sift'
|
||||
|
||||
@@ -160,17 +160,17 @@ gem 'email_reply_trimmer'
|
||||
gem 'html2text'
|
||||
|
||||
# to calculate working hours
|
||||
gem 'working_hours', '>= 1.5.0'
|
||||
gem 'working_hours'
|
||||
|
||||
# full text search for articles
|
||||
gem 'pg_search', '>= 2.3.7'
|
||||
gem 'pg_search'
|
||||
|
||||
# Subscriptions, Billing
|
||||
gem 'stripe', '~> 18.0'
|
||||
|
||||
## - helper gems --##
|
||||
## to populate db with sample data
|
||||
gem 'faker', '>= 3.2.1'
|
||||
gem 'faker'
|
||||
|
||||
# Include logrange conditionally in intializer using env variable
|
||||
gem 'lograge', '~> 0.14.0', require: false
|
||||
@@ -178,17 +178,17 @@ gem 'lograge', '~> 0.14.0', require: false
|
||||
# worked with microsoft refresh token
|
||||
gem 'omniauth-oauth2'
|
||||
|
||||
gem 'audited', '~> 5.4', '>= 5.4.2'
|
||||
gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-saml'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 2.0', '>= 2.0.0'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
|
||||
## Gems for reponse bot
|
||||
# adds cosine similarity to postgres using vector extension
|
||||
gem 'neighbor', '>= 0.3.0'
|
||||
gem 'neighbor'
|
||||
gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
@@ -207,7 +207,7 @@ gem 'cld3', '~> 3.7'
|
||||
gem 'opentelemetry-sdk'
|
||||
gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api', '>= 14.9.0'
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||
|
||||
@@ -218,16 +218,16 @@ group :production do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
# for heroku autoscaling
|
||||
gem 'judoscale-rails', '>= 1.8.3', require: false
|
||||
gem 'judoscale-sidekiq', '>= 1.8.3', require: false
|
||||
gem 'judoscale-rails', require: false
|
||||
gem 'judoscale-sidekiq', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem 'annotaterb', '>= 4.21.0'
|
||||
gem 'bullet', '>= 8.0.8'
|
||||
gem 'annotaterb'
|
||||
gem 'bullet'
|
||||
gem 'letter_opener'
|
||||
gem 'scss_lint', require: false
|
||||
gem 'web-console', '>= 4.3.0'
|
||||
gem 'web-console', '>= 4.2.1'
|
||||
|
||||
# When we want to squash migrations
|
||||
gem 'squasher'
|
||||
@@ -236,14 +236,14 @@ group :development do
|
||||
gem 'rack-mini-profiler', '>= 3.2.0', require: false
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.6'
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
|
||||
gem 'tidewave', '>= 0.3.0'
|
||||
gem 'tidewave'
|
||||
end
|
||||
|
||||
group :test do
|
||||
# fast cleaning of database
|
||||
gem 'database_cleaner', '>= 2.1.0'
|
||||
gem 'database_cleaner'
|
||||
# mock http calls
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
@@ -260,19 +260,19 @@ group :development, :test do
|
||||
gem 'byebug', platform: :mri
|
||||
gem 'climate_control'
|
||||
gem 'debug', '~> 1.8'
|
||||
gem 'factory_bot_rails', '>= 6.4.4'
|
||||
gem 'factory_bot_rails', '>= 6.4.3'
|
||||
gem 'listen'
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails', '>= 7.0.2'
|
||||
gem 'rspec-rails', '>= 6.1.5'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', '>= 2.33.0', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'rubocop-rspec', require: false
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump', '>= 3.4.0'
|
||||
gem 'shoulda-matchers', '>= 6.0.0'
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'skooma'
|
||||
gem 'spring'
|
||||
|
||||
+8
-8
@@ -170,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (4.1.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -193,7 +193,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.5)
|
||||
concurrent-ruby (1.3.7)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -274,8 +274,8 @@ GEM
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
@@ -304,7 +304,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.2)
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -474,7 +474,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.8)
|
||||
json (2.19.9)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -627,7 +627,7 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.16.10)
|
||||
oj (3.17.3)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
@@ -674,7 +674,7 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
|
||||
@@ -6,6 +6,11 @@ class Messages::Messenger::MessageBuilder
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
params = attachment_params(attachment)
|
||||
# During Meta's sticker webhook transition, a sticker message carries both an `image`
|
||||
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
|
||||
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
|
||||
return if duplicate_sticker?(attachment, params[:external_url])
|
||||
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
if facebook_reel?(attachment)
|
||||
@@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_attachment_links(attachment_obj)
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def fetch_attachment_links(attachment_obj)
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
@@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
def duplicate_sticker?(attachment, url)
|
||||
return false unless attachment['type'].to_sym == :sticker
|
||||
return false if url.blank?
|
||||
|
||||
@message.attachments.any? { |existing| existing.external_url == url }
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
|
||||
@@ -21,6 +21,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@assignable_agents = @inbox.assignable_agents
|
||||
end
|
||||
|
||||
def assignable_owners
|
||||
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
|
||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||
end
|
||||
|
||||
def campaigns
|
||||
@campaigns = @inbox.campaigns
|
||||
end
|
||||
|
||||
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
assignAgent({ conversationId, agentId }) {
|
||||
assignAgent({ conversationId, agentId, assigneeType }) {
|
||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||
assignee_id: agentId,
|
||||
assignee_type: assigneeType,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
return axios.get(`${this.url}/${inboxId}/campaigns`);
|
||||
}
|
||||
|
||||
getAssignableOwners(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
|
||||
}
|
||||
|
||||
deleteInboxAvatar(inboxId) {
|
||||
return axios.delete(`${this.url}/${inboxId}/avatar`);
|
||||
}
|
||||
|
||||
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
|
||||
});
|
||||
|
||||
it('#assignAgent', () => {
|
||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||
conversationAPI.assignAgent({
|
||||
conversationId: 12,
|
||||
agentId: 34,
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/assignments`,
|
||||
{
|
||||
assignee_id: 34,
|
||||
assignee_type: 'AgentBot',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('create');
|
||||
expect(inboxesAPI).toHaveProperty('update');
|
||||
expect(inboxesAPI).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
@@ -37,6 +38,13 @@ describe('#InboxesAPI', () => {
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
|
||||
});
|
||||
|
||||
it('#getAssignableOwners', () => {
|
||||
inboxesAPI.getAssignableOwners(2);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/assignable_owners'
|
||||
);
|
||||
});
|
||||
|
||||
it('#deleteInboxAvatar', () => {
|
||||
inboxesAPI.deleteInboxAvatar(2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||
|
||||
@@ -31,6 +31,7 @@ const mockUseMapGetter = (overrides = {}) => {
|
||||
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
||||
getCurrentAccountId: ref(1),
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
|
||||
};
|
||||
|
||||
const mergedGetters = { ...defaultGetters, ...overrides };
|
||||
@@ -75,6 +76,32 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('includes agent bots when includeAgentBots is true', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableOwners': ref(() => [
|
||||
{ id: 2, name: 'Agent', assignee_type: 'User' },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
thumbnail: '',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList(true, true);
|
||||
|
||||
expect(agentsList.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Bot',
|
||||
assignee_type: 'AgentBot',
|
||||
icon: 'i-lucide-bot',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -10,14 +10,21 @@ import {
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
*
|
||||
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
|
||||
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
|
||||
* @returns {Object} An object containing the agents list and assignable agents.
|
||||
*/
|
||||
export function useAgentsList(includeNoneAgent = true) {
|
||||
export function useAgentsList(
|
||||
includeNoneAgent = true,
|
||||
includeAgentBots = false
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
||||
const assignableOwners = useMapGetter(
|
||||
'inboxAssignableAgents/getAssignableOwners'
|
||||
);
|
||||
|
||||
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||
@@ -42,11 +49,22 @@ export function useAgentsList(includeNoneAgent = true) {
|
||||
return inboxId.value ? assignable.value(inboxId.value) : [];
|
||||
});
|
||||
|
||||
const owners = computed(() => {
|
||||
return includeAgentBots && inboxId.value
|
||||
? assignableOwners.value(inboxId.value)
|
||||
: [];
|
||||
});
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agents = includeAgentBots
|
||||
? owners.value.filter(owner => owner.assignee_type === 'User')
|
||||
: assignableAgents.value || [];
|
||||
const bots = owners.value.filter(
|
||||
owner => owner.assignee_type === 'AgentBot'
|
||||
);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
@@ -60,6 +78,7 @@ export function useAgentsList(includeNoneAgent = true) {
|
||||
return [
|
||||
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
...bots,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { agentsList } = useAgentsList();
|
||||
const { agentsList } = useAgentsList(true, true);
|
||||
return {
|
||||
agentsList,
|
||||
};
|
||||
@@ -81,18 +81,27 @@ export default {
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
return this.currentChat.meta.assignee;
|
||||
const assignee = this.currentChat.meta.assignee;
|
||||
if (!assignee) return assignee;
|
||||
|
||||
return {
|
||||
...assignee,
|
||||
assignee_type: this.currentChat.meta.assignee_type || 'User',
|
||||
};
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : null;
|
||||
const assigneeType = agent ? agent.assignee_type || 'User' : null;
|
||||
this.$store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: this.currentChat.id,
|
||||
assignee: agent,
|
||||
assigneeType,
|
||||
});
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
assigneeType,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
@@ -158,6 +167,12 @@ export default {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch(
|
||||
'inboxAssignableAgents/fetchAssignableOwners',
|
||||
this.currentChat.inbox_id
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
onSelfAssign() {
|
||||
const {
|
||||
@@ -174,6 +189,7 @@ export default {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
assignee_type: 'User',
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
@@ -183,7 +199,14 @@ export default {
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
onClickAssignAgent(selectedItem) {
|
||||
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
|
||||
const currentAssigneeType = this.assignedAgent?.assignee_type || 'User';
|
||||
const selectedAssigneeType = selectedItem.assignee_type || 'User';
|
||||
|
||||
if (
|
||||
this.assignedAgent &&
|
||||
this.assignedAgent.id === selectedItem.id &&
|
||||
currentAssigneeType === selectedAssigneeType
|
||||
) {
|
||||
this.assignedAgent = null;
|
||||
} else {
|
||||
this.assignedAgent = selectedItem;
|
||||
|
||||
@@ -208,23 +208,31 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
|
||||
assignAgent: async (
|
||||
{ dispatch },
|
||||
{ conversationId, agentId, assigneeType }
|
||||
) => {
|
||||
try {
|
||||
const response = await ConversationApi.assignAgent({
|
||||
conversationId,
|
||||
agentId,
|
||||
assigneeType,
|
||||
});
|
||||
dispatch('setCurrentChatAssignee', {
|
||||
conversationId,
|
||||
assignee: response.data,
|
||||
assigneeType,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||
setCurrentChatAssignee(
|
||||
{ commit },
|
||||
{ conversationId, assignee, assigneeType }
|
||||
) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
|
||||
},
|
||||
|
||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
||||
|
||||
@@ -108,10 +108,16 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.meta.assignee = assignee;
|
||||
chat.meta.assignee_type = assigneeType;
|
||||
if (assigneeType === 'AgentBot') {
|
||||
chat.status = 'pending';
|
||||
} else if (assignee) {
|
||||
chat.status = 'open';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import AssignableAgentsAPI from '../../api/assignableAgents';
|
||||
import InboxesAPI from '../../api/inboxes';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
ownerRecords: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
@@ -10,6 +12,7 @@ const state = {
|
||||
export const types = {
|
||||
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
|
||||
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
|
||||
SET_INBOX_ASSIGNABLE_OWNERS: 'SET_INBOX_ASSIGNABLE_OWNERS',
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -18,6 +21,7 @@ export const getters = {
|
||||
const verifiedAgents = allAgents.filter(record => record.confirmed);
|
||||
return verifiedAgents;
|
||||
},
|
||||
getAssignableOwners: $state => inboxId => $state.ownerRecords[inboxId] || [],
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
@@ -40,6 +44,15 @@ export const actions = {
|
||||
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
async fetchAssignableOwners({ commit }, inboxId) {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await InboxesAPI.getAssignableOwners(inboxId);
|
||||
commit(types.SET_INBOX_ASSIGNABLE_OWNERS, {
|
||||
inboxId,
|
||||
owners: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -55,6 +68,12 @@ export const mutations = {
|
||||
[inboxId]: members,
|
||||
};
|
||||
},
|
||||
[types.SET_INBOX_ASSIGNABLE_OWNERS]: ($state, { inboxId, owners }) => {
|
||||
$state.ownerRecords = {
|
||||
...$state.ownerRecords,
|
||||
[inboxId]: owners,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -357,11 +357,12 @@ describe('#actions', () => {
|
||||
});
|
||||
await actions.assignAgent(
|
||||
{ dispatch },
|
||||
{ conversationId: 1, agentId: 1 }
|
||||
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -371,6 +372,7 @@ describe('#actions', () => {
|
||||
const payload = {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
assigneeType: 'AgentBot',
|
||||
};
|
||||
await actions.setCurrentChatAssignee({ commit }, payload);
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -699,11 +699,11 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#ASSIGN_AGENT', () => {
|
||||
it('should assign agent to the correct conversation by ID', () => {
|
||||
it('should assign agent bot to the correct conversation by ID', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, meta: {} },
|
||||
{ id: 1, meta: {}, status: 'open' },
|
||||
{ id: 2, meta: {} },
|
||||
],
|
||||
selectedChatId: 2,
|
||||
@@ -712,10 +712,28 @@ describe('#mutations', () => {
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'AgentBot',
|
||||
});
|
||||
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
|
||||
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
|
||||
expect(state.allConversations[0].status).toEqual('pending');
|
||||
expect(state.allConversations[1].meta.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should open the conversation when assigning a user', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: {}, status: 'pending' }],
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'User',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].status).toEqual('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ASSIGN_PRIORITY', () => {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import axios from 'axios';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { actions, types } from '../../inboxAssignableAgents';
|
||||
import agentsData from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
vi.mock('dashboard/api/inboxes');
|
||||
|
||||
describe('#actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('#fetch', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
@@ -33,4 +39,20 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchAssignableOwners', () => {
|
||||
it('stores assignable owners for the inbox', async () => {
|
||||
InboxesAPI.getAssignableOwners.mockResolvedValue({
|
||||
data: { payload: agentsData },
|
||||
});
|
||||
|
||||
await actions.fetchAssignableOwners({ commit }, 1);
|
||||
|
||||
expect(InboxesAPI.getAssignableOwners).toHaveBeenCalledWith(1);
|
||||
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_OWNERS, {
|
||||
inboxId: 1,
|
||||
owners: agentsData,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,4 +13,16 @@ describe('#mutations', () => {
|
||||
expect(state.records).toEqual({ 1: agentsData });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_INBOX_ASSIGNABLE_OWNERS', () => {
|
||||
it('adds inbox owners to ownerRecords', () => {
|
||||
const state = { ownerRecords: {} };
|
||||
mutations[types.SET_INBOX_ASSIGNABLE_OWNERS](state, {
|
||||
owners: [...agentsData],
|
||||
inboxId: 1,
|
||||
});
|
||||
|
||||
expect(state.ownerRecords).toEqual({ 1: agentsData });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,7 +67,13 @@ export default {
|
||||
this.$refs.searchbar.focus();
|
||||
},
|
||||
isActive(option) {
|
||||
return this.selectedItems.some(item => item && option.id === item.id);
|
||||
return this.selectedItems.some(item => {
|
||||
if (!item || option.id !== item.id) return false;
|
||||
|
||||
return (
|
||||
(option.assignee_type || 'User') === (item.assignee_type || 'User')
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -88,7 +94,10 @@ export default {
|
||||
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
|
||||
<div class="w-full max-h-[10rem]">
|
||||
<WootDropdownMenu>
|
||||
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
|
||||
<WootDropdownItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="`${option.assignee_type || 'User'}-${option.id}`"
|
||||
>
|
||||
<NextButton
|
||||
slate
|
||||
:variant="isActive(option) ? 'faded' : 'ghost'"
|
||||
|
||||
@@ -30,6 +30,10 @@ class InboxPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def assignable_owners?
|
||||
true
|
||||
end
|
||||
|
||||
def agent_bot?
|
||||
true
|
||||
end
|
||||
|
||||
@@ -16,6 +16,7 @@ class Conversations::AssignmentService
|
||||
def assign_agent
|
||||
conversation.assignee = assignee
|
||||
conversation.assignee_agent_bot = nil
|
||||
conversation.status = :open if assignee
|
||||
conversation.save!
|
||||
assignee
|
||||
end
|
||||
@@ -25,6 +26,7 @@ class Conversations::AssignmentService
|
||||
|
||||
conversation.assignee = nil
|
||||
conversation.assignee_agent_bot = agent_bot
|
||||
conversation.status = :pending
|
||||
conversation.save!
|
||||
agent_bot
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
owners = @assignable_agents.map { |agent| { type: 'User', resource: agent } }
|
||||
owners += @agent_bots.map { |agent_bot| { type: 'AgentBot', resource: agent_bot } }
|
||||
|
||||
json.payload do
|
||||
json.array! owners do |owner|
|
||||
if owner[:type] == 'User'
|
||||
json.partial! 'api/v1/models/agent', formats: [:json], resource: owner[:resource]
|
||||
json.assignee_type 'User'
|
||||
else
|
||||
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: owner[:resource]
|
||||
json.assignee_type 'AgentBot'
|
||||
json.icon 'i-lucide-bot'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -254,6 +254,7 @@ Rails.application.routes.draw do
|
||||
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||
get :assignable_agents, on: :member
|
||||
get :assignable_owners, on: :member
|
||||
get :campaigns, on: :member
|
||||
get :agent_bot, on: :member
|
||||
post :set_agent_bot, on: :member
|
||||
|
||||
@@ -49,7 +49,7 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
||||
end
|
||||
|
||||
def destroy
|
||||
@company.destroy!
|
||||
Companies::DeleteJob.perform_later(company_id: @company.id)
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
+13
@@ -29,6 +29,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
|
||||
private
|
||||
|
||||
def create_account_for_user
|
||||
super
|
||||
record_marketing_attribution
|
||||
end
|
||||
|
||||
def record_marketing_attribution
|
||||
return if @account.blank?
|
||||
|
||||
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def handle_saml_auth
|
||||
account_id = extract_saml_account_id
|
||||
relay_state = saml_relay_state
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
class Companies::DeleteJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
CONTACT_COMPANY_CLEAR_SQL = <<~SQL.squish.freeze
|
||||
company_id = NULL,
|
||||
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) - 'company_name'
|
||||
SQL
|
||||
|
||||
def perform(company_id:)
|
||||
company = Company.find_by(id: company_id)
|
||||
return if company.blank?
|
||||
|
||||
clear_contact_company_names(company)
|
||||
company.destroy!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Avoid contact callbacks so this cleanup does not dispatch contact automations/webhooks.
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
def clear_contact_company_names(company)
|
||||
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
|
||||
contacts.update_all(CONTACT_COMPANY_CLEAR_SQL)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
class Companies::SyncContactNamesJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
CONTACT_COMPANY_NAME_UPDATE_SQL = <<~SQL.squish.freeze
|
||||
additional_attributes = jsonb_set(
|
||||
COALESCE(additional_attributes, '{}'::jsonb),
|
||||
'{company_name}',
|
||||
?::jsonb,
|
||||
true
|
||||
)
|
||||
SQL
|
||||
|
||||
def perform(company_id:)
|
||||
return if company_id.blank?
|
||||
|
||||
company = Company.find_by(id: company_id)
|
||||
return if company.blank?
|
||||
|
||||
sync_company_name(company)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Denormalized display field sync; avoid contact validations, callbacks, and webhook/automation side effects.
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
def sync_company_name(company)
|
||||
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
|
||||
contacts.update_all([CONTACT_COMPANY_NAME_UPDATE_SQL, company.name.to_json])
|
||||
end
|
||||
end
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
@@ -39,6 +39,7 @@ class Company < ApplicationRecord
|
||||
has_many :contacts, dependent: :nullify
|
||||
before_validation :prepare_jsonb_attributes
|
||||
after_create_commit :fetch_favicon, if: -> { domain.present? }
|
||||
after_update_commit :enqueue_contact_company_name_sync, if: :saved_change_to_name?
|
||||
|
||||
scope :ordered_by_name, -> { order(:name) }
|
||||
scope :search_by_name_or_domain, lambda { |query|
|
||||
@@ -76,4 +77,8 @@ class Company < ApplicationRecord
|
||||
def fetch_favicon
|
||||
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
||||
end
|
||||
|
||||
def enqueue_contact_company_name_sync
|
||||
Companies::SyncContactNamesJob.perform_later(company_id: id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
channel_instagram
|
||||
channel_tiktok
|
||||
captain_integration
|
||||
captain_document_auto_sync
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
- sla
|
||||
- custom_roles
|
||||
- captain_integration
|
||||
- captain_document_auto_sync
|
||||
- csat_review_notes
|
||||
- conversation_required_attributes
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"date-fns": "2.21.1",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"dompurify": "3.4.0",
|
||||
"dompurify": "3.4.11",
|
||||
"flag-icons": "^7.2.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
|
||||
Generated
+15
-7
@@ -130,8 +130,8 @@ importers:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.8(date-fns@2.21.1)
|
||||
dompurify:
|
||||
specifier: 3.4.0
|
||||
version: 3.4.0
|
||||
specifier: 3.4.11
|
||||
version: 3.4.11
|
||||
flag-icons:
|
||||
specifier: ^7.2.3
|
||||
version: 7.2.3
|
||||
@@ -1635,6 +1635,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.17.0:
|
||||
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
activestorage@5.2.8:
|
||||
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
|
||||
|
||||
@@ -2218,8 +2223,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.4.0:
|
||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||
dompurify@3.4.11:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
|
||||
domutils@3.1.0:
|
||||
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
||||
@@ -6380,6 +6385,9 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
acorn@8.17.0:
|
||||
optional: true
|
||||
|
||||
activestorage@5.2.8:
|
||||
dependencies:
|
||||
spark-md5: 3.0.2
|
||||
@@ -6999,7 +7007,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.4.0:
|
||||
dompurify@3.4.11:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -9511,7 +9519,7 @@ snapshots:
|
||||
terser@5.33.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.16.0
|
||||
acorn: 8.17.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
@@ -9898,7 +9906,7 @@ snapshots:
|
||||
|
||||
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
dompurify: 3.4.0
|
||||
dompurify: 3.4.11
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.0):
|
||||
|
||||
@@ -140,6 +140,66 @@ describe Messages::Facebook::MessageBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains a sticker attachment' do
|
||||
let(:sticker_url) { 'https://scontent.xx.fbcdn.net/sticker.png' }
|
||||
let(:sticker_message_object) do
|
||||
{
|
||||
messaging: {
|
||||
sender: { id: '3383290475046708' },
|
||||
recipient: { id: facebook_channel.page_id },
|
||||
timestamp: 1_772_452_164_516,
|
||||
message: {
|
||||
mid: 'm_sticker_test',
|
||||
attachments: [
|
||||
{ type: 'image', payload: { url: sticker_url } },
|
||||
{ type: 'sticker', payload: { url: sticker_url } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
let(:sticker_message) { Integrations::Facebook::MessageParser.new(sticker_message_object) }
|
||||
|
||||
before do
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
||||
)
|
||||
stub_request(:get, sticker_url).to_return(status: 200, body: 'sticker_data', headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
|
||||
it 'stores the sticker as a single image attachment' do
|
||||
described_class.new(sticker_message, facebook_channel.inbox).perform
|
||||
|
||||
message = facebook_channel.inbox.messages.find_by(source_id: 'm_sticker_test')
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('image')
|
||||
expect(message.attachments.first.external_url).to eq(sticker_url)
|
||||
end
|
||||
|
||||
it 'keeps duplicate non-sticker attachments that share a URL' do
|
||||
duplicate_image_object = {
|
||||
messaging: {
|
||||
sender: { id: '3383290475046708' },
|
||||
recipient: { id: facebook_channel.page_id },
|
||||
message: {
|
||||
mid: 'm_duplicate_image_test',
|
||||
attachments: [
|
||||
{ type: 'image', payload: { url: sticker_url } },
|
||||
{ type: 'image', payload: { url: sticker_url } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
duplicate_image_message = Integrations::Facebook::MessageParser.new(duplicate_image_object)
|
||||
|
||||
described_class.new(duplicate_image_message, facebook_channel.inbox).perform
|
||||
|
||||
message = facebook_channel.inbox.messages.find_by(source_id: 'm_duplicate_image_test')
|
||||
expect(message.attachments.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
{
|
||||
source_id: 'm_fallback_test',
|
||||
|
||||
@@ -44,6 +44,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
end
|
||||
|
||||
it 'assigns a user to the conversation' do
|
||||
conversation.update!(status: :pending)
|
||||
params = { assignee_id: agent.id }
|
||||
|
||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
@@ -52,10 +53,13 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
conversation.reload
|
||||
expect(conversation.assignee).to eq(agent)
|
||||
expect(conversation.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'assigns an agent bot to the conversation' do
|
||||
conversation.update!(status: :open)
|
||||
params = { assignee_id: agent_bot.id, assignee_type: 'AgentBot' }
|
||||
|
||||
expect(Conversations::AssignmentService).to receive(:new)
|
||||
@@ -72,6 +76,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
conversation.reload
|
||||
expect(conversation.assignee_agent_bot).to eq(agent_bot)
|
||||
expect(conversation.assignee).to be_nil
|
||||
expect(conversation.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'assigns a team to the conversation' do
|
||||
|
||||
@@ -251,6 +251,28 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/assignable_owners' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
|
||||
let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
end
|
||||
|
||||
it 'returns assignable agents and accessible agent bots' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignable_owners",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
response_data = response.parsed_body['payload']
|
||||
expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
|
||||
expect(response_data.pluck('name')).to include(agent.name, admin.name, account_bot.name, global_bot.name)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/campaigns' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
|
||||
@@ -385,13 +385,13 @@ RSpec.describe 'Companies API', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
it 'deletes the company' do
|
||||
company
|
||||
it 'enqueues company deletion' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Company, :count).by(-1)
|
||||
end.to have_enqueued_job(Companies::DeleteJob).with(company_id: company.id)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
require 'base64'
|
||||
|
||||
RSpec.describe 'Enterprise Google OAuth attribution', type: :request do
|
||||
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
|
||||
let(:email) { 'oauth-attribution@example.com' }
|
||||
let(:account_builder) { double }
|
||||
let(:account) { create(:account) }
|
||||
let(:first_touch_cookie) { encoded_cookie('source' => 'reddit', 'source_type' => 'paid_social') }
|
||||
let(:last_touch_cookie) { encoded_cookie('source' => 'github', 'source_type' => 'referral') }
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:enterprise?).and_return(true)
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
|
||||
allow(email_validation_service).to receive(:perform).and_return(true)
|
||||
allow(AccountBuilder).to receive(:new).and_return(account_builder)
|
||||
allow(account_builder).to receive(:perform) do
|
||||
[create(:user, email: email, account: account), account]
|
||||
end
|
||||
|
||||
OmniAuth.config.test_mode = true
|
||||
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
|
||||
provider: 'google',
|
||||
uid: '123545',
|
||||
info: {
|
||||
name: 'OAuth Attribution',
|
||||
email: email,
|
||||
image: 'https://example.com/image.jpg'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'records marketing attribution for Google OAuth signups' do
|
||||
cookies[Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE] = first_touch_cookie
|
||||
cookies[Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE] = last_touch_cookie
|
||||
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
|
||||
get '/omniauth/google_oauth2/callback'
|
||||
follow_redirect!
|
||||
end
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
|
||||
expect(attribution['captured_from']).to eq('cookie')
|
||||
expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
|
||||
expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
|
||||
end
|
||||
|
||||
def encoded_cookie(payload)
|
||||
Base64.urlsafe_encode64(payload.to_json, padding: false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Companies::DeleteJob, type: :job do
|
||||
describe '#perform' do
|
||||
it 'unlinks contacts, clears company names, and deletes the company' do
|
||||
account = create(:account)
|
||||
company = create(:company, account: account, name: 'Acme')
|
||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
|
||||
other_contact = create(:contact, account: account, additional_attributes: { 'company_name' => 'Acme' })
|
||||
|
||||
described_class.perform_now(company_id: company.id)
|
||||
|
||||
expect { company.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
expect(contact.reload.company_id).to be_nil
|
||||
expect(contact.additional_attributes).to eq('city' => 'Berlin')
|
||||
expect(other_contact.reload.additional_attributes).to eq('company_name' => 'Acme')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Companies::SyncContactNamesJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:company) { create(:company, account: account, name: 'Acme') }
|
||||
|
||||
describe '#perform' do
|
||||
it 'updates linked contact company names' do
|
||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
|
||||
|
||||
company.update!(name: 'Acme Labs')
|
||||
|
||||
described_class.perform_now(company_id: company.id)
|
||||
|
||||
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs', 'city' => 'Berlin')
|
||||
end
|
||||
|
||||
it 'uses the current company name when a stale rename job runs' do
|
||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
|
||||
company.update!(name: 'Acme Labs')
|
||||
|
||||
described_class.perform_now(company_id: company.id)
|
||||
|
||||
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs')
|
||||
end
|
||||
|
||||
it 'does not save contacts while syncing the denormalized company name' do
|
||||
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
|
||||
original_updated_at = contact.reload.updated_at
|
||||
|
||||
company.update!(name: 'Acme Labs')
|
||||
|
||||
described_class.perform_now(company_id: company.id)
|
||||
|
||||
expect(contact.reload.updated_at).to eq(original_updated_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -46,4 +46,15 @@ RSpec.describe Company, type: :model do
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'contact company name sync' do
|
||||
let(:account) { create(:account) }
|
||||
let(:company) { create(:company, account: account, name: 'Acme') }
|
||||
|
||||
it 'enqueues contact company name sync when the company name changes' do
|
||||
expect do
|
||||
company.update!(name: 'Acme Labs')
|
||||
end.to have_enqueued_job(Companies::SyncContactNamesJob).with(company_id: company.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,16 +23,17 @@ describe Conversations::AssignmentService do
|
||||
|
||||
context 'when assigning a user' do
|
||||
before do
|
||||
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
|
||||
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil, status: :pending)
|
||||
end
|
||||
|
||||
it 'sets the agent and clears agent bot' do
|
||||
it 'sets the agent, clears agent bot and opens the conversation' do
|
||||
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
|
||||
|
||||
conversation.reload
|
||||
expect(result).to eq(agent)
|
||||
expect(conversation.assignee_id).to eq(agent.id)
|
||||
expect(conversation.assignee_agent_bot_id).to be_nil
|
||||
expect(conversation.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,8 +46,8 @@ describe Conversations::AssignmentService do
|
||||
)
|
||||
end
|
||||
|
||||
it 'sets the agent bot and clears human assignee' do
|
||||
conversation.update!(assignee: agent, assignee_agent_bot: nil)
|
||||
it 'sets the agent bot, clears human assignee and marks the conversation pending' do
|
||||
conversation.update!(assignee: agent, assignee_agent_bot: nil, status: :open)
|
||||
|
||||
result = service.perform
|
||||
|
||||
@@ -54,6 +55,7 @@ describe Conversations::AssignmentService do
|
||||
expect(result).to eq(agent_bot)
|
||||
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
|
||||
expect(conversation.assignee_id).to be_nil
|
||||
expect(conversation.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user