diff --git a/.circleci/config.yml b/.circleci/config.yml
index 09bd5191d..c0320652b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
- parallelism: 16
+ parallelism: 20
steps:
- checkout
- node/install:
diff --git a/.env.example b/.env.example
index 55750b2f2..bc7380a29 100644
--- a/.env.example
+++ b/.env.example
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
+# REDIS_VELMA_SIZE=10
diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb
index 8b40ba3c9..8045e84c9 100644
--- a/app/builders/messages/instagram/base_message_builder.rb
+++ b/app/builders/messages/instagram/base_message_builder.rb
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
+ create_story_reply_attachment(story_reply_attributes['url'])
+ end
+
+ def create_story_reply_attachment(story_url)
+ return if story_url.blank?
+
+ attachment = @message.attachments.new(
+ file_type: :ig_story,
+ account_id: @message.account_id,
+ external_url: story_url
+ )
+ attachment.save!
+ begin
+ attach_file(attachment, story_url)
+ rescue Down::Error, StandardError => e
+ Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
+ end
+ @message.content_attributes[:image_type] = 'ig_story_reply'
+ @message.save!
end
def build_conversation
@@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
+ status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
+ params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
diff --git a/app/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb
new file mode 100644
index 000000000..971542596
--- /dev/null
+++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb
@@ -0,0 +1,68 @@
+class V2::Reports::FirstResponseTimeDistributionBuilder
+ include DateRangeHelper
+
+ attr_reader :account, :params
+
+ def initialize(account:, params:)
+ @account = account
+ @params = params
+ end
+
+ def build
+ build_distribution
+ end
+
+ private
+
+ def build_distribution
+ results = fetch_aggregated_counts
+ map_to_channel_types(results)
+ end
+
+ def fetch_aggregated_counts
+ ReportingEvent
+ .where(account_id: account.id, name: 'first_response')
+ .where(range_condition)
+ .group(:inbox_id)
+ .select(
+ :inbox_id,
+ bucket_case_statements
+ )
+ end
+
+ def bucket_case_statements
+ <<~SQL.squish
+ COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
+ COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
+ COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
+ COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
+ COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
+ SQL
+ end
+
+ def range_condition
+ range.present? ? { created_at: range } : {}
+ end
+
+ def inbox_channel_types
+ @inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
+ end
+
+ def map_to_channel_types(results)
+ results.each_with_object({}) do |row, hash|
+ channel_type = inbox_channel_types[row.inbox_id]
+ next unless channel_type
+
+ hash[channel_type] ||= empty_buckets
+ hash[channel_type]['0-1h'] += row.bucket_0_1h
+ hash[channel_type]['1-4h'] += row.bucket_1_4h
+ hash[channel_type]['4-8h'] += row.bucket_4_8h
+ hash[channel_type]['8-24h'] += row.bucket_8_24h
+ hash[channel_type]['24h+'] += row.bucket_24h_plus
+ end
+ end
+
+ def empty_buckets
+ { '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
+ end
+end
diff --git a/app/builders/v2/reports/inbox_label_matrix_builder.rb b/app/builders/v2/reports/inbox_label_matrix_builder.rb
new file mode 100644
index 000000000..c3715019d
--- /dev/null
+++ b/app/builders/v2/reports/inbox_label_matrix_builder.rb
@@ -0,0 +1,65 @@
+class V2::Reports::InboxLabelMatrixBuilder
+ include DateRangeHelper
+
+ attr_reader :account, :params
+
+ def initialize(account:, params:)
+ @account = account
+ @params = params
+ end
+
+ def build
+ {
+ inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
+ labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
+ matrix: build_matrix
+ }
+ end
+
+ private
+
+ def filtered_inboxes
+ @filtered_inboxes ||= begin
+ inboxes = account.inboxes
+ inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
+ inboxes.order(:name).to_a
+ end
+ end
+
+ def filtered_labels
+ @filtered_labels ||= begin
+ labels = account.labels
+ labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
+ labels.order(:title).to_a
+ end
+ end
+
+ def conversation_filter
+ filter = { account_id: account.id }
+ filter[:created_at] = range if range.present?
+ filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
+ filter
+ end
+
+ def fetch_grouped_counts
+ label_names = filtered_labels.map(&:title)
+ return {} if label_names.empty?
+
+ ActsAsTaggableOn::Tagging
+ .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
+ .joins('INNER JOIN tags ON taggings.tag_id = tags.id')
+ .where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
+ .where(tags: { name: label_names })
+ .group('conversations.inbox_id', 'tags.name')
+ .count
+ end
+
+ def build_matrix
+ counts = fetch_grouped_counts
+ filtered_inboxes.map do |inbox|
+ filtered_labels.map do |label|
+ counts[[inbox.id, label.title]] || 0
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb
index 86e21d9fd..14d4f2c89 100644
--- a/app/controllers/api/v1/accounts/contacts_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts_controller.rb
@@ -28,8 +28,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
- @contacts = fetch_contacts(contacts)
- @contacts_count = @contacts.total_count
+ @contacts = fetch_contacts_with_has_more(contacts)
end
def import
@@ -142,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
+ def fetch_contacts_with_has_more(contacts)
+ includes_hash = { avatar_attachment: [:blob] }
+ includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
+
+ # Calculate offset manually to fetch one extra record for has_more check
+ offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
+ results = filtrate(contacts)
+ .includes(includes_hash)
+ .offset(offset)
+ .limit(RESULTS_PER_PAGE + 1)
+ .to_a
+
+ @has_more = results.size > RESULTS_PER_PAGE
+ results = results.first(RESULTS_PER_PAGE) if @has_more
+ @contacts_count = results.size
+ results
+ end
+
def build_contact_inbox
return if params[:inbox_id].blank?
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 57062a5b2..bcbf80355 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
- params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
- conversation_required_attributes: [])
+ params.permit(*permitted_settings_attributes)
+ end
+
+ def permitted_settings_attributes
+ [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled
@@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
+
+Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index 714aeb0c9..ddd629048 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -62,6 +62,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
+ def inbox_label_matrix
+ builder = V2::Reports::InboxLabelMatrixBuilder.new(
+ account: Current.account,
+ params: inbox_label_matrix_params
+ )
+ render json: builder.build
+ end
+
+ def first_response_time_distribution
+ builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
+ account: Current.account,
+ params: first_response_time_distribution_params
+ )
+ render json: builder.build
+ end
+
private
def generate_csv(filename, template)
@@ -139,4 +155,20 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
+
+ def inbox_label_matrix_params
+ {
+ since: params[:since],
+ until: params[:until],
+ inbox_ids: params[:inbox_ids],
+ label_ids: params[:label_ids]
+ }
+ end
+
+ def first_response_time_distribution_params
+ {
+ since: params[:since],
+ until: params[:until]
+ }
+ end
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
new file mode 100644
index 000000000..fdf969a39
--- /dev/null
+++ b/app/controllers/health_controller.rb
@@ -0,0 +1,7 @@
+# Inherits from ActionController::Base to skip all middleware,
+# authentication, and callbacks. Used for health checks
+class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
+ def show
+ render json: { status: 'woot' }
+ end
+end
diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index e9d27c67a..b910a9c9a 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -46,7 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
- 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
+ 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
@@ -55,7 +55,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = mapping.fetch(
@config,
- %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
+ %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
end
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index a63cb1a90..61d7631e7 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -131,7 +131,7 @@ export default {
diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss
index 4d7975a32..784edce6c 100644
--- a/app/javascript/dashboard/assets/scss/_next-colors.scss
+++ b/app/javascript/dashboard/assets/scss/_next-colors.scss
@@ -107,21 +107,39 @@
--violet-11: 101 85 183;
--violet-12: 47 38 95;
- --background-color: 253 253 253;
- --text-blue: 8 109 224;
+ --background-color: 247 247 247;
+ --surface-1: 254 254 254;
+ --surface-2: 255 255 255;
+ --surface-active: 255 255 255;
+ --background-input-box: 0, 0, 0, 0.03;
+ --text-blue: 1 22 44;
+ --text-purple: 2 4 49;
+ --text-amber: 37 24 1;
--border-container: 236 236 236;
- --border-strong: 235 235 235;
+ --border-strong: 226 227 231;
--border-weak: 234 234 234;
+ --border-blue-strong: 18 61 117;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
- --solid-amber: 252 232 193;
+ --solid-amber: 255 228 181;
--solid-blue: 218 236 255;
+ --solid-blue-2: 251 253 255;
--solid-iris: 230 231 255;
+ --solid-purple: 230 231 255;
+ --solid-red: 254 200 201;
+ --solid-amber-button: 255 221 141;
+ --card-color: 255 255 255;
+ --overlay: 0, 0, 0, 0.12;
+ --overlay-avatar: 255, 255, 255, 0.67;
+ --button-color: 255 255 255;
+ --button-hover-color: 255, 255, 255, 0.2;
+ --label-background: 247 247 247;
+ --label-border: 0, 0, 0, 0.04;
- --alpha-1: 67, 67, 67, 0.06;
- --alpha-2: 201, 202, 207, 0.15;
+ --alpha-1: 215, 215, 215, 0.22;
+ --alpha-2: 196, 197, 198, 0.22;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
@@ -235,25 +253,43 @@
--violet-11: 169 153 236;
--violet-12: 226 221 254;
- --background-color: 18 18 19;
- --border-strong: 52 52 52;
- --border-weak: 38 38 42;
+ --background-color: 28 29 32;
+ --surface-1: 20 21 23;
+ --surface-2: 22 23 26;
+ --surface-active: 53 57 66;
+ --background-input-box: 255, 255, 255, 0.02;
+ --text-blue: 213 234 255;
+ --text-purple: 232 233 254;
+ --text-amber: 255 247 234;
+ --border-strong: 46 45 50;
+ --border-weak: 31 31 37;
+ --border-blue-strong: 201 226 255;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
- --solid-amber: 42 37 30;
- --solid-blue: 16 49 91;
+ --solid-amber: 56 50 41;
+ --solid-blue: 15 57 102;
+ --solid-blue-2: 26 29 35;
--solid-iris: 38 42 101;
- --text-blue: 126 182 255;
+ --solid-purple: 51 51 107;
+ --solid-red: 90 33 34;
+ --solid-amber-button: 255 221 141;
+ --card-color: 28 30 34;
+ --overlay: 0, 0, 0, 0.4;
+ --overlay-avatar: 0, 0, 0, 0.05;
+ --button-color: 42 43 51;
+ --button-hover-color: 0, 0, 0, 0.15;
+ --label-background: 36 38 45;
+ --label-border: 255, 255, 255, 0.03;
- --alpha-1: 36, 36, 36, 0.8;
- --alpha-2: 139, 147, 182, 0.15;
- --alpha-3: 36, 38, 45, 0.9;
+ --alpha-1: 35, 36, 42, 0.8;
+ --alpha-2: 147, 153, 176, 0.12;
+ --alpha-3: 33, 34, 38, 0.95;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
- --border-container: 236, 236, 236, 0;
+ --border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
index b4154e4d8..9567d66c3 100644
--- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
+++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
@@ -7,7 +7,7 @@
@apply bg-n-slate-3;
&::after {
- @apply text-n-blue-text;
+ @apply text-n-blue-11;
}
}
}
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
index 351e3240f..965f532f4 100644
--- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
+++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
- class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
+ class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
@add="onClickAddTag"
/>
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
index 184e8bd10..01a207797 100644
--- a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
@@ -20,7 +20,7 @@ const handleButtonClick = () => {
-
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
index 7e5ca21b9..00bd2297f 100644
--- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
- class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
+ class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>