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/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.lock b/Gemfile.lock index 4fdcf804a..8da80f52c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -193,7 +193,7 @@ GEM climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.7) connection_pool (2.5.5) crack (1.0.0) bigdecimal @@ -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) diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 17ae3db9f..b28b42bc0 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -92,15 +92,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/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/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/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/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/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue index e6bd3c272..bca6e877b 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/VoiceCallStatus.vue @@ -20,6 +20,7 @@ const ICON_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', + [VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x', }; const COLOR_MAP = { @@ -28,13 +29,18 @@ const COLOR_MAP = { [VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11', [VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9', [VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9', + [VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9', }; const isOutbound = computed( () => props.direction === VOICE_CALL_DIRECTION.OUTBOUND ); const isFailed = computed(() => - [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status) + [ + VOICE_CALL_STATUS.NO_ANSWER, + VOICE_CALL_STATUS.FAILED, + VOICE_CALL_STATUS.REJECTED, + ].includes(props.status) ); const labelKey = computed(() => { diff --git a/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue b/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue index 2272d1b7b..1dfcc51c2 100644 --- a/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue +++ b/app/javascript/dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue @@ -168,6 +168,7 @@ const selectEmoji = emoji => {