diff --git a/.eslintrc.js b/.eslintrc.js index 6c867f557..6b5205ad7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -103,6 +103,7 @@ module.exports = { '⌘', '📄', '🎉', + '🚀', '💬', '👥', '📥', diff --git a/.github/workflows/deploy_check.yml b/.github/workflows/deploy_check.yml index 7fda2b1a4..9f295a6c8 100644 --- a/.github/workflows/deploy_check.yml +++ b/.github/workflows/deploy_check.yml @@ -6,6 +6,11 @@ name: Deploy Check on: pull_request: +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: deployment_check: name: Check Deployment diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml index e9f84c313..5c45ba635 100644 --- a/.github/workflows/logging_percentage_check.yml +++ b/.github/workflows/logging_percentage_check.yml @@ -5,6 +5,11 @@ on: branches: - develop +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: log_lines_check: runs-on: ubuntu-latest diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 9be79da05..c2a4bd174 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -5,6 +5,11 @@ on: branches: - develop +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-22.04 diff --git a/Gemfile.lock b/Gemfile.lock index d9908f5e1..8315a5374 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -172,6 +172,8 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + childprocess (5.1.0) + logger (~> 1.5) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -433,10 +435,12 @@ GEM json (>= 1.8) rexml language_server-protocol (3.17.0.5) - launchy (2.5.2) + launchy (3.1.1) addressable (~> 2.8) - letter_opener (1.8.1) - launchy (>= 2.2, < 3) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) line-bot-api (1.28.0) lint_roller (1.1.0) liquid (5.4.0) @@ -563,7 +567,7 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (6.0.0) + public_suffix (6.0.2) puma (6.4.3) nio4r (~> 2.0) pundit (2.3.0) diff --git a/app/assets/javascripts/secretField.js b/app/assets/javascripts/secretField.js index 463109812..da2327eff 100644 --- a/app/assets/javascripts/secretField.js +++ b/app/assets/javascripts/secretField.js @@ -10,7 +10,8 @@ function toggleSecretField(e) { if (!textElement) return; if (textElement.dataset.secretMasked === 'false') { - textElement.textContent = '•'.repeat(10); + const maskedLength = secretField.dataset.secretText?.length || 10; + textElement.textContent = '•'.repeat(maskedLength); textElement.dataset.secretMasked = 'true'; toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); @@ -32,3 +33,13 @@ function copySecretField(e) { navigator.clipboard.writeText(secretField.dataset.secretText); } + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.cell-data__secret-field').forEach(field => { + const span = field.querySelector('[data-secret-masked]'); + if (span && span.dataset.secretMasked === 'true') { + const len = field.dataset.secretText?.length || 10; + span.textContent = '•'.repeat(len); + } + }); +}); diff --git a/app/assets/stylesheets/administrate/components/_cells.scss b/app/assets/stylesheets/administrate/components/_cells.scss index b5a079976..ae2d603cd 100644 --- a/app/assets/stylesheets/administrate/components/_cells.scss +++ b/app/assets/stylesheets/administrate/components/_cells.scss @@ -46,17 +46,25 @@ .cell-data__secret-field { align-items: center; + color: $hint-grey; display: flex; span { - flex: 1; + flex: 0 0 auto; } - button { - margin-left: 5px; + [data-secret-toggler], + [data-secret-copier] { + background: transparent; + border: 0; + color: inherit; + margin-left: 0.5rem; + padding: 0; svg { fill: currentColor; + height: 1.25rem; + width: 1.25rem; } } } diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb new file mode 100644 index 000000000..caa5a04d8 --- /dev/null +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -0,0 +1,103 @@ +class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder + attr_reader :account, :params + + # rubocop:disable Lint/MissingSuper + # the parent class has no initialize + def initialize(account:, params:) + @account = account + @params = params + + timezone_offset = (params[:timezone_offset] || 0).to_f + @timezone = ActiveSupport::TimeZone[timezone_offset]&.name + end + # rubocop:enable Lint/MissingSuper + + def build + labels = account.labels.to_a + return [] if labels.empty? + + report_data = collect_report_data + labels.map { |label| build_label_report(label, report_data) } + end + + private + + def collect_report_data + conversation_filter = build_conversation_filter + use_business_hours = use_business_hours? + + { + conversation_counts: fetch_conversation_counts(conversation_filter), + resolved_counts: fetch_resolved_counts(conversation_filter), + resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), + first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), + reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours) + } + end + + def build_label_report(label, report_data) + { + id: label.id, + name: label.title, + conversations_count: report_data[:conversation_counts][label.title] || 0, + avg_resolution_time: report_data[:resolution_metrics][label.title] || 0, + avg_first_response_time: report_data[:first_response_metrics][label.title] || 0, + avg_reply_time: report_data[:reply_metrics][label.title] || 0, + resolved_conversations_count: report_data[:resolved_counts][label.title] || 0 + } + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end + + def build_conversation_filter + conversation_filter = { account_id: account.id } + conversation_filter[:created_at] = range if range.present? + + conversation_filter + end + + def fetch_conversation_counts(conversation_filter) + fetch_counts(conversation_filter) + end + + def fetch_resolved_counts(conversation_filter) + # since the base query is ActsAsTaggableOn, + # the status :resolved won't automatically be converted to integer status + fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved])) + end + + def fetch_counts(conversation_filter) + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + taggable_type: 'Conversation', + context: 'labels', + conversations: conversation_filter + ) + .select('tags.name, COUNT(taggings.*) AS count') + .group('tags.name') + .each_with_object({}) { |record, hash| hash[record.name] = record.count } + end + + def fetch_metrics(conversation_filter, event_name, use_business_hours) + ReportingEvent + .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id') + .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + conversations: conversation_filter, + name: event_name, + taggings: { taggable_type: 'Conversation', context: 'labels' } + ) + .group('tags.name') + .order('tags.name') + .select( + 'tags.name', + use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value' + ) + .each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f } + end +end diff --git a/app/controllers/api/v1/accounts/google/authorizations_controller.rb b/app/controllers/api/v1/accounts/google/authorizations_controller.rb index 1140a214b..87a7cfa3f 100644 --- a/app/controllers/api/v1/accounts/google/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/google/authorizations_controller.rb @@ -1,32 +1,23 @@ -class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include GoogleConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = google_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/google/callback", - scope: 'email profile https://mail.google.com/', + scope: scope, response_type: 'code', prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it access_type: 'offline', # the default is 'online' + state: state, client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) } ) if redirect_url - cache_key = "google::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 61d16b2ca..e7b3b197b 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def create_channel - return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) + return unless allowed_channel_types.include?(permitted_params[:channel][:type]) account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) end + def allowed_channel_types + %w[web_widget api email line telegram whatsapp sms] + end + def update_inbox_working_hours @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] end diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index eace4411a..053c29731 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -1,7 +1,6 @@ -class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include InstagramConcern include Instagram::IntegrationHelper - before_action :check_authorization def create # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization @@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index c66f06909..eb6525bb1 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,8 +1,9 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController - before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] def destroy + revoke_linear_token @hook.destroy! head :ok end @@ -27,10 +28,16 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def create_issue - issue = linear_processor_service.create_issue(permitted_params) + issue = linear_processor_service.create_issue(permitted_params, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_created, + issue_data: { id: issue[:data][:identifier] }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end @@ -38,21 +45,34 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def link_issue issue_id = permitted_params[:issue_id] title = permitted_params[:title] - issue = linear_processor_service.link_issue(conversation_link, issue_id, title) + issue = linear_processor_service.link_issue(conversation_link, issue_id, title, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_linked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end def unlink_issue link_id = permitted_params[:link_id] + issue_id = permitted_params[:issue_id] issue = linear_processor_service.unlink_issue(link_id) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_unlinked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end @@ -101,4 +121,15 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def fetch_hook @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') end + + def revoke_linear_token + return unless @hook&.access_token + + begin + linear_client = Linear.new(@hook.access_token) + linear_client.revoke_token + rescue StandardError => e + Rails.logger.error "Failed to revoke Linear token: #{e.message}" + end + end end diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb new file mode 100644 index 000000000..dff6ccece --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -0,0 +1,14 @@ +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end + + private + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion') + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index df563094a..a300b5f59 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -1,28 +1,19 @@ -class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include MicrosoftConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = microsoft_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/microsoft/callback", - scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', + scope: scope, + state: state, prompt: 'consent' } ) if redirect_url - cache_key = "microsoft::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/notion/authorizations_controller.rb b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb new file mode 100644 index 000000000..bb9b2f858 --- /dev/null +++ b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb @@ -0,0 +1,21 @@ +class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include NotionConcern + + def create + redirect_url = notion_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/notion/callback", + response_type: 'code', + owner: 'user', + state: state, + client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil) + } + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb new file mode 100644 index 000000000..feb218b59 --- /dev/null +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -0,0 +1,23 @@ +class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController + before_action :check_authorization + + protected + + def scope + '' + end + + def state + Current.account.to_sgid(expires_in: 15.minutes).to_s + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 989952cfd..f31a53c7e 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -1,6 +1,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :prepare_builder_params, only: [:agent, :team, :inbox] + before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::InboxSummaryBuilder) end + def label + render_report_with(V2::Reports::LabelSummaryBuilder) + end + private def check_authorization diff --git a/app/controllers/concerns/google_concern.rb b/app/controllers/concerns/google_concern.rb index 474b14aec..13de7ced3 100644 --- a/app/controllers/concerns/google_concern.rb +++ b/app/controllers/concerns/google_concern.rb @@ -14,7 +14,7 @@ module GoogleConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'email profile https://mail.google.com/' end end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index 507b9f8a3..c3e0994bd 100644 --- a/app/controllers/concerns/microsoft_concern.rb +++ b/app/controllers/concerns/microsoft_concern.rb @@ -15,7 +15,7 @@ module MicrosoftConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email' end end diff --git a/app/controllers/concerns/notion_concern.rb b/app/controllers/concerns/notion_concern.rb new file mode 100644 index 000000000..2b94fe63b --- /dev/null +++ b/app/controllers/concerns/notion_concern.rb @@ -0,0 +1,21 @@ +module NotionConcern + extend ActiveSupport::Concern + + def notion_client + app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + }) + end + + private + + def scope + '' + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a2cb466f1..6a4ce2461 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -15,7 +15,7 @@ class DashboardController < ActionController::Base private def ensure_html_format - head :not_acceptable unless request.format.html? + render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json? end def set_global_config diff --git a/app/controllers/notion/callbacks_controller.rb b/app/controllers/notion/callbacks_controller.rb new file mode 100644 index 000000000..94030fc8e --- /dev/null +++ b/app/controllers/notion/callbacks_controller.rb @@ -0,0 +1,36 @@ +class Notion::CallbacksController < OauthCallbackController + include NotionConcern + + private + + def provider_name + 'notion' + end + + def oauth_client + notion_client + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'notion', + settings: { + token_type: parsed_body['token_type'], + workspace_name: parsed_body['workspace_name'], + workspace_id: parsed_body['workspace_id'], + workspace_icon: parsed_body['workspace_icon'], + bot_id: parsed_body['bot_id'], + owner: parsed_body['owner'] + } + ) + + hook.save! + redirect_to notion_redirect_uri + end + + def notion_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion" + end +end \ No newline at end of file diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 9aa73956a..be0fa5008 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -6,7 +6,6 @@ class OauthCallbackController < ApplicationController ) handle_response - ::Redis::Alfred.delete(cache_key) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception redirect_to '/' @@ -64,10 +63,6 @@ class OauthCallbackController < ApplicationController raise NotImplementedError end - def cache_key - "#{provider_name}::#{users_data['email'].downcase}" - end - def create_channel_with_inbox ActiveRecord::Base.transaction do channel_email = Channel::Email.create!(email: users_data['email'], account: account) @@ -86,12 +81,17 @@ class OauthCallbackController < ApplicationController decoded_token[0] end - def account_id - ::Redis::Alfred.get(cache_key) + def account_from_signed_id + raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? + + account = GlobalID::Locator.locate_signed(params[:state]) + raise 'Invalid or expired state' if account.nil? + + account end def account - @account ||= Account.find(account_id) + @account ||= account_from_signed_id end # Fallback name, for when name field is missing from users_data diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 32a147d34..e6cc2aa69 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -7,13 +7,19 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def index @articles = @portal.articles.published.includes(:category, :author) + + @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? + @articles_count = @articles.count + search_articles order_by_sort_param limit_results end - def show; end + def show + @og_image_url = helpers.set_og_image_url(@portal.name, @article.title) + end def tracking_pixel @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index c3a7b59e9..ebfcb310a 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -8,7 +8,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: @categories = @portal.categories.order(position: :asc) end - def show; end + def show + @og_image_url = helpers.set_og_image_url(@portal.name, @category.name) + end private diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index e8fb867cb..df4552432 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -4,7 +4,9 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl before_action :redirect_to_portal_with_locale, only: [:show] layout 'portal' - def show; end + def show + @og_image_url = helpers.set_og_image_url('', @portal.header_text) + end def sitemap @help_center_url = @portal.custom_domain || ChatwootApp.help_center_root diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 204bfc95b..771f9f28c 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,6 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], + 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] } diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index ff16e9386..455828228 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -27,7 +27,10 @@ class Twilio::CallbackController < ApplicationController *Array.new(10) { |i| :"MediaUrl#{i}" }, *Array.new(10) { |i| :"MediaContentType#{i}" }, :MessagingServiceSid, - :NumMedia + :NumMedia, + :Latitude, + :Longitude, + :MessageType ) end end diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 22c51b6ef..23694d08d 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper end def generate_labels_report - Current.account.labels.map do |label| - label_report = report_builder({ type: :label, id: label.id }).short_summary - [label.title] + generate_readable_report_metrics(label_report) + reports = V2::Reports::LabelSummaryBuilder.new( + account: Current.account, + params: build_params({}) + ).build + + reports.map do |report| + [report[:name]] + generate_readable_report_metrics(report) end end diff --git a/app/helpers/message_format_helper.rb b/app/helpers/message_format_helper.rb index 2c50fd609..8b271e3a3 100644 --- a/app/helpers/message_format_helper.rb +++ b/app/helpers/message_format_helper.rb @@ -1,9 +1,13 @@ module MessageFormatHelper - include RegexHelper - def transform_user_mention_content(message_content) # attachment message without content, message_content is nil - message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : '' + return '' unless message_content.presence + + # Use CommonMarker to convert markdown to plain text for notifications + # This handles all markdown formatting (links, bold, italic, etc.) not just mentions + # Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) + # To: @👍 customer support + CommonMarker.render_doc(message_content).to_plaintext.strip end def render_message_content(message_content) diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 2b1ab7b21..3ed303556 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -1,4 +1,21 @@ module PortalHelper + def set_og_image_url(portal_name, title) + cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL'] + return if cdn_url.blank? + + client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF'] + + uri = URI.parse(cdn_url) + uri.path = '/og' + uri.query = URI.encode_www_form( + clientRef: client_ref, + title: title, + portalName: portal_name + ) + + uri.to_s + end + def generate_portal_bg_color(portal_color, theme) base_color = theme == 'dark' ? 'black' : 'white' "color-mix(in srgb, #{portal_color} 20%, #{base_color})" diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index e51958e9e..675f6ea67 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,6 +1,6 @@