diff --git a/.gitignore b/.gitignore
index dbdd35bcd..017c5c224 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,6 +95,7 @@ yarn-debug.log*
.claude/settings.local.json
.cursor
.codex/
+.claude/
CLAUDE.local.md
# Histoire deployment
diff --git a/Gemfile b/Gemfile
index e6c5a5250..0636b7b2b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents'
+gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index cc1f9e253..7a7316e3c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
- ai-agents (0.9.0)
+ ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -583,14 +583,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
- nokogiri (1.18.9)
+ nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- nokogiri (1.18.9-arm64-darwin)
+ nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.18.9-x86_64-darwin)
+ nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.18.9-x86_64-linux-gnu)
+ nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -677,7 +677,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.3)
+ rack (3.2.5)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -1024,7 +1024,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
- ai-agents
+ ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
diff --git a/VERSION_CW b/VERSION_CW
index ad96464c4..d782fca8f 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.10.1
+4.11.1
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index 7c21844f5..ab1cae17d 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def meta
- result = conversation_finder.perform
+ result = conversation_finder.perform_meta_only
@conversations_count = result[:count]
end
diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
index bb5dab680..d7244616d 100644
--- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
+++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
@@ -1,6 +1,7 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
+ before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
+ def analyze
+ template_params = extract_template_params
+ return render_missing_message_error if template_params[:message].blank?
+
+ result = CsatTemplateUtilityAnalysisService.new(
+ account: Current.account,
+ inbox: @inbox,
+ message: template_params[:message],
+ button_text: template_params[:button_text],
+ language: template_params[:language]
+ ).perform
+
+ render json: result
+ rescue ActionController::ParameterMissing
+ render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
+ end
+
private
def fetch_inbox
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
+ def validate_captain_enabled
+ return if Current.account.feature_enabled?('captain_integration')
+
+ render json: { error: 'Captain is required for template analysis' }, status: :forbidden
+ end
+
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
diff --git a/app/controllers/webhooks/shopify_controller.rb b/app/controllers/webhooks/shopify_controller.rb
new file mode 100644
index 000000000..efc6a5122
--- /dev/null
+++ b/app/controllers/webhooks/shopify_controller.rb
@@ -0,0 +1,35 @@
+class Webhooks::ShopifyController < ActionController::API
+ before_action :verify_hmac!
+
+ def events
+ case request.headers['X-Shopify-Topic']
+ when 'shop/redact'
+ handle_shop_redact
+ end
+
+ head :ok
+ end
+
+ private
+
+ def verify_hmac!
+ secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
+ return head :unauthorized if secret.blank?
+
+ data = request.body.read
+ request.body.rewind
+
+ hmac_header = request.headers['X-Shopify-Hmac-SHA256']
+ return head :unauthorized if hmac_header.blank?
+
+ computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
+ return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
+ end
+
+ def handle_shop_redact
+ shop_domain = params[:shop_domain]
+ return if shop_domain.blank?
+
+ Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
+ end
+end
diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb
index d43ed31e7..7821bee49 100644
--- a/app/finders/conversation_finder.rb
+++ b/app/finders/conversation_finder.rb
@@ -55,6 +55,22 @@ class ConversationFinder
}
end
+ def perform_meta_only
+ set_up
+
+ mine_count, unassigned_count, all_count, = set_count_for_all_conversations
+ assigned_count = all_count - unassigned_count
+
+ {
+ count: {
+ mine_count: mine_count,
+ assigned_count: assigned_count,
+ unassigned_count: unassigned_count,
+ all_count: all_count
+ }
+ }
+ end
+
private
def set_up
diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb
index d734a346e..3d6b559c8 100644
--- a/app/helpers/api/v1/inboxes_helper.rb
+++ b/app/helpers/api/v1/inboxes_helper.rb
@@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
end
def check_smtp_connection(channel_data, smtp)
+ smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish
+ rescue Net::SMTPAuthenticationError
+ raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
+ raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
+ rescue OpenSSL::SSL::SSLError
+ raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
+ rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
+ raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
+ rescue StandardError => e
+ raise StandardError, e.message
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
- set_enable_tls(channel_data, smtp)
+ set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
elsif channel_data[:smtp_enable_starttls_auto]
- set_enable_starttls_auto(channel_data, smtp)
+ set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
end
end
- def set_enable_starttls_auto(channel_data, smtp)
- return unless smtp.respond_to?(:enable_starttls_auto)
+ def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
+ return unless smtp.respond_to?(method)
- if channel_data[:smtp_openssl_verify_mode]
- context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
- smtp.enable_starttls_auto(context)
- else
- smtp.enable_starttls_auto
- end
- end
-
- def set_enable_tls(channel_data, smtp)
- return unless smtp.respond_to?(:enable_tls)
-
- if channel_data[:smtp_openssl_verify_mode]
- context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
- smtp.enable_tls(context)
- else
- smtp.enable_tls
- end
+ context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
+ context ? smtp.send(method, context) : smtp.send(method)
end
def enable_openssl_mode(smtp_openssl_verify_mode)
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index 61d7631e7..8912c03d1 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -164,10 +164,4 @@ export default {
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
-
-.multiselect__input {
- margin-bottom: 0px !important;
-}
-
-
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index 83ba3e9ba..079f21815 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
+
+ analyzeCSATTemplateUtility(inboxId, template) {
+ return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
+ template,
+ });
+ }
}
export default new Inboxes();
diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss
index 14ae2a9d4..84c8a4b0f 100644
--- a/app/javascript/dashboard/assets/scss/_base.scss
+++ b/app/javascript/dashboard/assets/scss/_base.scss
@@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
- @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
+ @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
.field-disabled {
@@ -78,7 +78,7 @@ textarea {
}
}
-$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
+$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
#{$form-input-selector} {
@apply field-base h-10;
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
}
}
-input[type='file']:not(.multiselect__input) {
+input[type='file'] {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
}
@@ -126,13 +126,6 @@ label:has(.help-text) {
}
}
-// Error handling
-.has-multi-select-error {
- div.multiselect {
- @apply mb-1;
- }
-}
-
// FormKit support
.formkit-outer[data-invalid='true'] {
#{$form-input-selector},
@@ -150,9 +143,7 @@ label:has(.help-text) {
#{$form-input-selector},
input:not([type]),
textarea,
- select,
- .multiselect > .multiselect__tags,
- .multiselect:not(.no-margin) {
+ select {
@apply field-error;
}
diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss
index 40ca7b436..27764d150 100644
--- a/app/javascript/dashboard/assets/scss/_woot.scss
+++ b/app/javascript/dashboard/assets/scss/_woot.scss
@@ -13,7 +13,6 @@
@import 'base';
// Plugins
-@import 'plugins/multiselect';
@import 'plugins/date-picker';
html,
@@ -66,4 +65,84 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
+
+ /**
+ * ============================================================================
+ * TYPOGRAPHY UTILITIES
+ * ============================================================================
+ *
+ * | Class | Use Case |
+ * |--------------------|----------------------------------------------------|
+ * | .text-body-main |
, , general body text |
+ * | .text-body-para | for paragraphs, larger text blocks |
+ * | .text-heading-1 |
, page titles, panel headers |
+ * | .text-heading-2 | , section headings, card titles |
+ * | .text-heading-3 | , card headings, breadcrumbs, subsections |
+ * | .text-label |