diff --git a/.circleci/config.yml b/.circleci/config.yml index 804c63857..24119bc75 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -218,6 +218,49 @@ jobs: source ~/.rvm/scripts/rvm bundle install + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -234,6 +277,7 @@ jobs: sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" # Database setup - run: diff --git a/.env.example b/.env.example index d5c7a76f9..55750b2f2 100644 --- a/.env.example +++ b/.env.example @@ -274,3 +274,5 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# REDIS_ALFRED_SIZE=10 diff --git a/AGENTS.md b/AGENTS.md index ef1d3b26d..474fe6e7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,13 @@ - Avoid writing specs unless explicitly asked - 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 +- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors + +## Commit Messages + +- Prefer Conventional Commits: `type(scope): subject` (scope optional) +- Example: `feat(auth): add user authentication` - Don't reference Claude in commit messages ## Project-Specific @@ -78,3 +85,4 @@ Practical checklist for any change impacting core logic or public APIs - Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs. - When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. - Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. +- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`. diff --git a/Gemfile.lock b/Gemfile.lock index 15ed841ac..1cdfabee0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -140,24 +140,27 @@ GEM actionmailbox (>= 7.1.0) aws-sdk-s3 (~> 1, >= 1.123.0) aws-sdk-sns (~> 1, >= 1.61.0) - aws-eventstream (1.2.0) - aws-partitions (1.760.0) - aws-sdk-core (3.188.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1198.0) + aws-sdk-core (3.240.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.64.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.126.0) - aws-sdk-core (~> 3, >= 3.174.0) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.208.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) + aws-sigv4 (~> 1.5) aws-sdk-sns (1.70.0) aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sigv4 (1.5.2) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) barnes (0.0.9) multi_json (~> 1) @@ -438,7 +441,8 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.21.0) + httparty (0.24.0) + csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -552,7 +556,8 @@ GEM ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.6.0) + multi_xml (0.8.0) + bigdecimal (>= 3.1, < 5) multipart-post (2.3.0) mutex_m (0.3.0) neighbor (0.2.3) diff --git a/README.md b/README.md index 21316b422..d8b8ae7a2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ ___ The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
-
+ {{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
+
+ {{ t('YEAR_IN_REVIEW.LOADING') }}
+
+ {{ t('YEAR_IN_REVIEW.ERROR') }}
+ {{ error }}
+ {{
+ t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
+ count: busiestDay.count,
+ })
+ }}
+ {{ performanceHelperText }}
+
+ {{ performanceHelperText }}
+
+ {{ personalityMessage }}
+
+ {{ getCallInfo(call).contactName }}
+
+ {{ getCallInfo(call).inboxName }}
+
+ {{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
+
+ {{ formattedCallDuration }}
+
+ {{
+ incomingCalls[0]?.callDirection === 'outbound'
+ ? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
+ : $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
+ }}
+
- {{ category }}
-
- {{ truncatedContent }}
-
-
- {{ email }}
-
- •
- {{ phone }}
-
{{ $t('SEARCH.EMPTY_STATE', { item: titleCase, query }) }}
+ {{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }}
+
@@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
-*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
+*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
diff --git a/VERSION_CW b/VERSION_CW
index 88f181192..5b341fd79 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.8.0
+4.9.1
diff --git a/app/builders/year_in_review_builder.rb b/app/builders/year_in_review_builder.rb
new file mode 100644
index 000000000..545fe8029
--- /dev/null
+++ b/app/builders/year_in_review_builder.rb
@@ -0,0 +1,74 @@
+class YearInReviewBuilder
+ attr_reader :account, :user_id, :year
+
+ def initialize(account:, user_id:, year:)
+ @account = account
+ @user_id = user_id
+ @year = year
+ end
+
+ def build
+ {
+ year: year,
+ total_conversations: total_conversations_count,
+ busiest_day: busiest_day_data,
+ support_personality: support_personality_data
+ }
+ end
+
+ private
+
+ def year_range
+ @year_range ||= begin
+ start_time = Time.zone.local(year, 1, 1).beginning_of_day
+ end_time = Time.zone.local(year, 12, 31).end_of_day
+ start_time..end_time
+ end
+ end
+
+ def total_conversations_count
+ account.conversations
+ .where(assignee_id: user_id, created_at: year_range)
+ .count
+ end
+
+ def busiest_day_data
+ daily_counts = account.conversations
+ .where(assignee_id: user_id, created_at: year_range)
+ .group_by_day(:created_at, range: year_range, time_zone: Time.zone)
+ .count
+
+ return nil if daily_counts.empty?
+
+ busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
+
+ return nil if count.zero?
+
+ {
+ date: busiest_date.strftime('%b %d'),
+ count: count
+ }
+ end
+
+ def support_personality_data
+ response_time = average_response_time
+
+ return { avg_response_time_seconds: 0 } if response_time.nil?
+
+ {
+ avg_response_time_seconds: response_time.to_i
+ }
+ end
+
+ def average_response_time
+ avg_time = account.reporting_events
+ .where(
+ name: 'first_response',
+ user_id: user_id,
+ created_at: year_range
+ )
+ .average(:value)
+
+ avg_time&.to_f
+ end
+end
diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb
index 3d894808d..0840d0eea 100644
--- a/app/controllers/api/v1/accounts/automation_rules_controller.rb
+++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb
@@ -1,4 +1,6 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
+ include AttachmentConcern
+
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end
def create
+ blobs, actions, error = validate_and_prepare_attachments(params[:actions])
+ return render_could_not_create_error(error) if error
+
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
- @automation_rule.actions = params[:actions]
+ @automation_rule.actions = actions
@automation_rule.conditions = params[:conditions]
- render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
+ return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
@automation_rule.save!
- process_attachments
- @automation_rule
+ blobs.each { |blob| @automation_rule.files.attach(blob) }
end
def update
- ActiveRecord::Base.transaction do
- automation_rule_update
- process_attachments
+ blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
+ return render_could_not_create_error(error) if error
+ ActiveRecord::Base.transaction do
+ @automation_rule.assign_attributes(automation_rules_permit)
+ @automation_rule.actions = actions if params[:actions]
+ @automation_rule.conditions = params[:conditions] if params[:conditions]
+ @automation_rule.save!
+ blobs.each { |blob| @automation_rule.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
- render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
+ render_could_not_create_error(@automation_rule.errors.messages)
end
end
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
end
- def process_attachments
- actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
- return if actions.blank?
-
- actions.each do |action|
- blob_id = action['action_params']
- blob = ActiveStorage::Blob.find_by(id: blob_id)
- @automation_rule.files.attach(blob)
- end
- end
-
private
- def automation_rule_update
- @automation_rule.update!(automation_rules_permit)
- @automation_rule.actions = params[:actions] if params[:actions]
- @automation_rule.conditions = params[:conditions] if params[:conditions]
- @automation_rule.save!
- end
-
def automation_rules_permit
params.permit(
- :name, :description, :event_name, :account_id, :active,
+ :name, :description, :event_name, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
index 58ec3bfca..f3b14d49f 100644
--- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
+++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
- :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
+ :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
)
end
end
diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb
index 5dcdd2023..c4e0cd6dd 100644
--- a/app/controllers/api/v1/accounts/macros_controller.rb
+++ b/app/controllers/api/v1/accounts/macros_controller.rb
@@ -1,4 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
+ include AttachmentConcern
+
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
end
def create
+ blobs, actions, error = validate_and_prepare_attachments(params[:actions])
+ return render_could_not_create_error(error) if error
+
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
- @macro.actions = params[:actions]
+ @macro.actions = actions
- render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
+ return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
@macro.save!
- process_attachments
- @macro
+ blobs.each { |blob| @macro.files.attach(blob) }
end
def update
+ blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
+ return render_could_not_create_error(error) if error
+
ActiveRecord::Base.transaction do
- @macro.update!(macros_with_user)
+ @macro.assign_attributes(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
- process_attachments
+ @macro.actions = actions if params[:actions]
@macro.save!
+ blobs.each { |blob| @macro.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
- render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
+ render_could_not_create_error(@macro.errors.messages)
end
end
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
private
- def process_attachments
- actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
- return if actions.blank?
-
- actions.each do |action|
- blob_id = action['action_params']
- blob = ActiveStorage::Blob.find_by(id: blob_id)
- @macro.files.attach(blob)
- end
- end
-
def permitted_params
params.permit(
- :name, :account_id, :visibility,
+ :name, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index 57344cc1e..8eb24b757 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def process_attached_logo
blob_id = params[:blob_id]
- blob = ActiveStorage::Blob.find_by(id: blob_id)
+ blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
- :id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
+ :id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb
index 13e3a6a6c..7ee25e02e 100644
--- a/app/controllers/api/v1/accounts/search_controller.rb
+++ b/app/controllers/api/v1/accounts/search_controller.rb
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
+ rescue ArgumentError => e
+ render json: { error: e.message }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb
new file mode 100644
index 000000000..7c7320393
--- /dev/null
+++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb
@@ -0,0 +1,15 @@
+class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
+ include Tiktok::IntegrationHelper
+
+ def create
+ redirect_url = Tiktok::AuthClient.authorize_url(
+ state: generate_tiktok_token(Current.account.id)
+ )
+
+ if redirect_url
+ render json: { success: true, url: redirect_url }
+ else
+ render json: { success: false }, status: :unprocessable_entity
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb
index 6530279da..479d8ae1b 100644
--- a/app/controllers/api/v1/accounts/upload_controller.rb
+++ b/app/controllers/api/v1/accounts/upload_controller.rb
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def render_success(file_blob)
- render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
+ render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
end
def render_error(message, status)
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 773126755..57062a5b2 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
- params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
+ params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
+ conversation_required_attributes: [])
end
def check_signup_enabled
diff --git a/app/controllers/api/v2/accounts/year_in_reviews_controller.rb b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb
new file mode 100644
index 000000000..7946614bb
--- /dev/null
+++ b/app/controllers/api/v2/accounts/year_in_reviews_controller.rb
@@ -0,0 +1,26 @@
+class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
+ def show
+ year = params[:year] || 2025
+ cache_key = "year_in_review_#{Current.account.id}_#{year}"
+
+ cached_data = Current.user.ui_settings&.dig(cache_key)
+
+ if cached_data.present?
+ render json: cached_data
+ else
+ builder = YearInReviewBuilder.new(
+ account: Current.account,
+ user_id: Current.user.id,
+ year: year
+ )
+
+ data = builder.build
+
+ ui_settings = Current.user.ui_settings || {}
+ ui_settings[cache_key] = data
+ Current.user.update(ui_settings: ui_settings)
+
+ render json: data
+ end
+ end
+end
diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb
new file mode 100644
index 000000000..2652f04be
--- /dev/null
+++ b/app/controllers/concerns/attachment_concern.rb
@@ -0,0 +1,35 @@
+module AttachmentConcern
+ extend ActiveSupport::Concern
+
+ def validate_and_prepare_attachments(actions, record = nil)
+ blobs = []
+ return [blobs, actions, nil] if actions.blank?
+
+ sanitized = actions.map do |action|
+ next action unless action[:action_name] == 'send_attachment'
+
+ result = process_attachment_action(action, record, blobs)
+ return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
+
+ result
+ end
+
+ [blobs, sanitized, nil]
+ end
+
+ private
+
+ def process_attachment_action(action, record, blobs)
+ blob_id = action[:action_params].first
+ blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
+
+ return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
+ return action if blob_already_attached?(record, blob_id)
+
+ nil
+ end
+
+ def blob_already_attached?(record, blob_id)
+ record&.files&.any? { |f| f.blob_id == blob_id.to_i }
+ end
+end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 5003c4c70..d57ad0e53 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
- ANALYTICS_TOKEN
+ CLOUD_ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
@@ -73,6 +73,7 @@ class DashboardController < ActionController::Base
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
+ TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index 1a9539bb6..ec51305b5 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -46,6 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
+ 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb
new file mode 100644
index 000000000..e484905c3
--- /dev/null
+++ b/app/controllers/tiktok/callbacks_controller.rb
@@ -0,0 +1,144 @@
+class Tiktok::CallbacksController < ApplicationController
+ include Tiktok::IntegrationHelper
+
+ def show
+ return handle_authorization_error if params[:error].present?
+ return handle_ungranted_scopes_error unless all_scopes_granted?
+
+ process_successful_authorization
+ rescue StandardError => e
+ handle_error(e)
+ end
+
+ private
+
+ def all_scopes_granted?
+ granted_scopes = short_term_access_token[:scope].to_s.split(',')
+ (Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
+ end
+
+ def process_successful_authorization
+ inbox, already_exists = find_or_create_inbox
+
+ if already_exists
+ redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
+ else
+ redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
+ end
+ end
+
+ def handle_error(error)
+ Rails.logger.error("TikTok Channel creation Error: #{error.message}")
+ ChatwootExceptionTracker.new(error).capture_exception
+
+ redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
+ end
+
+ # Handles the case when a user denies permissions or cancels the authorization flow
+ def handle_authorization_error
+ redirect_to_error_page(
+ error_type: params[:error] || 'access_denied',
+ code: params[:error_code],
+ error_message: params[:error_description] || 'User cancelled the Authorization'
+ )
+ end
+
+ # Handles the case when a user partially accepted the required scopes
+ def handle_ungranted_scopes_error
+ redirect_to_error_page(
+ error_type: 'ungranted_scopes',
+ code: 400,
+ error_message: 'User did not grant all the required scopes'
+ )
+ end
+
+ # Centralized method to redirect to error page with appropriate parameters
+ # This ensures consistent error handling across different error scenarios
+ # Frontend will handle the error page based on the error_type
+ def redirect_to_error_page(error_type:, code:, error_message:)
+ redirect_to app_new_tiktok_inbox_url(
+ account_id: account_id,
+ error_type: error_type,
+ code: code,
+ error_message: error_message
+ )
+ end
+
+ def find_or_create_inbox
+ business_details = tiktok_client.business_account_details
+ channel_tiktok = find_channel
+ channel_exists = channel_tiktok.present?
+
+ if channel_tiktok
+ update_channel(channel_tiktok, business_details)
+ else
+ channel_tiktok = create_channel_with_inbox(business_details)
+ end
+
+ # reauthorized will also update cache keys for the associated inbox
+ channel_tiktok.reauthorized!
+
+ set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
+
+ [channel_tiktok.inbox, channel_exists]
+ end
+
+ def create_channel_with_inbox(business_details)
+ ActiveRecord::Base.transaction do
+ channel_tiktok = Channel::Tiktok.create!(
+ account: account,
+ business_id: short_term_access_token[:business_id],
+ access_token: short_term_access_token[:access_token],
+ refresh_token: short_term_access_token[:refresh_token],
+ expires_at: short_term_access_token[:expires_at],
+ refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
+ )
+
+ account.inboxes.create!(
+ account: account,
+ channel: channel_tiktok,
+ name: business_details[:display_name].presence || business_details[:username]
+ )
+
+ channel_tiktok
+ end
+ end
+
+ def find_channel
+ Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
+ end
+
+ def update_channel(channel_tiktok, business_details)
+ channel_tiktok.update!(
+ access_token: short_term_access_token[:access_token],
+ refresh_token: short_term_access_token[:refresh_token],
+ expires_at: short_term_access_token[:expires_at],
+ refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
+ )
+
+ channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
+ end
+
+ def set_avatar(inbox, avatar_url)
+ Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
+ end
+
+ def account_id
+ @account_id ||= verify_tiktok_token(params[:state])
+ end
+
+ def account
+ @account ||= Account.find(account_id)
+ end
+
+ def short_term_access_token
+ @short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
+ end
+
+ def tiktok_client
+ @tiktok_client ||= Tiktok::Client.new(
+ business_id: short_term_access_token[:business_id],
+ access_token: short_term_access_token[:access_token]
+ )
+ end
+end
diff --git a/app/controllers/webhooks/tiktok_controller.rb b/app/controllers/webhooks/tiktok_controller.rb
new file mode 100644
index 000000000..efaa1830c
--- /dev/null
+++ b/app/controllers/webhooks/tiktok_controller.rb
@@ -0,0 +1,53 @@
+class Webhooks::TiktokController < ActionController::API
+ before_action :verify_signature!
+
+ def events
+ event = JSON.parse(request_payload)
+ if echo_event?
+ # Add delay to prevent race condition where echo arrives before send message API completes
+ # This avoids duplicate messages when echo comes early during API processing
+ ::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
+ else
+ ::Webhooks::TiktokEventsJob.perform_later(event)
+ end
+
+ head :ok
+ end
+
+ private
+
+ def request_payload
+ @request_payload ||= request.body.read
+ end
+
+ def verify_signature!
+ signature_header = request.headers['Tiktok-Signature']
+ client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
+ received_timestamp, received_signature = extract_signature_parts(signature_header)
+
+ return head :unauthorized unless client_secret && received_timestamp && received_signature
+
+ signature_payload = "#{received_timestamp}.#{request_payload}"
+ computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
+
+ return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
+
+ # Check timestamp delay (acceptable delay: 5 seconds)
+ current_timestamp = Time.current.to_i
+ delay = current_timestamp - received_timestamp
+
+ return head :unauthorized if delay > 5
+ end
+
+ def extract_signature_parts(signature_header)
+ return [nil, nil] if signature_header.blank?
+
+ keys = signature_header.split(',')
+ signature_parts = keys.map { |part| part.split('=') }.to_h
+ [signature_parts['t']&.to_i, signature_parts['s']]
+ end
+
+ def echo_event?
+ params[:event] == 'im_send_msg'
+ end
+end
diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml
index b05c603cd..34c7a8138 100644
--- a/app/helpers/super_admin/features.yml
+++ b/app/helpers/super_admin/features.yml
@@ -78,6 +78,12 @@ instagram:
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
+tiktok:
+ name: 'TikTok'
+ description: 'Stay connected with your customers on TikTok'
+ enabled: true
+ icon: 'icon-tiktok'
+ config_key: 'tiktok'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb
new file mode 100644
index 000000000..b2de4a092
--- /dev/null
+++ b/app/helpers/tiktok/integration_helper.rb
@@ -0,0 +1,47 @@
+module Tiktok::IntegrationHelper
+ # Generates a signed JWT token for Tiktok integration
+ #
+ # @param account_id [Integer] The account ID to encode in the token
+ # @return [String, nil] The encoded JWT token or nil if client secret is missing
+ def generate_tiktok_token(account_id)
+ return if client_secret.blank?
+
+ JWT.encode(token_payload(account_id), client_secret, 'HS256')
+ rescue StandardError => e
+ Rails.logger.error("Failed to generate TikTok token: #{e.message}")
+ nil
+ end
+
+ # Verifies and decodes a Tiktok JWT token
+ #
+ # @param token [String] The JWT token to verify
+ # @return [Integer, nil] The account ID from the token or nil if invalid
+ def verify_tiktok_token(token)
+ return if token.blank? || client_secret.blank?
+
+ decode_token(token, client_secret)
+ end
+
+ private
+
+ def client_secret
+ @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
+ end
+
+ def token_payload(account_id)
+ {
+ sub: account_id,
+ iat: Time.current.to_i
+ }
+ end
+
+ def decode_token(token, secret)
+ JWT.decode(token, secret, true, {
+ algorithm: 'HS256',
+ verify_expiration: true
+ }).first['sub']
+ rescue StandardError => e
+ Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
+ nil
+ end
+end
diff --git a/app/javascript/dashboard/api/channel/tiktokClient.js b/app/javascript/dashboard/api/channel/tiktokClient.js
new file mode 100644
index 000000000..389eb2699
--- /dev/null
+++ b/app/javascript/dashboard/api/channel/tiktokClient.js
@@ -0,0 +1,14 @@
+/* global axios */
+import ApiClient from '../ApiClient';
+
+class TiktokChannel extends ApiClient {
+ constructor() {
+ super('tiktok', { accountScoped: true });
+ }
+
+ generateAuthorization(payload) {
+ return axios.post(`${this.url}/authorization`, payload);
+ }
+}
+
+export default new TiktokChannel();
diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
new file mode 100644
index 000000000..67f74a171
--- /dev/null
+++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
@@ -0,0 +1,95 @@
+import { Device } from '@twilio/voice-sdk';
+import VoiceAPI from './voiceAPIClient';
+
+const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
+
+class TwilioVoiceClient extends EventTarget {
+ constructor() {
+ super();
+ this.device = null;
+ this.activeConnection = null;
+ this.initialized = false;
+ this.inboxId = null;
+ }
+
+ async initializeDevice(inboxId) {
+ this.destroyDevice();
+
+ const response = await VoiceAPI.getToken(inboxId);
+ const { token, account_id } = response || {};
+ if (!token) throw new Error('Invalid token');
+
+ this.device = new Device(token, {
+ allowIncomingWhileBusy: true,
+ disableAudioContextSounds: true,
+ appParams: { account_id },
+ });
+
+ this.device.removeAllListeners();
+ this.device.on('connect', conn => {
+ this.activeConnection = conn;
+ conn.on('disconnect', this.onDisconnect);
+ });
+
+ this.device.on('disconnect', this.onDisconnect);
+
+ this.device.on('tokenWillExpire', async () => {
+ const r = await VoiceAPI.getToken(this.inboxId);
+ if (r?.token) this.device.updateToken(r.token);
+ });
+
+ this.initialized = true;
+ this.inboxId = inboxId;
+
+ return this.device;
+ }
+
+ get hasActiveConnection() {
+ return !!this.activeConnection;
+ }
+
+ endClientCall() {
+ if (this.activeConnection) {
+ this.activeConnection.disconnect();
+ }
+ this.activeConnection = null;
+ if (this.device) {
+ this.device.disconnectAll();
+ }
+ }
+
+ destroyDevice() {
+ if (this.device) {
+ this.device.destroy();
+ }
+ this.activeConnection = null;
+ this.device = null;
+ this.initialized = false;
+ this.inboxId = null;
+ }
+
+ async joinClientCall({ to, conversationId }) {
+ if (!this.device || !this.initialized || !to) return null;
+ if (this.activeConnection) return this.activeConnection;
+
+ const params = {
+ To: to,
+ is_agent: 'true',
+ conversation_id: conversationId,
+ };
+
+ const connection = await this.device.connect({ params });
+ this.activeConnection = connection;
+
+ connection.on('disconnect', this.onDisconnect);
+
+ return connection;
+ }
+
+ onDisconnect = () => {
+ this.activeConnection = null;
+ this.dispatchEvent(createCallDisconnectedEvent());
+ };
+}
+
+export default new TwilioVoiceClient();
diff --git a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js
new file mode 100644
index 000000000..6e1e548c8
--- /dev/null
+++ b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js
@@ -0,0 +1,40 @@
+/* global axios */
+import ApiClient from '../../ApiClient';
+import ContactsAPI from '../../contacts';
+
+class VoiceAPI extends ApiClient {
+ constructor() {
+ super('voice', { accountScoped: true });
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ initiateCall(contactId, inboxId) {
+ return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
+ }
+
+ leaveConference(inboxId, conversationId) {
+ return axios
+ .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
+ params: { conversation_id: conversationId },
+ })
+ .then(r => r.data);
+ }
+
+ joinConference({ conversationId, inboxId, callSid }) {
+ return axios
+ .post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
+ conversation_id: conversationId,
+ call_sid: callSid,
+ })
+ .then(r => r.data);
+ }
+
+ getToken(inboxId) {
+ if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
+ return axios
+ .get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
+ .then(r => r.data);
+ }
+}
+
+export default new VoiceAPI();
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index 361b9472f..83ba3e9ba 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
+
+ createCSATTemplate(inboxId, template) {
+ return axios.post(`${this.url}/${inboxId}/csat_template`, {
+ template,
+ });
+ }
+
+ getCSATTemplateStatus(inboxId) {
+ return axios.get(`${this.url}/${inboxId}/csat_template`);
+ }
}
export default new Inboxes();
diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js
index d533c2f28..10214f3f5 100644
--- a/app/javascript/dashboard/api/search.js
+++ b/app/javascript/dashboard/api/search.js
@@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
});
}
- contacts({ q, page = 1 }) {
+ contacts({ q, page = 1, since, until }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
+ since,
+ until,
},
});
}
- conversations({ q, page = 1 }) {
+ conversations({ q, page = 1, since, until }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
+ since,
+ until,
},
});
}
- messages({ q, page = 1 }) {
+ messages({ q, page = 1, since, until, from, inboxId }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
+ since,
+ until,
+ from,
+ inbox_id: inboxId,
},
});
}
- articles({ q, page = 1 }) {
+ articles({ q, page = 1, since, until }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
+ since,
+ until,
},
});
}
diff --git a/app/javascript/dashboard/api/specs/search.spec.js b/app/javascript/dashboard/api/specs/search.spec.js
new file mode 100644
index 000000000..251ea760e
--- /dev/null
+++ b/app/javascript/dashboard/api/specs/search.spec.js
@@ -0,0 +1,134 @@
+import searchAPI from '../search';
+import ApiClient from '../ApiClient';
+
+describe('#SearchAPI', () => {
+ it('creates correct instance', () => {
+ expect(searchAPI).toBeInstanceOf(ApiClient);
+ expect(searchAPI).toHaveProperty('get');
+ expect(searchAPI).toHaveProperty('contacts');
+ expect(searchAPI).toHaveProperty('conversations');
+ expect(searchAPI).toHaveProperty('messages');
+ expect(searchAPI).toHaveProperty('articles');
+ });
+
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const axiosMock = {
+ get: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ vi.clearAllMocks();
+ });
+
+ it('#get', () => {
+ searchAPI.get({ q: 'test query' });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
+ params: { q: 'test query' },
+ });
+ });
+
+ it('#contacts', () => {
+ searchAPI.contacts({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
+ params: { q: 'test', page: 1, since: undefined, until: undefined },
+ });
+ });
+
+ it('#contacts with date filters', () => {
+ searchAPI.contacts({
+ q: 'test',
+ page: 2,
+ since: 1700000000,
+ until: 1732000000,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
+ params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
+ });
+ });
+
+ it('#conversations', () => {
+ searchAPI.conversations({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/search/conversations',
+ {
+ params: { q: 'test', page: 1, since: undefined, until: undefined },
+ }
+ );
+ });
+
+ it('#conversations with date filters', () => {
+ searchAPI.conversations({
+ q: 'test',
+ page: 1,
+ since: 1700000000,
+ until: 1732000000,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/search/conversations',
+ {
+ params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
+ }
+ );
+ });
+
+ it('#messages', () => {
+ searchAPI.messages({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
+ params: {
+ q: 'test',
+ page: 1,
+ since: undefined,
+ until: undefined,
+ from: undefined,
+ inbox_id: undefined,
+ },
+ });
+ });
+
+ it('#messages with all filters', () => {
+ searchAPI.messages({
+ q: 'test',
+ page: 1,
+ since: 1700000000,
+ until: 1732000000,
+ from: 'contact:42',
+ inboxId: 10,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
+ params: {
+ q: 'test',
+ page: 1,
+ since: 1700000000,
+ until: 1732000000,
+ from: 'contact:42',
+ inbox_id: 10,
+ },
+ });
+ });
+
+ it('#articles', () => {
+ searchAPI.articles({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
+ params: { q: 'test', page: 1, since: undefined, until: undefined },
+ });
+ });
+
+ it('#articles with date filters', () => {
+ searchAPI.articles({
+ q: 'test',
+ page: 2,
+ since: 1700000000,
+ until: 1732000000,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
+ params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/api/specs/tiktokClient.spec.js b/app/javascript/dashboard/api/specs/tiktokClient.spec.js
new file mode 100644
index 000000000..5250e2c7b
--- /dev/null
+++ b/app/javascript/dashboard/api/specs/tiktokClient.spec.js
@@ -0,0 +1,35 @@
+import ApiClient from '../ApiClient';
+import tiktokClient from '../channel/tiktokClient';
+
+describe('#TiktokClient', () => {
+ it('creates correct instance', () => {
+ expect(tiktokClient).toBeInstanceOf(ApiClient);
+ expect(tiktokClient).toHaveProperty('generateAuthorization');
+ });
+
+ describe('#generateAuthorization', () => {
+ const originalAxios = window.axios;
+ const originalPathname = window.location.pathname;
+ const axiosMock = {
+ post: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ window.history.pushState({}, '', '/app/accounts/1/settings');
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ window.history.pushState({}, '', originalPathname);
+ });
+
+ it('posts to the authorization endpoint', () => {
+ tiktokClient.generateAuthorization({ state: 'test-state' });
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/tiktok/authorization',
+ { state: 'test-state' }
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/api/yearInReview.js b/app/javascript/dashboard/api/yearInReview.js
new file mode 100644
index 000000000..fb0661804
--- /dev/null
+++ b/app/javascript/dashboard/api/yearInReview.js
@@ -0,0 +1,16 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class YearInReviewAPI extends ApiClient {
+ constructor() {
+ super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
+ }
+
+ get(year) {
+ return axios.get(`${this.url}`, {
+ params: { year },
+ });
+ }
+}
+
+export default new YearInReviewAPI();
diff --git a/app/javascript/dashboard/components-next/CardLayout.vue b/app/javascript/dashboard/components-next/CardLayout.vue
index 462402167..166f5ea4c 100644
--- a/app/javascript/dashboard/components-next/CardLayout.vue
+++ b/app/javascript/dashboard/components-next/CardLayout.vue
@@ -19,7 +19,7 @@ const handleClick = () => {
+ {{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
+
+
+
+
+
+
+
+
+
+
+ {{ year }}
+
+
+ {{ t('YEAR_IN_REVIEW.TITLE') }}
+
+
+
+
+
+
+ {{ $t('SEARCH.RECENT_SEARCHES') }}
+
+
- {{ title }}
-
-
+
+ {{ title }}
+
+
+
+ {{ category }}
+
+
+ {{ status }}
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue
index 30c6f0557..a8ce1220b 100644
--- a/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue
@@ -1,8 +1,12 @@
-
- {{ name }}
-
-
+ {{ name }}
+
+
+ {{ $t('SEARCH.UPDATED_AT', { time: updatedAtTime }) }}
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue
index 26a11eba3..e0047a65e 100644
--- a/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue
@@ -1,10 +1,12 @@
-
+
-
- {{ $t(item.label) }}:
-
- {{ item.value }}
-
+
+
+ {{ $t(item.label) + ':' }}
+
+ {{ item.value }}
+
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultMessageItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultMessageItem.vue
new file mode 100644
index 000000000..b9272c928
--- /dev/null
+++ b/app/javascript/dashboard/modules/search/components/SearchResultMessageItem.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultSection.vue b/app/javascript/dashboard/modules/search/components/SearchResultSection.vue
index 13ba1ce87..40d1ca6c6 100644
--- a/app/javascript/dashboard/modules/search/components/SearchResultSection.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchResultSection.vue
@@ -1,6 +1,8 @@
- {{ title }}
+ {{ title }}
- {{ cannedItem.content }}
+ {{ getPlainText(cannedItem.content) }}