diff --git a/.circleci/config.yml b/.circleci/config.yml index f764cb611..59702c139 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 20 + parallelism: 18 steps: - checkout - node/install: diff --git a/.env.example b/.env.example index 69b1b9cde..c9f3c855c 100644 --- a/.env.example +++ b/.env.example @@ -272,9 +272,9 @@ AZURE_APP_SECRET= # ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false -# AI powered features -## OpenAI key -# OPENAI_API_KEY= +# AI powered features (Captain) +# The OpenAI API key and endpoint for Captain are not configured via .env. +# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # Housekeeping/Performance related configurations # Set to true if you want to remove stale contact inboxes diff --git a/AGENTS.md b/AGENTS.md index 2ab6373b7..2430fae2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,18 @@ ## General Guidelines -- MVP focus: Least code change, happy-path only -- No unnecessary defensive programming -- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer the smallest production-ready change that solves the current problem. +- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. +- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. +- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks. +- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully. +- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing. +- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read. - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked +- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity. - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs diff --git a/Gemfile b/Gemfile index 7533cf3cf..7735dc099 100644 --- a/Gemfile +++ b/Gemfile @@ -195,7 +195,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.10.0' +gem 'ai-agents', '>= 0.12.0' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.14.1' diff --git a/Gemfile.lock b/Gemfile.lock index 8d6132849..bd41474a3 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.10.0) + ai-agents (0.12.0) ruby_llm (~> 1.14) annotaterb (4.20.0) activerecord (>= 6.0.0) @@ -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,12 +193,12 @@ 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 rexml - crass (1.0.6) + crass (1.0.7) cronex (0.15.0) tzinfo unicode (>= 0.4.4.5) @@ -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) @@ -570,7 +570,7 @@ GEM minitest (5.25.5) mock_redis (0.36.0) ruby2_keywords - msgpack (1.8.0) + msgpack (1.8.3) multi_json (1.15.0) multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) @@ -598,14 +598,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.5) - nokogiri (1.19.3) + nokogiri (1.19.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.3-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.6) auth-sanitizer (~> 0.2, >= 0.2.1) @@ -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) @@ -1058,7 +1058,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.10.0) + ai-agents (>= 0.12.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) diff --git a/VERSION_CW b/VERSION_CW index 0fb7a35b6..fb0557132 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.2 +4.15.1 diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 24b6d9e70..c7608399e 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder def fallback_params(attachment) { - fallback_title: attachment['title'], + fallback_title: attachment['title'] || attachment.dig('payload', 'title'), external_url: attachment['url'] || attachment.dig('payload', 'url') } end # Facebook shared posts point to page URLs, not downloadable media URLs. + # Both `share` and `post` attachment types carry a page URL rather than a media file, + # so map them to `fallback` (which keeps the title/link without attempting a download). # Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling. def normalize_file_type(type) - return :fallback if type.to_sym == :share + return :fallback if [:share, :post].include?(type.to_sym) super end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index ecd6f06ea..712a24608 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -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) diff --git a/app/builders/v2/reports/drilldown_builder.rb b/app/builders/v2/reports/drilldown_builder.rb new file mode 100644 index 000000000..a3d2c073d --- /dev/null +++ b/app/builders/v2/reports/drilldown_builder.rb @@ -0,0 +1,213 @@ +class V2::Reports::DrilldownBuilder + include DateRangeHelper + include TimezoneHelper + + DEFAULT_GROUP_BY = 'day'.freeze + DEFAULT_PAGE = 1 + DEFAULT_PER_PAGE = 25 + MAX_PER_PAGE = 100 + SUPPORTED_GROUP_BY = %w[hour day week month year].freeze + SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze + MESSAGE_METRICS = { + 'incoming_messages_count' => :incoming, + 'outgoing_messages_count' => :outgoing + }.freeze + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + pattr_initialize :account, :params + + def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s) + + def build + records = paginated_records.to_a + { meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } } + end + + private + + def meta + { + metric: metric, + record_type: record_type, + bucket: { + since: bucket_range.begin.to_i, + until: bucket_range.end.to_i + }, + current_page: current_page, + per_page: per_page, + total_count: paginated_records.total_count, + conversation_count: conversation_count + } + end + + def conversation_count + return paginated_records.total_count if conversation_metric? + + drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id) + end + + def paginated_records + @paginated_records ||= drilldown_scope.page(current_page).per(per_page) + end + + def drilldown_scope + if message_metric? + message_scope + elsif conversation_metric? + conversation_scope + else + reporting_event_scope + end + end + + def message_scope + scope.messages + .where(account_id: account.id, created_at: bucket_range) + .public_send(MESSAGE_METRICS.fetch(metric)) + .includes(:sender, conversation: [:assignee, :contact, :inbox]) + .reorder(created_at: :desc) + end + + def conversation_scope + scope.conversations + .where(account_id: account.id, created_at: bucket_range) + .includes(:assignee, :contact, :inbox) + .order(created_at: :desc) + end + + def reporting_event_scope + events = scope.reporting_events + .where(account_id: account.id, name: raw_event_name, created_at: bucket_range) + .includes(:user, :inbox, conversation: [:assignee, :contact, :inbox]) + .order(created_at: :desc) + + if raw_count_strategy == :exclude_bot_handoffs + events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery) + elsif raw_count_strategy == :distinct_conversation + events = events.where(id: distinct_conversation_event_ids(events)) + end + + events + end + + def bot_handoff_conversation_ids_subquery + scope.reporting_events + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) + end + + def distinct_conversation_event_ids(events) + events.reorder(nil) + .where.not(conversation_id: nil) + .select('MAX(reporting_events.id)') + .group(:conversation_id) + end + + def record_serializer(records) + @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new( + account, + metric, + use_business_hours?, + records + ) + end + + def bucket_range + @bucket_range ||= begin + bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone) + bucket_end = bucket_end_for(bucket_start) + requested_start = Time.zone.at(params[:since].to_i) + requested_end = Time.zone.at(params[:until].to_i) + + [bucket_start, requested_start].max...[bucket_end, requested_end].min + end + end + + def bucket_end_for(bucket_start) + { + 'hour' => bucket_start + 1.hour, + 'day' => bucket_start + 1.day, + 'week' => bucket_start + 1.week, + 'month' => bucket_start + 1.month, + 'year' => bucket_start + 1.year + }.fetch(group_by) + end + + def scope + case dimension_type + when 'account' then account + when 'inbox' then inbox + when 'agent' then user + when 'label' then label + when 'team' then team + else + raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}" + end + end + + def inbox = @inbox ||= account.inboxes.find(params[:id]) + + def user = @user ||= account.users.find(params[:id]) + + def label = @label ||= account.labels.find(params[:id]) + + def team = @team ||= account.teams.find(params[:id]) + + def metric + params[:metric].to_s + end + + def report_metric + @report_metric ||= Reports::ReportMetricRegistry.fetch(metric) + end + + def raw_event_name + report_metric&.raw_event_name + end + + def raw_count_strategy + report_metric&.raw_count_strategy + end + + def record_type + return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric) + + 'conversation' + end + + def message_metric? + MESSAGE_METRICS.key?(metric) + end + + def conversation_metric? + metric == 'conversations_count' + end + + def dimension_type + (params[:type].presence || 'account').to_s + end + + def group_by + @group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY + end + + def timezone + @timezone ||= timezone_name_from_offset(params[:timezone_offset]) + end + + def current_page + [params[:page].to_i, DEFAULT_PAGE].max + end + + def per_page + requested_per_page = params[:per_page].to_i + requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0 + + [requested_per_page, MAX_PER_PAGE].min + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end +end diff --git a/app/builders/v2/reports/drilldown_record_serializer.rb b/app/builders/v2/reports/drilldown_record_serializer.rb new file mode 100644 index 000000000..04edf65b4 --- /dev/null +++ b/app/builders/v2/reports/drilldown_record_serializer.rb @@ -0,0 +1,199 @@ +class V2::Reports::DrilldownRecordSerializer + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + attr_reader :account, :metric, :use_business_hours, :records + + def initialize(account, metric, use_business_hours, records = []) + @account = account + @metric = metric + @use_business_hours = use_business_hours + @records = records + end + + def serialize(record) + return serialize_message(record) if record.is_a?(Message) + return serialize_conversation_event(record) if record.is_a?(ReportingEvent) + + serialize_conversation(record) + end + + private + + def serialize_message(message, metric_value: nil, occurred_at: nil) + { + record_type: 'message', + conversation: conversation_attributes(message.conversation), + message: message_attributes(message), + metric_value: metric_value, + occurred_at: (occurred_at || message.created_at).to_i + } + end + + def serialize_conversation_event(event) + inferred_message = inferred_message_for(event) + if inferred_message.present? + return serialize_message( + inferred_message, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event) + ) + end + + serialize_conversation( + event.conversation, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event), + event_name: event.name + ) + end + + def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil) + serialized_record = { + record_type: 'conversation', + conversation: conversation_attributes(conversation), + message: nil, + metric_value: metric_value, + occurred_at: (occurred_at || conversation&.created_at)&.to_i + } + serialized_record[:event_name] = event_name if event_name.present? + serialized_record + end + + def conversation_attributes(conversation) + return {} if conversation.blank? + + { + id: conversation.id, + display_id: conversation.display_id, + contact_id: conversation.contact_id, + contact_name: conversation.contact&.name, + inbox_id: conversation.inbox_id, + inbox_name: conversation.inbox&.name, + assignee_id: conversation.assignee_id, + assignee_name: conversation.assignee&.name, + status: conversation.status, + created_at: conversation.created_at.to_i, + last_activity_at: conversation.last_activity_at.to_i, + last_message: last_message_attributes(conversation) + } + end + + def message_attributes(message) + { + id: message.id, + content: message.content, + message_type: message.message_type, + sender_name: message.sender&.try(:name), + created_at: message.created_at.to_i + } + end + + def last_message_attributes(conversation) + message = latest_messages_by_conversation_id[conversation.id] + return if message.blank? + + message_attributes(message) + end + + def inferred_message_for(event) + return unless MESSAGE_EVENT_METRICS.include?(metric) + return if event.conversation.blank? || event.event_end_time.blank? + + inferred_messages_by_event_id[event.id] + end + + def first_response_event_with_user?(event) + metric == 'avg_first_response_time' && event.user_id.present? + end + + def message_inference_range(event) + (event.event_end_time - 1.second)..(event.event_end_time + 1.second) + end + + def event_metric_value(event) + use_business_hours ? event.value_in_business_hours : event.value + end + + def event_timestamp(event) + event.event_end_time || event.created_at + end + + def latest_messages_by_conversation_id + @latest_messages_by_conversation_id ||= if conversation_ids.blank? + {} + else + latest_messages.index_by(&:conversation_id) + end + end + + def latest_messages + Message + .where(account_id: account.id, conversation_id: conversation_ids) + .where.not(message_type: :activity) + .select('DISTINCT ON (messages.conversation_id) messages.*') + .reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC')) + .includes(:sender) + end + + def inferred_messages_by_event_id + @inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id| + messages_by_event_id[event.id] = inferred_message_candidates.find do |message| + message_matches_event?(message, event) + end + end + end + + def inferred_message_candidates + @inferred_message_candidates ||= if inference_events.blank? + [] + else + inferred_messages.to_a + end + end + + def inferred_messages + Message + .where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq) + .where(created_at: inference_time_range) + .where(message_type: %i[outgoing template]) + .includes(:sender) + .reorder(created_at: :desc, id: :desc) + end + + def message_matches_event?(message, event) + message.conversation_id == event.conversation_id && + message.created_at.between?( + message_inference_range(event).begin, + message_inference_range(event).end + ) && + message_sender_matches_event?(message, event) + end + + def message_sender_matches_event?(message, event) + return true unless first_response_event_with_user?(event) + + message.sender_id == event.user_id && message.sender_type == 'User' + end + + def inference_time_range + event_end_times = inference_events.map(&:event_end_time) + + (event_end_times.min - 1.second)..(event_end_times.max + 1.second) + end + + def inference_events + @inference_events ||= records.select do |record| + record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present? + end + end + + def conversation_ids + @conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq + end + + def conversation_id_for(record) + return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent) + + record.id + end +end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 5e1609b64..4a8363fdd 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end def reorder - Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) - head :ok + positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + render json: { positions: positions } end private diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afb..0150cb677 100644 --- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC def assignment_policy_params params.require(:assignment_policy).permit( :name, :description, :assignment_order, :conversation_priority, - :fair_distribution_limit, :fair_distribution_window, :enabled + :fair_distribution_limit, :fair_distribution_window, :enabled, + :exclude_older_than_hours ) end end diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 156c031fa..04eeff92b 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def update params_to_update = captain_params - @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models] - @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features] + @current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models) + @current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features) @current_account.save! render json: preferences_payload @@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def merged_captain_models existing_models = @current_account.captain_models || {} - existing_models.merge(permitted_captain_models) + existing_models.merge(permitted_captain_models).compact_blank.presence end def merged_captain_features @@ -47,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas end def permitted_captain_models - params.require(:captain_models).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys end def permitted_captain_features - params.require(:captain_features).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys + end + + def captain_feature_keys + Llm::Models.feature_keys.map(&:to_sym) end def features_with_account_preferences preferences = Current.account.captain_preferences account_features = preferences[:features] || {} - account_models = preferences[:models] || {} Llm::Models.feature_keys.index_with do |feature_key| config = Llm::Models.feature_config(feature_key) + route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account) config.merge( enabled: account_features[feature_key] == true, - selected: account_models[feature_key] || config[:default] + model: route[:model], + selected: route[:model], + provider: route[:provider], + source: route[:source] ) end end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a715..b632ac78d 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end render json: { content: translated_content } + rescue Google::Cloud::Error => e + # `details` carries the clean human message; `message` includes gRPC debug noise + render_could_not_create_error(e.details.presence || e.message) end private diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e..7bda1c802 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC end render_response( - dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) ) end diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb index 181e4965e..d7c49b35d 100644 --- a/app/controllers/api/v1/accounts/onboardings_controller.rb +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -1,17 +1,19 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController before_action :check_admin_authorization? + ONBOARDING_STEP_KEY = 'onboarding_step'.freeze + STEP_ACCOUNT_DETAILS = 'account_details'.freeze + STEP_INBOX_SETUP = 'inbox_setup'.freeze + ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze + def update + return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step]) + @account = Current.account - finalize = finalizing_account_details? - - @account.assign_attributes(account_params) - @account.custom_attributes.merge!(custom_attributes_params) - @account.custom_attributes.delete('onboarding_step') if finalize - @account.save! - - # TODO: re-enable when the help center generation UI is ready to surface progress - # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present? + # The client declares the step it is completing; `account_details` runs + # `complete_account_details`, and so on. The known-step guard above keeps the + # client value from `send`-ing an arbitrary method. + send("complete_#{params[:onboarding_step]}") render 'api/v1/accounts/update', format: :json end @@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll private - def finalizing_account_details? - @account.custom_attributes['onboarding_step'] == 'account_details' + def complete_account_details + # Only act while the cursor still points here, so a stale replay after + # onboarding finished can't re-enter it. + return unless current_step == STEP_ACCOUNT_DETAILS + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + + # inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded + # environment check); self-hosted finishes onboarding here. + if ChatwootApp.chatwoot_cloud? + move_to_step(STEP_INBOX_SETUP) + create_onboarding_inboxes + else + finish_onboarding + end end - def website - custom_attributes_params[:website] + def complete_inbox_setup + # Only finalize while the cursor still points here, so a stale or out-of-order + # request can't end onboarding early. Replays are no-ops. + return unless current_step == STEP_INBOX_SETUP + + finish_onboarding + end + + def current_step + @account.custom_attributes[ONBOARDING_STEP_KEY] + end + + def move_to_step(step) + @account.custom_attributes[ONBOARDING_STEP_KEY] = step + @account.save! + end + + def finish_onboarding + @account.custom_attributes.delete(ONBOARDING_STEP_KEY) + @account.save! + end + + def create_onboarding_inboxes + Onboarding::WebWidgetCreationService.new(@account, Current.user).perform end def account_params diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index e8688dcfb..6239e00eb 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController end def team_params - params.require(:team).permit(:name, :description, :allow_auto_assign) + params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color) end end diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3c..fde425b26 100644 --- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr response = dyte_processor_service.add_participant_to_meeting( @message.content_attributes['data']['meeting_id'], - @conversation.contact + @conversation.contact, + @message ) render_response(response) end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 192b3619c..93be19eb9 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic') end + def drilldown + return head :unauthorized unless Current.account_user.administrator? + return head :unprocessable_entity unless valid_drilldown_params? + + render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build + end + def conversations return head :unprocessable_entity if params[:type].blank? @@ -133,6 +140,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController }) end + def drilldown_params + permitted_params = params.permit( + :metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page + ).to_h.symbolize_keys + permitted_params.merge( + type: (params[:type].presence || 'account').to_sym, + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + ) + end + + def valid_drilldown_params? + %i[metric bucket_timestamp since until].all? { |param| params[param].present? } && + Reports::ReportMetricRegistry.supported?(params[:metric]) && + V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params) + end + def conversation_params { type: params[:type].to_sym, diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb new file mode 100644 index 000000000..633071301 --- /dev/null +++ b/app/controllers/concerns/portal_home_data.rb @@ -0,0 +1,29 @@ +module PortalHomeData + extend ActiveSupport::Concern + + private + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index b6df015f7..a369830b6 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ class DashboardController < ActionController::Base include SwitchLocale + include PortalHomeData GLOBAL_CONFIG_KEYS = %w[ LOGO @@ -63,6 +64,10 @@ class DashboardController < ActionController::Base return unless @portal @locale = @portal.default_locale + if @portal.layout == 'documentation' + request.variant = :documentation + load_home_data + end render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 2991b84d2..323440304 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController end def switch_locale_with_portal(&) - @locale = validate_and_get_locale(params[:locale]) + # Keep @locale as the portal's own locale code (e.g. th_TH) for content queries, + # while UI translations fall back to an available I18n locale (e.g. th). + @locale = params[:locale] - I18n.with_locale(@locale, &) + I18n.with_locale(validate_and_get_locale(@locale), &) end def switch_locale_with_article(&) @@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - article_locale = if article.category.present? - article.category.locale - else - article.locale - end - @locale = validate_and_get_locale(article_locale) - I18n.with_locale(@locale, &) + @locale = if article.category.present? + article.category.locale + else + article.locale + end + I18n.with_locale(validate_and_get_locale(@locale), &) end def allow_iframe_requests diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 57db11aec..4982278d7 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,4 +1,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController + include PortalHomeData + before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal @@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end - - def load_home_data - base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) - @visible_categories = @portal.categories - .where(locale: @locale) - .joins(:articles).where(articles: { status: :published }) - .order(position: :asc) - .group('categories.id') - @popular_topics = @visible_categories.first(3) - @featured = base_articles.order_by_views.limit(6) - @category_contributors = build_category_contributors(@visible_categories) - end - - def build_category_contributors(categories) - category_ids = categories.map(&:id) - return {} if category_ids.empty? - - @portal.articles - .published - .where(locale: @locale, category_id: category_ids) - .includes(:author) - .group_by(&:category_id) - .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } - end end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 27ce587f7..59b99c37e 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # def resource_params permitted_params = super - permitted_params[:limits] = permitted_params[:limits].to_h.compact + permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits) + permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models) permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present? permitted_params end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f11..b2683f2e0 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard # Add all_features last so it appears after manually_managed_features attributes[:all_features] = AccountFeaturesField + attributes[:captain_models] = CaptainModelOverridesField attributes else @@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[custom_attributes limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) - attrs = super + [limits: {}] + attrs = super + [limits: {}, captain_models: {}] # Add manually_managed_features to permitted attributes only for Chatwoot Cloud attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? diff --git a/app/javascript/dashboard/api/captain/messageReports.js b/app/javascript/dashboard/api/captain/messageReports.js new file mode 100644 index 000000000..2df1e5747 --- /dev/null +++ b/app/javascript/dashboard/api/captain/messageReports.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class MessageReports extends ApiClient { + constructor() { + super('captain/message_reports', { accountScoped: true }); + } +} + +export default new MessageReports(); diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index bab45bcb5..55b620d1a 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -16,6 +16,7 @@ class ArticlesAPI extends PortalsAPI { authorId, categorySlug, sort, + query, }) { const url = getArticleSearchURL({ pageNumber, @@ -25,6 +26,7 @@ class ArticlesAPI extends PortalsAPI { authorId, categorySlug, sort, + query, host: this.url, }); diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 00f040f8e..daa0cb11d 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient { }); } + getDrilldown({ + metric, + bucketTimestamp, + from, + to, + type = 'account', + id, + groupBy, + businessHours, + page, + perPage, + signal, + }) { + const requestConfig = { + params: { + metric, + bucket_timestamp: bucketTimestamp, + since: from, + until: to, + type, + id, + group_by: groupBy, + business_hours: businessHours, + timezone_offset: getTimeOffset(), + page, + per_page: perPage, + }, + }; + + if (signal) { + requestConfig.signal = signal; + } + + return axios.get(`${this.url}/drilldown`, requestConfig); + } + // eslint-disable-next-line default-param-last getSummary(since, until, type = 'account', id, groupBy, businessHours) { return axios.get(`${this.url}/summary`, { diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index e458633d0..178c98a70 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -1,6 +1,8 @@ import reportsAPI from '../reports'; import ApiClient from '../ApiClient'; +const timezoneOffset = () => -new Date().getTimezoneOffset() / 60; + describe('#Reports API', () => { it('creates correct instance', () => { expect(reportsAPI).toBeInstanceOf(ApiClient); @@ -11,6 +13,7 @@ describe('#Reports API', () => { expect(reportsAPI).toHaveProperty('update'); expect(reportsAPI).toHaveProperty('delete'); expect(reportsAPI).toHaveProperty('getReports'); + expect(reportsAPI).toHaveProperty('getDrilldown'); expect(reportsAPI).toHaveProperty('getSummary'); expect(reportsAPI).toHaveProperty('getAgentReports'); expect(reportsAPI).toHaveProperty('getLabelReports'); @@ -42,11 +45,14 @@ describe('#Reports API', () => { }); expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { params: { + business_hours: undefined, + group_by: undefined, + id: undefined, metric: 'conversations_count', since: 1621103400, until: 1621621800, type: 'account', - timezone_offset: -0, + timezone_offset: timezoneOffset(), }, }); }); @@ -59,13 +65,70 @@ describe('#Reports API', () => { group_by: undefined, id: undefined, since: 1621103400, - timezone_offset: -0, + timezone_offset: timezoneOffset(), type: 'account', until: 1621621800, }, }); }); + it('#getDrilldown', () => { + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + from: 1621103400, + to: 1621621800, + type: 'inbox', + id: 1, + groupBy: 'day', + businessHours: false, + page: 2, + perPage: 25, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: 1621103400, + until: 1621621800, + type: 'inbox', + id: 1, + group_by: 'day', + business_hours: false, + timezone_offset: timezoneOffset(), + page: 2, + per_page: 25, + }, + }); + }); + + it('#getDrilldown with abort signal', () => { + const controller = new AbortController(); + + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + signal: controller.signal, + }); + + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: undefined, + until: undefined, + type: 'account', + id: undefined, + group_by: undefined, + business_hours: undefined, + timezone_offset: timezoneOffset(), + page: undefined, + per_page: undefined, + }, + signal: controller.signal, + }); + }); + it('#getAgentReports', () => { reportsAPI.getAgentReports({ from: 1621103400, diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss index 784edce6c..4a67e5182 100644 --- a/app/javascript/dashboard/assets/scss/_next-colors.scss +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -145,6 +145,12 @@ --black-alpha-2: 0, 0, 0, 0.04; --border-blue: 39, 129, 246, 0.5; --white-alpha: 255, 255, 255, 0.8; + + // Voice call widget - light mode + --call-widget: 33, 34, 38, 0.95; + --call-widget-border: 255, 255, 255, 0.1; + --call-widget-text: 237, 238, 240, 1; + --call-widget-sub-text: 173, 177, 184, 1; } .dark { @@ -291,6 +297,12 @@ --border-blue: 39, 129, 246, 0.5; --border-container: 255, 255, 255, 0; --white-alpha: 255, 255, 255, 0.1; + + // Voice call widget - dark mode + --call-widget: 50, 53, 61, 1; + --call-widget-border: 255, 255, 255, 0.07; + --call-widget-text: 237, 238, 240, 1; + --call-widget-sub-text: 173, 177, 184, 1; } } // NEXT COLORS END diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue index df2b22b7e..3486816d5 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue @@ -16,6 +16,10 @@ const props = defineProps({ type: Array, required: true, }, + contact: { + type: Object, + required: true, + }, }); const { t } = useI18n(); @@ -49,7 +53,9 @@ const unreadMessagesCount = computed(() => { const hasSlaThreshold = computed(() => { return ( - slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId + !props.contact?.blocked && + slaCardLabelRef.value?.hasSlaThreshold && + props.conversation?.appliedSla?.id ); }); diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue index f9a2507a1..be3ddf280 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue @@ -126,6 +126,7 @@ const onCardClick = e => { v-show="!showMessagePreviewWithoutMeta" ref="cardMessagePreviewWithMetaRef" :conversation="conversation" + :contact="contact" :account-labels="accountLabels" /> diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue index d0f8f0211..0119b7168 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue @@ -51,7 +51,9 @@ const unreadCount = computed(() => props.chat.unread_count); const slaCardLabel = useTemplateRef('slaCardLabel'); const hasSlaPolicyId = computed( - () => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold + () => + !props.currentContact?.blocked && + (props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold) ); const selectedModel = computed({ diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue index ff57d6c93..608bd84bd 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js new file mode 100644 index 000000000..a6800beb5 --- /dev/null +++ b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js @@ -0,0 +1,222 @@ +import { mount } from '@vue/test-utils'; +import { h, nextTick } from 'vue'; +import DraggableReorderList from '../DraggableReorderList.vue'; + +// The component is pointer-driven, so we drive it through real pointer events on +// window while mocking the layout APIs jsdom does not implement: elementFromPoint +// (which card is under the cursor) and getBoundingClientRect (its geometry). +const elementAtPoint = { current: null }; + +const move = (clientX, clientY) => + window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY })); +const release = () => window.dispatchEvent(new MouseEvent('pointerup')); + +// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list. +const stubGeometry = wrapper => { + wrapper.element.getBoundingClientRect = () => ({ + left: 0, + right: 500, + top: 0, + bottom: 600, + }); + wrapper.findAll('[data-drag-id]').forEach((li, index) => { + const top = index * 50; + li.element.getBoundingClientRect = () => ({ + top, + height: 40, + bottom: top + 40, + }); + }); +}; + +const mountList = (props = {}) => + mount(DraggableReorderList, { + props: { items: [], ...props }, + slots: { + item: scope => h('div', { class: 'card' }, scope.item.title), + ghost: scope => h('div', { class: 'ghost' }, scope.item.title), + }, + global: { stubs: { Icon: true, teleport: true } }, + }); + +describe('DraggableReorderList', () => { + let wrapper; + + beforeEach(() => { + elementAtPoint.current = null; + document.elementFromPoint = vi.fn(() => elementAtPoint.current); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.useRealTimers(); + }); + + const startDragging = async id => { + stubGeometry(wrapper); + wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent( + new MouseEvent('pointerdown', { + button: 0, + clientX: 250, + clientY: 20, + bubbles: true, + }) + ); + await nextTick(); + }; + + it('renders each item through the item slot', () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + + const cards = wrapper.findAll('.card'); + expect(cards).toHaveLength(2); + expect(cards[0].text()).toBe('Alpha'); + expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true); + expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true); + }); + + it('shows a grab affordance only when enabled', () => { + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] }); + expect(wrapper.find('[data-drag-id="1"]').classes()).toContain( + 'cursor-grab' + ); + + wrapper.unmount(); + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true }); + expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain( + 'cursor-grab' + ); + }); + + it('does not start a drag when disabled', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + disabled: true, + }); + await startDragging(1); + move(250, 200); + await nextTick(); + + expect(wrapper.emitted('dragging')).toBeUndefined(); + }); + + it('emits dragging true then false across a drag', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + await startDragging(1); + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 60); + await nextTick(); + + expect(wrapper.emitted('dragging')[0]).toEqual([true]); + + release(); + await nextTick(); + expect(wrapper.emitted('dragging')[1]).toEqual([false]); + }); + + it('emits the midpoint position when dropped between two rows', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + { id: 3, title: 'Gamma', position: 30 }, + ], + }); + await startDragging(1); + + // Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap + // sits before Gamma; dropping there lands halfway between Beta and Gamma. + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 85); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 }); + }); + + it('does not reorder when the only row on a page is dropped in place', async () => { + // P1: dragging the lone article on a later page and releasing without + // crossing to another page must be a no-op, not move it to the top. + wrapper = mountList({ + items: [{ id: 5, title: 'Solo', position: 260 }], + currentPage: 2, + totalPages: 2, + }); + await startDragging(5); + move(250, 300); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('dragging')).toEqual([[true], [false]]); + expect(wrapper.emitted('reorder')).toBeUndefined(); + }); + + it('turns the page after dwelling on a pageable edge', async () => { + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + await startDragging(1); + + // Drag to the right edge over blank space (no card) and hold. + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')[0]).toEqual([2]); + }); + + it('can still turn pages after releasing during a pending flip', async () => { + // Releasing while a flip fetch is in flight must clear paging state, or every + // later drag would be stuck unable to navigate. + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + + // First drag: park at the edge to start a flip, then release before the new + // page arrives (items never change here). + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + release(); + await nextTick(); + + // Second drag must be able to flip again. + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]); + }); +}); diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue index 9b1c48a90..564888f0d 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue @@ -210,9 +210,9 @@ const handleClick = id => { -
-
-
+
+
+
{ {{ authorName || '-' }}
- + - {{ categoryName }} + {{ categoryName }}
@@ -247,7 +245,7 @@ const handleClick = id => {
- + {{ lastUpdatedAt }}
diff --git a/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue b/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue index 5b6399da7..fb564622a 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue @@ -67,11 +67,11 @@ const togglePortalSwitcher = () => { > {{ activePortalName }} -
+
+
+ +
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue index 575eaa828..9e886d1d3 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue @@ -1,6 +1,5 @@