diff --git a/.env.example b/.env.example
index bc7380a29..69b1b9cde 100644
--- a/.env.example
+++ b/.env.example
@@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
+# Maximum time in seconds to process a single IMAP email
+# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
@@ -232,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
+## SafeFetch private network access
+## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
+# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
+
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
diff --git a/Gemfile b/Gemfile
index e10984f53..680a0738b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
-gem 'jwt'
+gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'pundit'
# super admin
diff --git a/Gemfile.lock b/Gemfile.lock
index 4da0e5847..7151d0ff1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -301,7 +301,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
- faraday (2.14.1)
+ faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -491,7 +491,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
- jwt (2.10.1)
+ jwt (2.10.3)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -996,11 +996,13 @@ GEM
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
- vite_rails (3.0.17)
- railties (>= 5.1, < 8)
+ vite_rails (3.10.0)
+ railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
- vite_ruby (3.8.0)
+ vite_ruby (3.10.2)
dry-cli (>= 0.7, < 2)
+ logger (~> 1.6)
+ mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
@@ -1102,7 +1104,7 @@ DEPENDENCIES
json_schemer
judoscale-rails
judoscale-sidekiq
- jwt
+ jwt (~> 2.10, >= 2.10.3)
kaminari
koala
letter_opener
diff --git a/VERSION_CW b/VERSION_CW
index c412a4e2e..d2b9909a9 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.14.0
+4.14.1
diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb
index 1f59deadb..24b6d9e70 100644
--- a/app/builders/messages/facebook/message_builder.rb
+++ b/app/builders/messages/facebook/message_builder.rb
@@ -92,10 +92,18 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
- external_url: attachment['url']
+ external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
+ # Facebook shared posts point to page URLs, not downloadable media URLs.
+ # 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
+
+ super
+ end
+
def conversation_params
{
account_id: @inbox.account_id,
diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb
index 7df72e14a..297e77fcc 100644
--- a/app/builders/messages/message_builder.rb
+++ b/app/builders/messages/message_builder.rb
@@ -13,6 +13,7 @@ class Messages::MessageBuilder
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
+ @is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -56,16 +57,25 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
- attachment.file_type = if uploaded_attachment.is_a?(String)
- file_type_by_signed_id(
- uploaded_attachment
- )
- else
- file_type(uploaded_attachment&.content_type)
- end
+ attachment.file_type = attachment_file_type(uploaded_attachment)
+ tag_voice_message(attachment)
end
end
+ def attachment_file_type(uploaded_attachment)
+ if uploaded_attachment.is_a?(String)
+ file_type_by_signed_id(uploaded_attachment)
+ else
+ file_type(uploaded_attachment&.content_type)
+ end
+ end
+
+ def tag_voice_message(attachment)
+ return unless @is_voice_message && attachment.file_type == 'audio'
+
+ attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
+ end
+
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
index b45c16828..7ea9cab5d 100644
--- a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
+++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
- before_action :set_articles, only: [:update_status, :delete_articles]
+ before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
def translate
head :not_implemented
@@ -19,6 +19,18 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
render_could_not_create_error(e.message)
end
+ def update_category
+ return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
+ return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
+
+ ActiveRecord::Base.transaction do
+ @articles.find_each { |article| article.update!(category_id: params[:category_id]) }
+ end
+ head :ok
+ rescue ActiveRecord::RecordInvalid => e
+ render_could_not_create_error(e.message)
+ end
+
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@@ -39,5 +51,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
+
+ def category_valid?
+ @portal.categories.exists?(id: params[:category_id])
+ end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb
index 053c29731..116a31e85 100644
--- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
- state: generate_instagram_token(Current.account.id)
+ state: generate_instagram_token(Current.account.id, params[:return_to])
}
)
if redirect_url
diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb
index feb218b59..7fbca86da 100644
--- a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb
+++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb
@@ -8,7 +8,15 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end
def state
- Current.account.to_sgid(expires_in: 15.minutes).to_s
+ # The sgid purpose doubles as a return hint: onboarding tags it so the callback
+ # can route the user back to inbox setup. The purpose is part of the signed
+ # payload (tamper-proof), and a non-onboarding request keeps the default
+ # purpose, leaving callers like Notion byte-identical.
+ Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
+ end
+
+ def state_purpose
+ params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
end
def base_url
diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb
new file mode 100644
index 000000000..77c6245ea
--- /dev/null
+++ b/app/controllers/api/v1/accounts/onboardings_controller.rb
@@ -0,0 +1,36 @@
+class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
+ before_action :check_admin_authorization?
+
+ def update
+ @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?
+
+ render 'api/v1/accounts/update', format: :json
+ end
+
+ private
+
+ def finalizing_account_details?
+ @account.custom_attributes['onboarding_step'] == 'account_details'
+ end
+
+ def website
+ custom_attributes_params[:website]
+ end
+
+ def account_params
+ params.permit(:name, :locale)
+ end
+
+ def custom_attributes_params
+ params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
+ end
+end
diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb
index 7c7320393..64bf38775 100644
--- a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
def create
redirect_url = Tiktok::AuthClient.authorize_url(
- state: generate_tiktok_token(Current.account.id)
+ state: generate_tiktok_token(Current.account.id, params[:return_to])
)
if redirect_url
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 865b387b9..fb991949a 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
- @account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb
index 4dc8ece1c..cd317363c 100644
--- a/app/controllers/instagram/callbacks_controller.rb
+++ b/app/controllers/instagram/callbacks_controller.rb
@@ -28,6 +28,8 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
+ return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
+
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -149,6 +151,10 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state])
end
+ def return_to
+ instagram_token_return_to(params[:state])
+ end
+
def oauth_code
params[:code]
end
diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb
index 2f07505fc..045789f75 100644
--- a/app/controllers/microsoft/callbacks_controller.rb
+++ b/app/controllers/microsoft/callbacks_controller.rb
@@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address
'outlook.office365.com'
end
+
+ # Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
+ # it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
+ # `upn` is the v1.0 fallback.
+ def imap_login_identity
+ users_data['preferred_username'] || users_data['upn'] || super
+ end
end
diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb
index be0fa5008..4a3d049a6 100644
--- a/app/controllers/oauth_callback_controller.rb
+++ b/app/controllers/oauth_callback_controller.rb
@@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController
def handle_response
inbox, already_exists = find_or_create_inbox
+ return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
+
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
@@ -44,7 +46,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email)
channel_email.update!({
- imap_login: users_data['email'], imap_address: imap_address,
+ imap_login: imap_login_identity, imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
@@ -55,6 +57,13 @@ class OauthCallbackController < ApplicationController
})
end
+ # Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
+ # id_token's email claim; providers override when their server requires a different
+ # claim (e.g. Microsoft SMTP requires UPN).
+ def imap_login_identity
+ users_data['email']
+ end
+
def provider_name
raise NotImplementedError
end
@@ -81,10 +90,19 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
+ # The sgid purpose carries the onboarding return hint (see
+ # OauthAuthorizationController#state). Try the onboarding purpose first — a match
+ # both resolves the account and records the return target — then fall back to the
+ # default purpose used by every other caller.
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
- account = GlobalID::Locator.locate_signed(params[:state])
+ if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
+ @return_to = 'onboarding'
+ else
+ account = GlobalID::Locator.locate_signed(params[:state])
+ end
+
raise 'Invalid or expired state' if account.nil?
account
@@ -94,6 +112,11 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id
end
+ def return_to
+ account # resolving the sgid records which purpose matched
+ @return_to
+ end
+
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
diff --git a/app/controllers/public/api/v1/portals/search_controller.rb b/app/controllers/public/api/v1/portals/search_controller.rb
new file mode 100644
index 000000000..104741773
--- /dev/null
+++ b/app/controllers/public/api/v1/portals/search_controller.rb
@@ -0,0 +1,31 @@
+class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
+ before_action :ensure_custom_domain_request, only: [:index]
+ before_action :portal
+ before_action :set_portal_layout
+ before_action :set_view_variant
+ before_action :ensure_portal_feature_enabled
+ layout 'portal'
+
+ def index
+ @query = params[:query].to_s.strip
+ @articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
+
+ search_articles
+
+ @articles = @articles.page(params[:page]).per(10)
+ end
+
+ private
+
+ def search_articles
+ @articles = @query.present? ? @articles.search(search_params) : @articles.none
+ end
+
+ def search_params
+ params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
+ permitted[:query] = @query
+ end
+ end
+end
+
+Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb
index e484905c3..20c0ee9c0 100644
--- a/app/controllers/tiktok/callbacks_controller.rb
+++ b/app/controllers/tiktok/callbacks_controller.rb
@@ -20,6 +20,8 @@ class Tiktok::CallbacksController < ApplicationController
def process_successful_authorization
inbox, already_exists = find_or_create_inbox
+ return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
+
if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -127,6 +129,10 @@ class Tiktok::CallbacksController < ApplicationController
@account_id ||= verify_tiktok_token(params[:state])
end
+ def return_to
+ tiktok_token_return_to(params[:state])
+ end
+
def account
@account ||= Account.find(account_id)
end
diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb
index 8ba57bf95..2f91eb6b0 100644
--- a/app/helpers/instagram/integration_helper.rb
+++ b/app/helpers/instagram/integration_helper.rb
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
+ # @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
- def generate_instagram_token(account_id)
+ def generate_instagram_token(account_id, return_to = nil)
return if client_secret.blank?
- JWT.encode(token_payload(account_id), client_secret, 'HS256')
+ JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
- def token_payload(account_id)
- {
- sub: account_id,
- iat: Time.current.to_i
- }
+ def token_payload(account_id, return_to = nil)
+ payload = { sub: account_id, iat: Time.current.to_i }
+ payload[:return_to] = return_to if return_to.present?
+ payload
end
# Verifies and decodes a Instagram JWT token
@@ -28,7 +28,14 @@ module Instagram::IntegrationHelper
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
- decode_token(token, client_secret)
+ decode_token(token, client_secret)&.dig('sub')
+ end
+
+ # Reads the onboarding return hint from a Instagram JWT token, if present.
+ def instagram_token_return_to(token)
+ return if token.blank? || client_secret.blank?
+
+ decode_token(token, client_secret)&.dig('return_to')
end
private
@@ -41,7 +48,7 @@ module Instagram::IntegrationHelper
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
- }).first['sub']
+ }).first
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb
index 65166145c..0c993ec59 100644
--- a/app/helpers/portal_helper.rb
+++ b/app/helpers/portal_helper.rb
@@ -45,9 +45,16 @@ module PortalHelper
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
+ def portal_query_string(theme, is_plain_layout_enabled)
+ query_params = {}
+ query_params[:theme] = theme if theme.present? && theme != 'system'
+ query_params[:show_plain_layout] = true if is_plain_layout_enabled
+ query_params.present? ? "?#{query_params.to_query}" : ''
+ end
+
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
- "/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
+ "/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}"
else
"/hc/#{portal_slug}/#{portal_locale}"
end
@@ -61,7 +68,7 @@ module PortalHelper
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
- "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
+ "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
else
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end
@@ -69,7 +76,7 @@ module PortalHelper
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
- "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
+ "/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
else
"/hc/#{portal_slug}/articles/#{article_slug}"
end
diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb
index b2de4a092..7bc8bc4ab 100644
--- a/app/helpers/tiktok/integration_helper.rb
+++ b/app/helpers/tiktok/integration_helper.rb
@@ -2,11 +2,12 @@ module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration
#
# @param account_id [Integer] The account ID to encode in the token
+ # @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
- def generate_tiktok_token(account_id)
+ def generate_tiktok_token(account_id, return_to = nil)
return if client_secret.blank?
- JWT.encode(token_payload(account_id), client_secret, 'HS256')
+ JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil
@@ -19,7 +20,14 @@ module Tiktok::IntegrationHelper
def verify_tiktok_token(token)
return if token.blank? || client_secret.blank?
- decode_token(token, client_secret)
+ decode_token(token, client_secret)&.dig('sub')
+ end
+
+ # Reads the onboarding return hint from a Tiktok JWT token, if present.
+ def tiktok_token_return_to(token)
+ return if token.blank? || client_secret.blank?
+
+ decode_token(token, client_secret)&.dig('return_to')
end
private
@@ -28,18 +36,17 @@ module Tiktok::IntegrationHelper
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end
- def token_payload(account_id)
- {
- sub: account_id,
- iat: Time.current.to_i
- }
+ def token_payload(account_id, return_to = nil)
+ payload = { sub: account_id, iat: Time.current.to_i }
+ payload[:return_to] = return_to if return_to.present?
+ payload
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
- }).first['sub']
+ }).first
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil
diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
new file mode 100644
index 000000000..ec24aae34
--- /dev/null
+++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
@@ -0,0 +1,45 @@
+/* global axios */
+import ApiClient from '../../ApiClient';
+
+class WhatsappCallsAPI extends ApiClient {
+ constructor() {
+ super('whatsapp_calls', { accountScoped: true });
+ }
+
+ show(callId) {
+ return axios.get(`${this.url}/${callId}`).then(r => r.data);
+ }
+
+ initiate(conversationId, sdpOffer) {
+ return axios
+ .post(`${this.url}/initiate`, {
+ conversation_id: conversationId,
+ sdp_offer: sdpOffer,
+ })
+ .then(r => r.data);
+ }
+
+ accept(callId, sdpAnswer) {
+ return axios
+ .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
+ .then(r => r.data);
+ }
+
+ reject(callId) {
+ return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
+ }
+
+ terminate(callId) {
+ return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
+ }
+
+ uploadRecording(callId, blob, filename = 'call-recording.webm') {
+ const formData = new FormData();
+ formData.append('recording', blob, filename);
+ return axios
+ .post(`${this.url}/${callId}/upload_recording`, formData)
+ .then(r => r.data);
+ }
+}
+
+export default new WhatsappCallsAPI();
diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js
index bae5623a7..c39a4cf9d 100644
--- a/app/javascript/dashboard/api/contacts.js
+++ b/app/javascript/dashboard/api/contacts.js
@@ -35,8 +35,9 @@ class ContactAPI extends ApiClient {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
- getConversations(contactId) {
- return axios.get(`${this.url}/${contactId}/conversations`);
+ getConversations(contactId, { inboxId } = {}) {
+ const params = inboxId ? { inbox_id: inboxId } : {};
+ return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getContactableInboxes(contactId) {
@@ -47,9 +48,10 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
- initiateCall(contactId, inboxId) {
+ initiateCall(contactId, inboxId, conversationId = null) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
+ conversation_id: conversationId,
});
}
diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js
index c79aa5da7..bab45bcb5 100644
--- a/app/javascript/dashboard/api/helpCenter/articles.js
+++ b/app/javascript/dashboard/api/helpCenter/articles.js
@@ -87,6 +87,13 @@ class ArticlesAPI extends PortalsAPI {
);
}
+ bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
+ return axios.patch(
+ `${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
+ { ids: articleIds, category_id: categoryId }
+ );
+ }
+
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js
index 8f294a0ee..06b85078e 100644
--- a/app/javascript/dashboard/api/inbox/message.js
+++ b/app/javascript/dashboard/api/inbox/message.js
@@ -12,6 +12,7 @@ export const buildCreatePayload = ({
bccEmails = '',
toEmails = '',
templateParams,
+ isVoiceMessage = false,
}) => {
let payload;
if (files && files.length !== 0) {
@@ -33,6 +34,9 @@ export const buildCreatePayload = ({
if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes));
}
+ if (isVoiceMessage) {
+ payload.append('is_voice_message', true);
+ }
} else {
payload = {
content: message,
@@ -64,6 +68,7 @@ class MessageApi extends ApiClient {
bccEmails = '',
toEmails = '',
templateParams,
+ isVoiceMessage = false,
}) {
return axios({
method: 'post',
@@ -78,6 +83,7 @@ class MessageApi extends ApiClient {
bccEmails,
toEmails,
templateParams,
+ isVoiceMessage,
}),
});
}
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index cc564fe96..114dbb6f4 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient {
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
+
+ enableWhatsappCalling(inboxId) {
+ return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
+ }
+
+ disableWhatsappCalling(inboxId) {
+ return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
+ }
}
export default new Inboxes();
diff --git a/app/javascript/dashboard/api/onboarding.js b/app/javascript/dashboard/api/onboarding.js
new file mode 100644
index 000000000..2bc098eef
--- /dev/null
+++ b/app/javascript/dashboard/api/onboarding.js
@@ -0,0 +1,14 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class OnboardingAPI extends ApiClient {
+ constructor() {
+ super('onboarding', { accountScoped: true });
+ }
+
+ update(data) {
+ return axios.patch(this.url, data);
+ }
+}
+
+export default new OnboardingAPI();
diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js
index 71128682c..b40613739 100644
--- a/app/javascript/dashboard/api/specs/article.spec.js
+++ b/app/javascript/dashboard/api/specs/article.spec.js
@@ -153,4 +153,33 @@ describe('#PortalAPI', () => {
);
});
});
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const axiosMock = {
+ post: vi.fn(() => Promise.resolve()),
+ get: vi.fn(() => Promise.resolve()),
+ patch: vi.fn(() => Promise.resolve()),
+ delete: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ });
+
+ it('#bulkUpdateCategory', () => {
+ articlesAPI.bulkUpdateCategory({
+ portalSlug: 'room-rental',
+ articleIds: [1, 2, 3],
+ categoryId: 7,
+ });
+ expect(axiosMock.patch).toHaveBeenCalledWith(
+ '/api/v1/portals/room-rental/articles/bulk_actions/update_category',
+ { ids: [1, 2, 3], category_id: 7 }
+ );
+ });
+ });
});
diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js
index b21aeb102..f55ecdfaa 100644
--- a/app/javascript/dashboard/api/specs/contacts.spec.js
+++ b/app/javascript/dashboard/api/specs/contacts.spec.js
@@ -41,7 +41,8 @@ describe('#ContactsAPI', () => {
it('#getConversations', () => {
contactAPI.getConversations(1);
expect(axiosMock.get).toHaveBeenCalledWith(
- '/api/v1/contacts/1/conversations'
+ '/api/v1/contacts/1/conversations',
+ { params: {} }
);
});
diff --git a/app/javascript/dashboard/api/specs/inbox/message.spec.js b/app/javascript/dashboard/api/specs/inbox/message.spec.js
index 941f5c99c..84c0b9cf2 100644
--- a/app/javascript/dashboard/api/specs/inbox/message.spec.js
+++ b/app/javascript/dashboard/api/specs/inbox/message.spec.js
@@ -83,5 +83,29 @@ describe('#ConversationAPI', () => {
template_params: undefined,
});
});
+
+ it('appends is_voice_message when isVoiceMessage is true', () => {
+ const formPayload = buildCreatePayload({
+ message: 'voice message',
+ echoId: 42,
+ isPrivate: false,
+ files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
+ isVoiceMessage: true,
+ });
+ expect(formPayload).toBeInstanceOf(FormData);
+ expect(formPayload.get('is_voice_message')).toEqual('true');
+ });
+
+ it('does not append is_voice_message when isVoiceMessage is false', () => {
+ const formPayload = buildCreatePayload({
+ message: 'regular audio',
+ echoId: 43,
+ isPrivate: false,
+ files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
+ isVoiceMessage: false,
+ });
+ expect(formPayload).toBeInstanceOf(FormData);
+ expect(formPayload.get('is_voice_message')).toBeNull();
+ });
});
});
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
index 05507fc89..437933e5f 100644
--- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
@@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
+const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter();
@@ -68,9 +69,15 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
- return props.status === STATUS_COMPLETED
- ? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
- : t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
+ if (props.status === STATUS_COMPLETED) {
+ return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
+ }
+
+ if (props.status === STATUS_PROCESSING) {
+ return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
+ }
+
+ return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue
index d5932535c..9deaa84f2 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue
@@ -1,34 +1,48 @@
+
+
+
+ {{ duration }}
+
+ {{ callInfo.contactName }}
+
+ {{ callInfo.phoneNumber }}
+