diff --git a/Gemfile b/Gemfile index 1526bf686..ee1943fcc 100644 --- a/Gemfile +++ b/Gemfile @@ -123,6 +123,8 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false gem 'sidekiq', '>= 7.3.1' # We want cron jobs gem 'sidekiq-cron', '>= 1.12.0' +# for sidekiq healthcheck +gem 'sidekiq_alive' ##-- Push notification service --## gem 'fcm' @@ -179,6 +181,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' +gem 'ai-agents', '>= 0.2.1' gem 'shopify_api' diff --git a/Gemfile.lock b/Gemfile.lock index 5d667e987..e755f82f2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -97,6 +97,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 8.0) selectize-rails (~> 0.6) + ai-agents (0.4.3) + ruby_llm (~> 1.3) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -106,24 +108,25 @@ GEM activerecord (>= 5.2, < 8.2) activesupport (>= 5.2, < 8.2) aws-eventstream (1.4.0) - aws-partitions (1.1122.0) - aws-sdk-core (3.226.1) + aws-partitions (1.1145.0) + aws-sdk-core (3.229.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 + bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.106.0) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-kms (1.110.0) + aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.191.0) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-s3 (1.196.1) + aws-sdk-core (~> 3, >= 3.228.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.9) + azure-blob (0.5.9.1) rexml barnes (0.0.9) multi_json (~> 1) @@ -135,7 +138,7 @@ GEM bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (7.1.0) racc browser (6.2.0) builder (3.3.0) @@ -167,9 +170,9 @@ GEM csv (~> 3.0) database_cleaner (2.1.0) database_cleaner-active_record (>= 2, < 3) - database_cleaner-active_record (2.2.1) + database_cleaner-active_record (2.2.2) activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) + database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) date (3.4.1) ddtrace (0.48.0) @@ -204,15 +207,15 @@ GEM down (5.4.2) addressable (~> 2.8) drb (2.2.3) - dry-cli (1.2.0) + dry-cli (1.3.0) elastic-apm (4.8.0) concurrent-ruby (~> 1.0) http (>= 3.0) ruby2_keywords email_reply_trimmer (0.2.0) - erb (5.0.1) + erb (5.0.2) erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.3.0) tzinfo event_stream_parser (1.0.0) execjs (2.10.0) @@ -224,9 +227,9 @@ GEM factory_bot_rails (6.5.0) factory_bot (~> 6.5) railties (>= 6.1.0) - faker (3.5.1) + faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.13.4) faraday-net_http (>= 2.0, < 3.5) json logger @@ -242,19 +245,24 @@ GEM fcm (2.0.1) faraday (>= 1.0.0, < 3.0) googleauth (~> 1) - ffi (1.17.2) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake flag_shih_tzu (0.3.23) - foreman (0.88.1) + foreman (0.90.0) + thor (~> 1.4) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) - gapic-common (1.0.0) + gapic-common (1.1.0) faraday (>= 1.9, < 3.a) faraday-retry (>= 1.0, < 3.a) google-cloud-env (~> 2.2) @@ -283,12 +291,12 @@ GEM retriable (>= 2.0, < 4.a) google-apis-iamcredentials_v1 (0.24.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.53.0) + google-apis-storage_v1 (0.55.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-dialogflow-v2 (1.10.0) + google-cloud-dialogflow-v2 (1.10.1) gapic-common (~> 1.0) google-cloud-errors (~> 1.0) google-cloud-location (~> 1.0) @@ -308,7 +316,7 @@ GEM google-cloud-core (~> 1.6) googleauth (~> 1.9) mini_mime (~> 1.0) - google-cloud-translate-v3 (1.5.1) + google-cloud-translate-v3 (1.5.2) gapic-common (~> 1.0) google-cloud-errors (~> 1.0) google-cloud-location (~> 1.0) @@ -321,6 +329,12 @@ GEM google-protobuf (4.31.1) bigdecimal rake (>= 13) + google-protobuf (4.31.1-aarch64-linux-gnu) + bigdecimal + rake (>= 13) + google-protobuf (4.31.1-aarch64-linux-musl) + bigdecimal + rake (>= 13) google-protobuf (4.31.1-arm64-darwin) bigdecimal rake (>= 13) @@ -330,6 +344,9 @@ GEM google-protobuf (4.31.1-x86_64-linux-gnu) bigdecimal rake (>= 13) + google-protobuf (4.31.1-x86_64-linux-musl) + bigdecimal + rake (>= 13) googleapis-common-protos (1.7.0) google-protobuf (>= 3.18, < 5.a) googleapis-common-protos-types (~> 1.7) @@ -346,22 +363,32 @@ GEM signet (>= 0.16, < 2.a) groupdate (6.7.0) activesupport (>= 7.1) - grpc (1.73.0) + grpc (1.74.1) google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) - grpc (1.73.0-arm64-darwin) + grpc (1.74.1-aarch64-linux-gnu) google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) - grpc (1.73.0-x86_64-darwin) + grpc (1.74.1-aarch64-linux-musl) google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) - grpc (1.73.0-x86_64-linux) + grpc (1.74.1-arm64-darwin) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.74.1-x86_64-darwin) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.74.1-x86_64-linux-gnu) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.74.1-x86_64-linux-musl) google-protobuf (>= 3.25, < 5.0) googleapis-common-protos-types (~> 1.0) grpc-google-iam-v1 (1.11.0) google-protobuf (>= 3.18, < 5.a) googleapis-common-protos (~> 1.7.0) grpc (~> 1.41) + gserver (0.0.1) haikunator (1.1.1) hairtrigger (1.3.1) activerecord (>= 6.0, < 9) @@ -393,22 +420,22 @@ GEM image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - io-console (0.8.0) + io-console (0.8.1) irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) iso-639 (0.3.8) csv - jbuilder (2.13.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) + jbuilder (2.14.0) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) jmespath (1.6.2) jquery-rails (4.6.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.12.2) + json (2.13.2) json_refs (0.1.9) hana json_schemer (2.4.0) @@ -423,7 +450,7 @@ GEM judoscale-sidekiq (1.11.1) judoscale-ruby (= 1.11.1) sidekiq (>= 5.0) - jwt (2.10.1) + jwt (2.10.2) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -451,7 +478,7 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - line-bot-api (2.0.0) + line-bot-api (2.1.0) base64 (~> 0.2) multipart-post (~> 2.4) lint_roller (1.1.0) @@ -487,17 +514,15 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0624) - mini_magick (5.2.0) - benchmark + mime-types-data (3.2025.0805) + mini_magick (5.3.0) logger mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (5.25.5) - mock_redis (0.50.0) + mock_redis (0.51.0) redis (~> 5) msgpack (1.8.0) - multi_json (1.15.0) + multi_json (1.17.0) multi_xml (0.7.2) bigdecimal (~> 3.1) multipart-post (2.4.1) @@ -519,16 +544,23 @@ GEM newrelic-sidekiq-metrics (1.6.2) newrelic_rpm (>= 8.0.0) sidekiq - newrelic_rpm (9.19.0) + newrelic_rpm (9.20.0) nio4r (2.7.4) - nokogiri (1.18.8) - mini_portile2 (~> 2.8.2) + nokogiri (1.18.9-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-arm64-darwin) + nokogiri (1.18.9-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.8-x86_64-darwin) + nokogiri (1.18.9-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) + nokogiri (1.18.9-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-musl) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -565,12 +597,18 @@ GEM openssl (3.3.0) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.2) + ostruct (0.6.3) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc - pg (1.5.9) + pg (1.6.1) + pg (1.6.1-aarch64-linux) + pg (1.6.1-aarch64-linux-musl) + pg (1.6.1-arm64-darwin) + pg (1.6.1-x86_64-darwin) + pg (1.6.1-x86_64-linux) + pg (1.6.1-x86_64-linux-musl) pg_search (2.3.7) activerecord (>= 6.1) activesupport (>= 6.1) @@ -590,7 +628,7 @@ GEM date stringio public_suffix (6.0.2) - puma (6.6.0) + puma (6.6.1) nio4r (~> 2.0) pundit (2.5.0) activesupport (>= 3.0.0) @@ -604,7 +642,7 @@ GEM rack-cors (3.0.0) logger rack (>= 3.0.14) - rack-mini-profiler (4.0.0) + rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-protection (4.1.1) base64 (>= 0.1.0) @@ -654,17 +692,17 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rdoc (6.14.1) + rdoc (6.14.2) erb psych (>= 4.0.0) - redis (5.4.0) + redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.25.0) + redis-client (0.25.2) connection_pool redis-namespace (1.11.0) redis (>= 4) - regexp_parser (2.10.0) - reline (0.6.1) + regexp_parser (2.11.1) + reline (0.6.2) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -692,7 +730,7 @@ GEM rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.1) + rspec-rails (8.0.2) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) @@ -703,7 +741,7 @@ GEM rspec-support (3.13.4) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.77.0) + rubocop (1.79.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -711,10 +749,10 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.1, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.1) + rubocop-ast (1.46.0) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-factory_bot (2.27.1) @@ -724,7 +762,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.32.0) + rubocop-rails (2.33.1) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -737,7 +775,7 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec (~> 3.5) - ruby-openai (8.1.0) + ruby-openai (8.2.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) faraday-multipart (>= 1) @@ -749,6 +787,15 @@ GEM ruby2ruby (2.5.2) ruby_parser (~> 3.1) sexp_processor (~> 4.6) + ruby_llm (1.6.0) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1.0) + zeitwerk (~> 2) ruby_parser (3.21.1) racc (~> 1.5) sexp_processor (~> 4.16) @@ -765,7 +812,7 @@ GEM sprockets (> 3.0) sprockets-rails tilt - scout_apm (5.6.4) + scout_apm (5.7.0) parser scss_lint (0.60.0) sass (~> 3.5, >= 3.5.5) @@ -774,17 +821,17 @@ GEM activerecord (>= 4) activesupport (>= 4) selectize-rails (0.12.6) - sentry-rails (5.25.0) + sentry-rails (5.26.0) railties (>= 5.0) - sentry-ruby (~> 5.25.0) - sentry-ruby (5.25.0) + sentry-ruby (~> 5.26.0) + sentry-ruby (5.26.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.25.0) - sentry-ruby (~> 5.25.0) + sentry-sidekiq (5.26.0) + sentry-ruby (~> 5.26.0) sidekiq (>= 3.0) sexp_processor (4.17.3) - shopify_api (14.10.0) + shopify_api (14.11.0) activesupport concurrent-ruby hash_diff @@ -797,17 +844,20 @@ GEM zeitwerk (~> 2.5) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) - sidekiq (8.0.4) + sidekiq (8.0.7) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) rack (>= 3.1.0) redis-client (>= 0.23.2) - sidekiq-cron (2.3.0) + sidekiq-cron (2.3.1) cronex (>= 0.13.0) fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) sidekiq (>= 6.5.0) + sidekiq_alive (2.5.0) + gserver (~> 0.0.1) + sidekiq (>= 5, < 9) signet (0.20.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -829,8 +879,8 @@ GEM snaky_hash (2.0.3) hashie (>= 0.1.0, < 6) version_gem (>= 1.1.8, < 3) - sorbet-runtime (0.5.12210) - spring (4.3.0) + sorbet-runtime (0.5.12388) + spring (4.4.0) spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) spring (>= 4) @@ -846,12 +896,12 @@ GEM stackprof (0.2.27) statsd-ruby (1.5.0) stringio (3.1.7) - stripe (15.2.1) + stripe (15.4.0) strscan (3.1.5) telephone_number (1.4.23) test-prof (1.4.4) - thor (1.3.2) - tilt (2.6.0) + thor (1.4.0) + tilt (2.6.1) time_diff (0.3.0) activesupport i18n @@ -915,15 +965,16 @@ GEM zeitwerk (2.7.3) PLATFORMS - arm64-darwin-20 - arm64-darwin-21 - arm64-darwin-22 - ruby - x86_64-darwin-18 - x86_64-darwin-20 - x86_64-darwin-21 - x86_64-darwin-22 + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES active_record_query_trace @@ -932,6 +983,7 @@ DEPENDENCIES administrate administrate-field-active_storage administrate-field-belongs_to_search + ai-agents (>= 0.2.1) annotate attr_extras audited (~> 5.4, >= 5.4.1) @@ -1036,6 +1088,7 @@ DEPENDENCIES shoulda-matchers sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) + sidekiq_alive simplecov (= 0.17.1) slack-ruby-client (~> 2.5.2) spring @@ -1062,4 +1115,4 @@ RUBY VERSION ruby 3.4.4p34 BUNDLED WITH - 2.5.16 + 2.6.7 diff --git a/VERSION_CW b/VERSION_CW index 4eba2a62e..fdc669880 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -3.13.0 +4.4.0 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 944880fa1..4d9d11cf5 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.2.0 +3.4.2 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index a88d3535b..6db565959 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -6,6 +6,8 @@ # We don't want to update the name of the identified original contact. class ContactIdentifyAction + include UrlHelper + pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }] def perform @@ -104,7 +106,14 @@ class ContactIdentifyAction # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.discard_invalid_attrs if discard_invalid_attrs @contact.save! - Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached? + enqueue_avatar_job + end + + def enqueue_avatar_job + return unless params[:avatar_url].present? && !@contact.avatar.attached? + return unless url_valid?(params[:avatar_url]) + + Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) end def merge_contact(base_contact, merge_contact) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 4fbe50902..8a97e9712 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -1,5 +1,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController include Sift + sort_on :email, type: :string sort_on :name, internal_name: :order_on_name, type: :scope, scope_params: [:direction] sort_on :phone_number, type: :string @@ -122,7 +123,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def resolved_contacts return @resolved_contacts if @resolved_contacts - @resolved_contacts = Current.account.contacts.resolved_contacts + @resolved_contacts = Current.account.contacts.resolved_contacts(use_crm_v2: Current.account.feature_enabled?('crm_v2')) @resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present? @resolved_contacts diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index e7b3b197b..fe59e21a9 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,5 +1,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::InboxesHelper + before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] @@ -69,6 +70,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end + def sync_templates + unless @inbox.channel.is_a?(Channel::Whatsapp) + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } + end + + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + render status: :ok, json: { message: 'Template sync initiated successfully' } + rescue StandardError => e + render status: :internal_server_error, json: { error: e.message } + end + private def fetch_inbox diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 6cfed161f..af96441f8 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? # @portal.custom_domain = parsed_custom_domain process_attached_logo if params[:blob_id].present? - rescue StandardError => e - Rails.logger.error e - render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render_record_invalid(e) end end @@ -47,6 +46,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController head :ok end + def send_instructions + email = permitted_params[:email] + return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank? + return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email) + return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank? + + PortalInstructionsMailer.send_cname_instructions( + portal: @portal, + recipient_email: email + ).deliver_later + + render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok + end + def process_attached_logo blob_id = params[:blob_id] blob = ActiveStorage::Blob.find_by(id: blob_id) @@ -60,12 +73,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def permitted_params - params.permit(:id) + params.permit(:id, :email) end def portal_params params.require(:portal).permit( - :account_id, :color, :custom_domain, :header_text, :homepage_link, + :id, :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } ) end @@ -88,4 +101,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController domain = URI.parse(@portal.custom_domain) domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain end + + def valid_email?(email) + ValidEmail2::Address.new(email).valid? + end end + +Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController') diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index e7a1f3fa6..3e7d876c3 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,8 +1,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController before_action :validate_feature_enabled! + before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } # POST /api/v1/accounts/:account_id/whatsapp/authorization - # Handles the embedded signup callback data from the Facebook SDK + # Handles both initial authorization and reauthorization + # If inbox_id is present in params, it performs reauthorization def create validate_embedded_signup_params! channel = process_embedded_signup @@ -16,21 +18,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, - code: params[:code], - business_id: params[:business_id], - waba_id: params[:waba_id], - phone_number_id: params[:phone_number_id] + params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys, + inbox_id: params[:inbox_id] ) service.perform end - def render_success_response(inbox) + def fetch_and_validate_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + validate_reauthorization_required + end + + def validate_reauthorization_required + return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? + render json: { + success: false, + message: I18n.t('inbox.reauthorization.not_required') + }, status: :unprocessable_entity + end + + def can_upgrade_to_embedded_signup? + channel = @inbox.channel + return false unless channel.provider == 'whatsapp_cloud' + + true + end + + def render_success_response(inbox) + response = { success: true, id: inbox.id, name: inbox.name, channel_type: 'whatsapp' } + response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present? + render json: response end def render_error_response(error) diff --git a/app/controllers/microsoft_controller.rb b/app/controllers/microsoft_controller.rb index e6a12dafa..3071eac31 100644 --- a/app/controllers/microsoft_controller.rb +++ b/app/controllers/microsoft_controller.rb @@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController after_action :set_version_header def identity_association - microsoft_indentity + microsoft_identity end private @@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length end - def microsoft_indentity + def microsoft_identity @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil) end end diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index e11cf9d4a..4521930a6 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -1,4 +1,11 @@ class Platform::Api::V1::AccountsController < PlatformController + def index + @resources = @platform_app.platform_app_permissibles + .where(permissible_type: 'Account') + .includes(:permissible) + .map(&:permissible) + end + def show; end def create diff --git a/app/controllers/slack_uploads_controller.rb b/app/controllers/slack_uploads_controller.rb index 127e77649..1d689655d 100644 --- a/app/controllers/slack_uploads_controller.rb +++ b/app/controllers/slack_uploads_controller.rb @@ -1,5 +1,6 @@ class SlackUploadsController < ApplicationController include Rails.application.routes.url_helpers + before_action :set_blob, only: [:show] def show @@ -17,7 +18,12 @@ class SlackUploadsController < ApplicationController end def blob_url - url_for(@blob.representation(resize_to_fill: [250, nil])) + # Only generate representations for images + if @blob.content_type.start_with?('image/') + url_for(@blob.representation(resize_to_fill: [250, nil])) + else + url_for(@blob) + end end def avatar_url diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index 455828228..d607ba151 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController :NumMedia, :Latitude, :Longitude, - :MessageType + :MessageType, + :ProfileName ) end end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 3d46334ca..569c2524b 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -4,7 +4,16 @@ class Webhooks::InstagramController < ActionController::API def events Rails.logger.info('Instagram webhook received events') if params['object'].casecmp('instagram').zero? - ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) + entry_params = params.to_unsafe_hash[:entry] + + if contains_echo_event?(entry_params) + # Add delay to prevent race condition where echo arrives before send message API completes + # This avoids duplicate messages when echo comes early during API processing + ::Webhooks::InstagramEventsJob.set(wait: 2.seconds).perform_later(entry_params) + else + ::Webhooks::InstagramEventsJob.perform_later(entry_params) + end + render json: :ok else Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}") @@ -14,6 +23,16 @@ class Webhooks::InstagramController < ActionController::API private + def contains_echo_event?(entry_params) + return false unless entry_params.is_a?(Array) + + entry_params.any? do |entry| + # Check messaging array for echo events + messaging_events = entry[:messaging] || [] + messaging_events.any? { |messaging| messaging.dig(:message, :is_echo).present? } + end + end + def valid_token?(token) # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) diff --git a/app/finders/notification_finder.rb b/app/finders/notification_finder.rb index ccfe470a0..e1958827a 100644 --- a/app/finders/notification_finder.rb +++ b/app/finders/notification_finder.rb @@ -15,7 +15,13 @@ class NotificationFinder end def unread_count - @notifications.where(read_at: nil).count + if type_included?('read') + # If we're including read notifications, filter to unread + @notifications.where(read_at: nil).count + else + # Already filtered to unread notifications, just count + @notifications.count + end end def count @@ -27,7 +33,7 @@ class NotificationFinder def set_up find_all_notifications filter_snoozed_notifications - fitler_read_notifications + filter_read_notifications end def find_all_notifications @@ -38,7 +44,7 @@ class NotificationFinder @notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed') end - def fitler_read_notifications + def filter_read_notifications @notifications = @notifications.where(read_at: nil) unless type_included?('read') end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 675f6ea67..8da7e7476 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -136,8 +136,7 @@ export default {
diff --git a/app/javascript/dashboard/api/captain/scenarios.js b/app/javascript/dashboard/api/captain/scenarios.js new file mode 100644 index 000000000..3e61c28a3 --- /dev/null +++ b/app/javascript/dashboard/api/captain/scenarios.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainScenarios extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId, page = 1, searchKey } = {}) { + return axios.get(`${this.url}/${assistantId}/scenarios`, { + params: { page, searchKey }, + }); + } + + show({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/scenarios/${id}`); + } + + create({ assistantId, ...data } = {}) { + return axios.post(`${this.url}/${assistantId}/scenarios`, { + scenario: data, + }); + } + + update({ assistantId, id }, data = {}) { + return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, { + scenario: data, + }); + } + + delete({ assistantId, id }) { + return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`); + } +} + +export default new CaptainScenarios(); diff --git a/app/javascript/dashboard/api/captain/tools.js b/app/javascript/dashboard/api/captain/tools.js new file mode 100644 index 000000000..20edaa95e --- /dev/null +++ b/app/javascript/dashboard/api/captain/tools.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainTools extends ApiClient { + constructor() { + super('captain/assistants/tools', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { + params, + }); + } +} + +export default new CaptainTools(); diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js index e1003b123..8f51f4878 100644 --- a/app/javascript/dashboard/api/channel/whatsappChannel.js +++ b/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -9,6 +9,13 @@ class WhatsappChannel extends ApiClient { createEmbeddedSignup(params) { return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params); } + + reauthorizeWhatsApp({ inboxId, ...params }) { + return axios.post(`${this.baseUrl()}/whatsapp/authorization`, { + ...params, + inbox_id: inboxId, + }); + } } export default new WhatsappChannel(); diff --git a/app/javascript/dashboard/api/helpCenter/portals.js b/app/javascript/dashboard/api/helpCenter/portals.js index 7c6210dbd..d65dcaf1a 100644 --- a/app/javascript/dashboard/api/helpCenter/portals.js +++ b/app/javascript/dashboard/api/helpCenter/portals.js @@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient { deleteLogo(portalSlug) { return axios.delete(`${this.url}/${portalSlug}/logo`); } + + sendCnameInstructions(portalSlug, email) { + return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email }); + } + + sslStatus(portalSlug) { + return axios.get(`${this.url}/${portalSlug}/ssl_status`); + } } export default PortalsAPI; diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 8c09791c8..361b9472f 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient { agent_bot: botId, }); } + + syncTemplates(inboxId) { + return axios.post(`${this.url}/${inboxId}/sync_templates`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index 628ce0f34..64ba44aea 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -12,6 +12,7 @@ describe('#InboxesAPI', () => { expect(inboxesAPI).toHaveProperty('getCampaigns'); expect(inboxesAPI).toHaveProperty('getAgentBot'); expect(inboxesAPI).toHaveProperty('setAgentBot'); + expect(inboxesAPI).toHaveProperty('syncTemplates'); }); describe('API calls', () => { @@ -40,5 +41,12 @@ describe('#InboxesAPI', () => { inboxesAPI.deleteInboxAvatar(2); expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar'); }); + + it('#syncTemplates', () => { + inboxesAPI.syncTemplates(2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/inboxes/2/sync_templates' + ); + }); }); }); diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 66416c64c..40ca7b436 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -37,30 +37,6 @@ body { width: 100%; } -.app-wrapper { - @apply h-screen flex-grow-0 min-h-0 w-full; - - .button--fixed-top { - @apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row; - } -} - -.banner + .app-wrapper { - // Reduce the height of the dashboard to make room for the banner. - // And causing the top right green-action button to be pushed down when scrolling. - @apply h-[calc(100%-48px)]; - - .button--fixed-top { - @apply top-14; - } - - .off-canvas-content { - .button--fixed-top { - @apply top-2; - } - } -} - .tooltip { @apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96; } diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue index 304e55347..05507fc89 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue @@ -76,8 +76,8 @@ const campaignStatus = computed(() => { const inboxName = computed(() => props.inbox?.name || ''); const inboxIcon = computed(() => { - const { phone_number: phoneNumber, channel_type: type } = props.inbox; - return getInboxIconByType(type, phoneNumber); + const { medium, channel_type: type } = props.inbox; + return getInboxIconByType(type, medium); }); diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue index 12a789fee..f4301375d 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue @@ -38,11 +38,13 @@ const handleClose = () => emit('close'); diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue index df76ae901..0babd11ec 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue @@ -9,6 +9,7 @@ import Input from 'dashboard/components-next/input/Input.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue'; +import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue'; const emit = defineEmits(['submit', 'cancel']); @@ -18,7 +19,9 @@ const formState = { uiFlags: useMapGetter('campaigns/getUIFlags'), labels: useMapGetter('labels/getLabels'), inboxes: useMapGetter('inboxes/getWhatsAppInboxes'), - getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'), + getFilteredWhatsAppTemplates: useMapGetter( + 'inboxes/getFilteredWhatsAppTemplates' + ), }; const initialState = { @@ -30,7 +33,7 @@ const initialState = { }; const state = reactive({ ...initialState }); -const processedParams = ref({}); +const templateParserRef = ref(null); const rules = { title: { required, minLength: minLength(1) }, @@ -67,7 +70,7 @@ const inboxOptions = computed(() => const templateOptions = computed(() => { if (!state.inboxId) return []; - const templates = formState.getWhatsAppTemplates.value(state.inboxId); + const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId); return templates.map(template => { // Create a more user-friendly label from template name const friendlyName = template.name @@ -88,26 +91,6 @@ const selectedTemplate = computed(() => { ?.template; }); -const templateString = computed(() => { - if (!selectedTemplate.value) return ''; - try { - return ( - selectedTemplate.value.components?.find( - component => component.type === 'BODY' - )?.text || '' - ); - } catch (error) { - return ''; - } -}); - -const processedString = computed(() => { - if (!templateString.value) return ''; - return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => { - return processedParams.value[variable] || `{{${variable}}}`; - }); -}); - const getErrorMessage = (field, errorKey) => { const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM'; return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : ''; @@ -122,8 +105,7 @@ const formErrors = computed(() => ({ })); const hasRequiredTemplateParams = computed(() => { - const params = Object.values(processedParams.value); - return params.length === 0 || params.every(param => param.trim() !== ''); + return templateParserRef.value?.v$?.$invalid === false || true; }); const isSubmitDisabled = computed( @@ -135,32 +117,18 @@ const formatToUTCString = localDateTime => const resetState = () => { Object.assign(state, initialState); - processedParams.value = {}; v$.value.$reset(); }; const handleCancel = () => emit('cancel'); -const generateVariables = () => { - const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); - if (!matchedVariables) { - processedParams.value = {}; - return; - } - - const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, '')); - processedParams.value = finalVars.reduce((acc, variable) => { - acc[variable] = processedParams.value[variable] || ''; - return acc; - }, {}); -}; - const prepareCampaignDetails = () => { // Find the selected template to get its content const currentTemplate = selectedTemplate.value; + const parserData = templateParserRef.value; // Extract template content - this should be the template message body - const templateContent = templateString.value; + const templateContent = parserData?.renderedTemplate || ''; // Prepare template_params object with the same structure as used in contacts const templateParams = { @@ -168,7 +136,7 @@ const prepareCampaignDetails = () => { namespace: currentTemplate?.namespace || '', category: currentTemplate?.category || 'UTILITY', language: currentTemplate?.language || 'en_US', - processed_params: processedParams.value, + processed_params: parserData?.processedParams || {}, }; return { @@ -198,15 +166,6 @@ watch( () => state.inboxId, () => { state.templateId = null; - processedParams.value = {}; - } -); - -// Generate variables when template changes -watch( - () => state.templateId, - () => { - generateVariables(); } ); @@ -254,62 +213,12 @@ watch(

- -
+ -
-

- {{ selectedTemplate.name }} -

- - {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}: - {{ selectedTemplate.language || 'en' }} - -
- -
-
-
- {{ processedString }} -
-
-
- -
- {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}: - {{ selectedTemplate.category || 'UTILITY' }} -
-
- - -
- -
-
- -
-
-
+ ref="templateParserRef" + :template="selectedTemplate" + />