<%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.
-<% end %>
+ if @resource.unconfirmed_email.present?
+ eyebrow = 'Email update'
+ heading = 'Confirm your new email address'
+ intro_text = "We received a request to update the email address on your #{brand_name} account."
+ supporting_text = 'Confirm the new address below to finish the change.'
+ action_text = 'Confirm email address'
+ elsif @resource.confirmed?
+ eyebrow = 'Account ready'
+ heading = 'Your account is ready'
+ intro_text = "Your #{brand_name} account is already active."
+ supporting_text = 'Use the button below to sign in and continue where you left off.'
+ action_text = 'Open my account'
+ action_url = frontend_url('auth/sign_in')
+ detail_rows = []
+ elsif invited_user
+ eyebrow = 'Workspace invitation'
+ heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
+ intro_text = if account_name.present?
+ "#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
+ else
+ "#{inviter.name} invited you to try #{brand_name}."
+ end
+ supporting_text = 'Create your account to start collaborating with your team.'
+ action_text = 'Accept invitation'
+ action_url = frontend_url(
+ 'auth/password/edit',
+ reset_password_token: @resource.send(:set_reset_password_token)
+ )
+ detail_rows = [['Invited by', inviter.name]]
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ end
+%>
-<% if @resource.confirmed? %>
- You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:
-<% else %>
- <% if account_user&.inviter.blank? %>
-
- Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
-
- <% end %>
- Please take a moment and click the link below and activate your account.
-<% end %>
-
-
-<% if @resource.unconfirmed_email.present? %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% elsif @resource.confirmed? %>
- <%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %>
-<% else %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% end %>
\ No newline at end of file
+<%= render partial: 'devise/mailer/confirmation_body', locals: {
+ action_text: action_text,
+ action_url: action_url,
+ detail_rows: detail_rows,
+ eyebrow: eyebrow,
+ heading: heading,
+ info_text: info_text,
+ info_title: info_title,
+ intro_text: intro_text,
+ recipient_name: recipient_name,
+ supporting_text: supporting_text
+} %>
diff --git a/app/views/layouts/mailer/base.liquid b/app/views/layouts/mailer/base.liquid
index 5fa07e139..e6c70b3e5 100644
--- a/app/views/layouts/mailer/base.liquid
+++ b/app/views/layouts/mailer/base.liquid
@@ -7,86 +7,129 @@
-
-
+ {% assign brand_name = global_config['BRAND_NAME'] %}
+ {% if brand_name == nil %}
+ {% assign brand_name = 'Chatwoot' %}
+ {% endif %}
+ {% assign brand_url = global_config['BRAND_URL'] %}
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ content_for_layout }}
+
+
+
+
+
+
+ {% if brand_name != '' %}
-
-
- {{ content_for_layout }}
-
+
-
-
-
+ {% endif %}
+
diff --git a/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb b/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb
index 91837f980..5032db646 100644
--- a/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb
+++ b/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -1,45 +1,99 @@
-Hi <%= @resource.name %>,
+<%
+ brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
+ recipient_name = @resource.name.presence || @resource.email
+ account_user = @resource&.account_users&.first
+ inviter = account_user&.inviter
+ account_name = account_user&.account&.name
+ is_saml_account = account_user&.account&.saml_enabled?
+ invited_user = inviter.present? && @resource.unconfirmed_email.blank?
-<% account_user = @resource&.account_users&.first %>
-<% is_saml_account = account_user&.account&.saml_enabled? %>
+ eyebrow = 'Welcome'
+ heading = 'Confirm your email to get started'
+ intro_text =
+ "Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
+ supporting_text = 'This only takes a moment.'
+ action_text = 'Confirm my account'
+ action_url = frontend_url('auth/confirmation', confirmation_token: @token)
+ info_title = nil
+ info_text = nil
+ detail_rows = []
+ detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
-<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
- <% if is_saml_account %>
- <%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).
- Your organization uses SSO for secure authentication. You will not need a password to access your account.
- <% else %>
- <%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.
- <% end %>
-<% end %>
+ if @resource.unconfirmed_email.present?
+ eyebrow = 'Email update'
+ heading = 'Confirm your new email address'
+ intro_text = "We received a request to update the email address on your #{brand_name} account."
+ supporting_text = 'Confirm the new address below to finish the change.'
+ action_text = 'Confirm email address'
+ elsif @resource.confirmed?
+ eyebrow = 'Account ready'
-<% if @resource.confirmed? %>
- You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:
-<% else %>
- <% if account_user&.inviter.blank? %>
-
- Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
-
- <% end %>
- <% unless is_saml_account %>
- Please take a moment and click the link below and activate your account.
- <% end %>
-<% end %>
+ if is_saml_account
+ heading = 'Your access is ready'
+ intro_text = "Your #{brand_name} access is already set up."
+ supporting_text = "Use your organization's Single Sign-On (SSO) portal to access #{brand_name}."
+ action_text = nil
+ action_url = nil
+ info_title = "Sign in with your organization's SSO"
+ info_text =
+ "You won't need a separate password for #{brand_name}. Start from your company identity provider portal."
+ detail_rows = []
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
+ else
+ heading = 'Your account is ready'
+ intro_text = "Your #{brand_name} account is already active."
+ supporting_text = 'Use the button below to sign in and continue where you left off.'
+ action_text = 'Open my account'
+ action_url = frontend_url('auth/sign_in')
+ detail_rows = []
+ end
+ elsif invited_user
+ eyebrow = 'Workspace invitation'
+ heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
+ if is_saml_account
+ intro_text = if account_name.present?
+ "#{inviter.name} invited you to access the #{account_name} workspace on #{brand_name}."
+ else
+ "#{inviter.name} invited you to access #{brand_name}."
+ end
+ supporting_text =
+ "Your organization uses Single Sign-On (SSO), so you won't need to create a separate password."
+ action_text = nil
+ action_url = nil
+ info_title = "Use your organization's SSO portal"
+ info_text = "Continue from your company identity provider portal to access #{brand_name}."
+ detail_rows = [['Invited by', inviter.name]]
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
+ else
+ intro_text = if account_name.present?
+ "#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
+ else
+ "#{inviter.name} invited you to try #{brand_name}."
+ end
+ supporting_text = 'Create your account to start collaborating with your team.'
+ action_text = 'Accept invitation'
+ action_url = frontend_url(
+ 'auth/password/edit',
+ reset_password_token: @resource.send(:set_reset_password_token)
+ )
+ detail_rows = [['Invited by', inviter.name]]
+ detail_rows << ['Workspace', account_name] if account_name.present?
+ end
+ end
+%>
-<% if @resource.unconfirmed_email.present? %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% elsif @resource.confirmed? %>
- <% if is_saml_account %>
- You can now access your account by logging in through your organization's SSO portal.
- <% else %>
- <%= link_to 'Login to my account', frontend_url('auth/sign_in') %>
- <% end %>
-<% elsif account_user&.inviter.present? %>
- <% if is_saml_account %>
- You can access your account by logging in through your organization's SSO portal.
- <% else %>
- <%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %>
- <% end %>
-<% else %>
- <%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% end %>
+<%= render partial: 'devise/mailer/confirmation_body', locals: {
+ action_text: action_text,
+ action_url: action_url,
+ detail_rows: detail_rows,
+ eyebrow: eyebrow,
+ heading: heading,
+ info_text: info_text,
+ info_title: info_title,
+ intro_text: intro_text,
+ recipient_name: recipient_name,
+ supporting_text: supporting_text
+} %>
diff --git a/spec/enterprise/mailers/devise_mailer_spec.rb b/spec/enterprise/mailers/devise_mailer_spec.rb
index 286e863f7..61ce92047 100644
--- a/spec/enterprise/mailers/devise_mailer_spec.rb
+++ b/spec/enterprise/mailers/devise_mailer_spec.rb
@@ -8,12 +8,23 @@ RSpec.describe 'Devise::Mailer' do
let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
let(:inviter_val) { nil }
let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
+ let(:mail_body) { CGI.unescapeHTML(mail.body.to_s) }
before do
confirmable_user.update!(confirmed_at: nil)
confirmable_user.send(:generate_confirmation_token)
end
+ context 'when brand name is intentionally blank' do
+ before do
+ create(:installation_config, name: 'BRAND_NAME', value: '')
+ end
+
+ it 'preserves the blank brand override' do
+ expect(mail_body).not_to include('Chatwoot')
+ end
+ end
+
context 'with SAML enabled account' do
let(:saml_settings) { create(:account_saml_settings, account: account) }
@@ -21,12 +32,13 @@ RSpec.describe 'Devise::Mailer' do
context 'when user has no inviter' do
it 'shows standard welcome message without SSO references' do
- expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
- expect(mail.body).not_to match('via Single Sign-On')
+ expect(mail_body).to include('Confirm your email to get started')
+ expect(mail_body).to include('We just need to verify your email address before you can start using your account.')
+ expect(mail_body).not_to include('Single Sign-On (SSO)')
end
- it 'does not show activation instructions for SAML accounts' do
- expect(mail.body).not_to match('Please take a moment and click the link below and activate your account')
+ it 'shows the standard confirmation CTA' do
+ expect(mail_body).to include('Confirm my account')
end
it 'shows confirmation link' do
@@ -38,22 +50,21 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'mentions SSO invitation' do
- expect(mail.body).to match(
- "#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to access.*via Single Sign-On \\(SSO\\)"
- )
+ expect(mail_body).to include("You're invited to join #{account.name}")
+ expect(mail_body).to include("#{inviter_val.name} invited you to access the #{account.name} workspace on Chatwoot.")
end
it 'explains SSO authentication' do
- expect(mail.body).to match('Your organization uses SSO for secure authentication')
- expect(mail.body).to match('You will not need a password to access your account')
+ expect(mail_body).to include("Your organization uses Single Sign-On (SSO), so you won't need to create a separate password.")
end
it 'does not show standard invitation message' do
- expect(mail.body).not_to match('has invited you to try out')
+ expect(mail_body).not_to include('invited you to join')
+ expect(mail_body).not_to include('Accept invitation')
end
it 'directs to SSO portal instead of password reset' do
- expect(mail.body).to match('You can access your account by logging in through your organization\'s SSO portal')
+ expect(mail_body).to include("Use your organization's SSO portal")
expect(mail.body).not_to include('app/auth/password/edit')
end
end
@@ -66,7 +77,9 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows SSO login instructions' do
- expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
+ expect(mail_body).to include('Your access is ready')
+ expect(mail_body).to include("Sign in with your organization's SSO")
+ expect(mail_body).to include("Use your organization's Single Sign-On (SSO) portal to access")
expect(mail.body).not_to include('/auth/sign_in')
end
end
@@ -79,6 +92,7 @@ RSpec.describe 'Devise::Mailer' do
end
it 'still shows confirmation link for email verification' do
+ expect(mail_body).to include('Confirm your new email address')
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
@@ -90,7 +104,8 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows SSO login instructions instead of regular login' do
- expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
+ expect(mail_body).to include('Your access is ready')
+ expect(mail_body).to include("Sign in with your organization's SSO")
expect(mail.body).not_to include('/auth/sign_in')
end
end
@@ -101,9 +116,10 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'shows standard invitation without SSO references' do
- expect(mail.body).to match('has invited you to try out Chatwoot')
- expect(mail.body).not_to match('via Single Sign-On')
- expect(mail.body).not_to match('SSO portal')
+ expect(mail_body).to include("You're invited to join #{account.name}")
+ expect(mail_body).to include("#{inviter_val.name} invited you to join the #{account.name} workspace on")
+ expect(mail_body).not_to include('Single Sign-On (SSO)')
+ expect(mail_body).not_to include("Use your organization's SSO portal")
end
it 'shows password reset link' do
@@ -112,9 +128,10 @@ RSpec.describe 'Devise::Mailer' do
end
context 'when user has no inviter' do
- it 'shows standard welcome message and activation instructions' do
- expect(mail.body).to match('We have a suite of powerful tools ready for you to explore')
- expect(mail.body).to match('Please take a moment and click the link below and activate your account')
+ it 'shows the standard confirmation state' do
+ expect(mail_body).to include('Confirm your email to get started')
+ expect(mail_body).to include('We just need to verify your email address before you can start using your account.')
+ expect(mail_body).to include('Confirm my account')
end
it 'shows confirmation link' do
@@ -130,8 +147,9 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows regular login link' do
+ expect(mail_body).to include('Your account is ready')
expect(mail.body).to include('/auth/sign_in')
- expect(mail.body).not_to match('SSO portal')
+ expect(mail_body).not_to include('SSO portal')
end
end
@@ -141,6 +159,7 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows confirmation link for email verification' do
+ expect(mail_body).to include('Confirm your new email address')
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
diff --git a/spec/mailers/confirmation_instructions_spec.rb b/spec/mailers/confirmation_instructions_spec.rb
index 484001957..b82202c51 100644
--- a/spec/mailers/confirmation_instructions_spec.rb
+++ b/spec/mailers/confirmation_instructions_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe 'Devise::Mailer' do
let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
let(:inviter_val) { nil }
let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
+ let(:mail_body) { CGI.unescapeHTML(mail.body.to_s) }
before do
# to verify the token in email
@@ -22,12 +23,26 @@ RSpec.describe 'Devise::Mailer' do
end
it 'uses the user\'s name' do
- expect(mail.body).to match("Hi #{CGI.escapeHTML(confirmable_user.name)},")
+ expect(mail.body.to_s).to include("Hi #{CGI.escapeHTML(confirmable_user.name)},")
+ expect(mail_body).to include("Hi #{confirmable_user.name},")
end
- it 'does not refer to the inviter and their account' do
- expect(mail.body).not_to match('has invited you to try out Chatwoot!')
- expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
+ context 'when the user name contains HTML' do
+ before do
+ confirmable_user.update!(name: 'Sony ')
+ end
+
+ it 'escapes the name in the rendered email body' do
+ expect(mail.body.to_s).to include("Hi #{CGI.escapeHTML(confirmable_user.name)},")
+ expect(mail.body.to_s).not_to include("Hi #{confirmable_user.name},")
+ end
+ end
+
+ it 'shows the default confirmation state' do
+ expect(mail_body).to include('Confirm your email to get started')
+ expect(mail_body).to include('Welcome to Chatwoot. We just need to verify your email address before you can start using your account.')
+ expect(mail_body).to include('Confirm my account')
+ expect(mail_body).not_to include('Workspace invitation')
end
it 'sends a confirmation link' do
@@ -39,10 +54,10 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'refers to the inviter and their account' do
- expect(mail.body).to match(
- "#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to try out Chatwoot."
- )
- expect(mail.body).not_to match('We have a suite of powerful tools ready for you to explore.')
+ expect(mail_body).to include("You're invited to join #{account.name}")
+ expect(mail_body).to include("#{inviter_val.name} invited you to join the #{account.name} workspace on Chatwoot.")
+ expect(mail_body).to include('Accept invitation')
+ expect(mail_body).not_to include('Confirm your email to get started')
end
it 'sends a password reset link' do
@@ -58,7 +73,10 @@ RSpec.describe 'Devise::Mailer' do
it 'sends a confirmation link' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
+ confirmation_body = CGI.unescapeHTML(confirmation_mail.body.to_s)
+ expect(confirmation_body).to include('Confirm your new email address')
+ expect(confirmation_body).to include('New email')
expect(confirmation_mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmation_mail.body).not_to include('app/auth/password/edit')
expect(confirmable_user.unconfirmed_email.blank?).to be false
@@ -73,7 +91,9 @@ RSpec.describe 'Devise::Mailer' do
it 'sends a confirmation link' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
+ confirmation_body = CGI.unescapeHTML(confirmation_mail.body.to_s)
+ expect(confirmation_body).to include('Confirm your new email address')
expect(confirmation_mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmation_mail.body).not_to include('app/auth/password/edit')
expect(confirmable_user.unconfirmed_email.blank?).to be false
@@ -88,6 +108,10 @@ RSpec.describe 'Devise::Mailer' do
it 'send instructions with the link to login' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
+ confirmation_body = CGI.unescapeHTML(confirmation_mail.body.to_s)
+
+ expect(confirmation_body).to include('Your account is ready')
+ expect(confirmation_body).to include('Open my account')
expect(confirmation_mail.body).to include('/auth/sign_in')
end
end
From 751c28d94d33c78b499cd6b0602b544dd0328839 Mon Sep 17 00:00:00 2001
From: Pranav
Date: Fri, 24 Apr 2026 08:51:26 -0700
Subject: [PATCH 19/24] feat(ee): Add article translation via LLM in help
center (#14136)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the ability to translate help center articles to other languages using Captain's LLM infrastructure. Translated articles are created as drafts linked to the source article.
Fixes
https://linear.app/chatwoot/issue/CW-6901/translate-article-to-another-language
**How to test**
1. Navigate to Help Center → Articles for a portal with multiple locales
2. Click the three-dot menu on any article → "Translate"
3. Select a target language and category → click Translate
4. Switch to the target locale — the translated article appears as a
draft
5. Try translating the same article again — a warning shows the existing
translation with a link to open it in a new tab
6. Click "Overwrite and translate" to replace the existing translation
https://github.com/user-attachments/assets/1d2e991b-f0ac-403a-bcc1-2181b5731ea4
---
.../articles/bulk_actions_controller.rb | 19 ++
.../dashboard/api/helpCenter/articles.js | 7 +
.../HelpCenter/ArticleCard/ArticleCard.vue | 22 +-
.../Pages/ArticlePage/ArticleList.vue | 6 +
.../Pages/ArticlePage/ArticlesPage.vue | 17 +-
.../Pages/ArticlePage/BulkTranslateDialog.vue | 249 ++++++++++++++++++
.../dashboard/helper/portalHelper.js | 13 +-
.../dashboard/i18n/locale/en/helpCenter.json | 18 ++
.../modules/helpCenterArticles/actions.js | 14 +
config/locales/en.yml | 5 +
config/routes.rb | 5 +
.../articles/bulk_actions_controller.rb | 68 +++++
.../jobs/captain/articles/translate_job.rb | 59 +++++
.../llm/article_translation_service.rb | 62 +++++
.../articles/bulk_actions_controller_spec.rb | 179 +++++++++++++
.../captain/articles/translate_job_spec.rb | 134 ++++++++++
.../llm/article_translation_service_spec.rb | 67 +++++
17 files changed, 939 insertions(+), 5 deletions(-)
create mode 100644 app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
create mode 100644 app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue
create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb
create mode 100644 enterprise/app/jobs/captain/articles/translate_job.rb
create mode 100644 enterprise/app/services/captain/llm/article_translation_service.rb
create mode 100644 spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb
create mode 100644 spec/enterprise/jobs/captain/articles/translate_job_spec.rb
create mode 100644 spec/enterprise/services/captain/llm/article_translation_service_spec.rb
diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
new file mode 100644
index 000000000..584e3dbf2
--- /dev/null
+++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
@@ -0,0 +1,19 @@
+class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
+ before_action :portal
+ before_action :check_authorization
+
+ def translate
+ head :not_implemented
+ end
+
+ private
+
+ def portal
+ @portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
+ end
+
+ def check_authorization
+ authorize(Article, :create?)
+ end
+end
+Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js
index 727340ed5..781570d0b 100644
--- a/app/javascript/dashboard/api/helpCenter/articles.js
+++ b/app/javascript/dashboard/api/helpCenter/articles.js
@@ -72,6 +72,13 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
+
+ bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
+ return axios.post(
+ `${this.url}/${portalSlug}/articles/bulk_actions/translate`,
+ { ids: articleIds, locale, category_id: categoryId, force }
+ );
+ }
}
export default new ArticlesAPI();
diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
index ce9e9db36..25eda255a 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
@@ -9,6 +9,9 @@ import {
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
+import { useMapGetter } from 'dashboard/composables/store.js';
+import { useConfig } from 'dashboard/composables/useConfig';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -52,6 +55,21 @@ const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
+const currentAccountId = useMapGetter('getCurrentAccountId');
+const isFeatureEnabledonAccount = useMapGetter(
+ 'accounts/isFeatureEnabledonAccount'
+);
+const { isEnterprise } = useConfig();
+
+const isTranslationAvailable = computed(
+ () =>
+ isEnterprise &&
+ isFeatureEnabledonAccount.value(
+ currentAccountId.value,
+ FEATURE_FLAGS.CAPTAIN_TASKS
+ )
+);
+
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
@@ -64,7 +82,9 @@ const articleMenuItems = computed(() => {
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
- ).map(key => commonItems[key]);
+ )
+ .filter(key => key !== 'translate' || isTranslationAvailable.value)
+ .map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
index cc7c97000..c46a965bc 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
@@ -22,6 +22,8 @@ const props = defineProps({
},
});
+const emit = defineEmits(['translateArticle']);
+
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
@@ -152,6 +154,10 @@ const handleArticleAction = async (action, { status, id }) => {
};
const updateArticle = ({ action, value, id }) => {
+ if (action === 'translate') {
+ emit('translateArticle', id);
+ return;
+ }
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
index e31d40d8a..f1e177505 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
@@ -1,5 +1,5 @@
@@ -170,6 +179,7 @@ const navigateToNewArticlePage = () => {
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
+ @translate-article="handleTranslateArticle"
/>
{
@click="navigateToNewArticlePage"
/>
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue
new file mode 100644
index 000000000..c2551830d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+
+ {{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
+
+
+
+
+
+ {{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
+
+ {{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
+
+
+
+
+
+
+
+
+ {{
+ t(
+ 'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
+ duplicateArticles.length
+ )
+ }}
+
+
+
+ {{
+ t(
+ 'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
+ )
+ }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/helper/portalHelper.js b/app/javascript/dashboard/helper/portalHelper.js
index 37d09337f..33a2a822a 100644
--- a/app/javascript/dashboard/helper/portalHelper.js
+++ b/app/javascript/dashboard/helper/portalHelper.js
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
+ translate: {
+ label:
+ 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
+ value: 'translate',
+ action: 'translate',
+ icon: 'i-lucide-languages',
+ },
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
- [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
- [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
- [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
+ [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
+ [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
+ [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index ffeca222a..bb9bf2e99 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
+ "TRANSLATE": "Translate",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,23 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
+ },
+ "BULK_TRANSLATE": {
+ "TITLE": "Translate article | Translate {count} articles",
+ "DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
+ "LOCALE_LABEL": "Target language",
+ "LOCALE_PLACEHOLDER": "Select a language",
+ "CATEGORY_LABEL": "Target category",
+ "CATEGORY_PLACEHOLDER": "Select a category",
+ "OPTIONAL": "(optional)",
+ "CONFIRM": "Translate",
+ "CONFIRM_OVERWRITE": "Overwrite and translate",
+ "DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
+ "DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
+ "API": {
+ "SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
+ "ERROR_MESSAGE": "Failed to start translation. Please try again."
+ }
}
},
"CATEGORY_PAGE": {
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
index 13a2b4899..229a42a5d 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
@@ -166,4 +166,18 @@ export const actions = {
throw error;
}
},
+
+ bulkTranslate: async (
+ _,
+ { portalSlug, articleIds, locale, categoryId, force = false }
+ ) => {
+ const { data } = await articlesAPI.bulkTranslate({
+ portalSlug,
+ articleIds,
+ locale,
+ categoryId,
+ force,
+ });
+ return data;
+ },
};
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 1841db332..36f115bab 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -488,6 +488,11 @@ en:
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
portals:
+ articles:
+ captain_not_available: 'Translation requires Captain to be enabled for this account'
+ locale_not_available: 'Locale not available in this portal'
+ category_not_found: 'Category not found in this portal'
+ no_articles_found: 'No articles found to process'
send_instructions:
email_required: 'Email is required'
invalid_email_format: 'Invalid email format'
diff --git a/config/routes.rb b/config/routes.rb
index 2461539ad..c6111d317 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -358,6 +358,11 @@ Rails.application.routes.draw do
resources :categories do
post :reorder, on: :collection
end
+ namespace :articles do
+ resource :bulk_actions, only: [] do
+ post :translate
+ end
+ end
resources :articles do
post :reorder, on: :collection
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb
new file mode 100644
index 000000000..87ad54d24
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb
@@ -0,0 +1,68 @@
+module Enterprise::Api::V1::Accounts::Articles::BulkActionsController
+ def translate
+ return unless validate_translate_params?
+
+ duplicates = find_existing_translations
+ if duplicates.any? && !ActiveModel::Type::Boolean.new.cast(permitted_params[:force])
+ return render json: {
+ duplicate_articles: duplicates.map { |a| { id: a.id, title: a.title } }
+ }, status: :conflict
+ end
+
+ @articles.find_each do |article|
+ Captain::Articles::TranslateJob.perform_later(
+ Current.account, article.id, @locale, @category&.id, Current.user
+ )
+ end
+
+ head :ok
+ end
+
+ private
+
+ def permitted_params
+ params.permit(:locale, :category_id, :force, ids: [])
+ end
+
+ def validate_translate_params?
+ @locale = permitted_params[:locale]
+ @category = @portal.categories.find_by(id: permitted_params[:category_id], locale: @locale)
+ @articles = @portal.articles.where(id: permitted_params[:ids])
+
+ captain_available? && valid_locale? && valid_category? && valid_articles?
+ end
+
+ def find_existing_translations
+ root_ids = @articles.map { |a| Article.find_root_article_id(a) }
+ @portal.articles.where(associated_article_id: root_ids, locale: @locale)
+ end
+
+ def captain_available?
+ return true if Current.account.feature_enabled?('captain_tasks')
+
+ render_could_not_create_error(I18n.t('portals.articles.captain_not_available'))
+ false
+ end
+
+ def valid_locale?
+ return true if @portal.config['allowed_locales']&.include?(@locale)
+
+ render_could_not_create_error(I18n.t('portals.articles.locale_not_available'))
+ false
+ end
+
+ def valid_category?
+ return true if permitted_params[:category_id].blank?
+ return true if @category.present?
+
+ render_could_not_create_error(I18n.t('portals.articles.category_not_found'))
+ false
+ end
+
+ def valid_articles?
+ return true if @articles.any?
+
+ render_could_not_create_error(I18n.t('portals.articles.no_articles_found'))
+ false
+ end
+end
diff --git a/enterprise/app/jobs/captain/articles/translate_job.rb b/enterprise/app/jobs/captain/articles/translate_job.rb
new file mode 100644
index 000000000..c3524cbff
--- /dev/null
+++ b/enterprise/app/jobs/captain/articles/translate_job.rb
@@ -0,0 +1,59 @@
+class Captain::Articles::TranslateJob < ApplicationJob
+ queue_as :low
+
+ def perform(account, article_id, target_locale, target_category_id, user)
+ @account = account
+ @source_article = account.articles.find(article_id)
+
+ target_language = language_name_for(target_locale)
+
+ translated_title = translate(@source_article.title, target_language: target_language, type: :title)
+ translated_content = if @source_article.content.present?
+ translate(@source_article.content, target_language: target_language, type: :content)
+ else
+ @source_article.content
+ end
+
+ existing = find_existing_translation(target_locale)
+
+ if existing
+ existing.update!(title: translated_title, content: translated_content, description: @source_article.description)
+ else
+ create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
+ end
+ end
+
+ private
+
+ def translate(text, target_language:, type:)
+ response = Captain::Llm::ArticleTranslationService.new(
+ account: @account, text: text, target_language: target_language, type: type
+ ).perform
+ raise "Translation failed: #{response[:error]}" if response[:error]
+
+ response[:message]
+ end
+
+ def find_existing_translation(target_locale)
+ root_id = Article.find_root_article_id(@source_article)
+ @source_article.portal.articles.find_by(associated_article_id: root_id, locale: target_locale)
+ end
+
+ def create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
+ @source_article.portal.articles.create!(
+ title: translated_title,
+ content: translated_content,
+ description: @source_article.description,
+ category_id: target_category_id,
+ locale: target_locale,
+ author_id: user.id,
+ status: :draft,
+ associated_article_id: Article.find_root_article_id(@source_article)
+ )
+ end
+
+ def language_name_for(locale_code)
+ language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
+ language_map[locale_code] || locale_code
+ end
+end
diff --git a/enterprise/app/services/captain/llm/article_translation_service.rb b/enterprise/app/services/captain/llm/article_translation_service.rb
new file mode 100644
index 000000000..5db26088e
--- /dev/null
+++ b/enterprise/app/services/captain/llm/article_translation_service.rb
@@ -0,0 +1,62 @@
+class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService
+ TYPES = %i[title content].freeze
+
+ pattr_initialize [:account!, :text!, :target_language!, :type!]
+
+ def perform
+ raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type)
+
+ response = make_api_call(model: translation_model, messages: messages)
+ return response if response[:error]
+
+ response.merge(message: response[:message].strip)
+ end
+
+ private
+
+ def messages
+ [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: text }
+ ]
+ end
+
+ def system_prompt
+ type == :title ? title_system_prompt : content_system_prompt
+ end
+
+ def event_name
+ 'article_translation'
+ end
+
+ def llm_credential
+ @llm_credential ||= system_llm_credential
+ end
+
+ def translation_model
+ @translation_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
+ end
+
+ def title_system_prompt
+ <<~SYSTEM_PROMPT_MESSAGE
+ You are a professional translator.
+ Translate the following text to #{target_language}.
+ Return only the translated text, no explanations or extra formatting.
+ SYSTEM_PROMPT_MESSAGE
+ end
+
+ def content_system_prompt
+ <<~SYSTEM_PROMPT_MESSAGE
+ You are a professional translator. Translate the following content to #{target_language}.
+ The content is markdown that may contain embedded HTML blocks.
+ Rules:
+ - Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
+ - Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
+ - Preserve ALL HTML tags, attributes, and structure exactly as they are.
+ - Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
+ - Keep all image tags (both markdown  and HTML ), iframes, and embedded media completely unchanged.
+ - Preserve all line breaks, blank lines, and whitespace patterns.
+ - Return ONLY the translated content, no wrapping or explanations.
+ SYSTEM_PROMPT_MESSAGE
+ end
+end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb
new file mode 100644
index 000000000..31f61d977
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller_spec.rb
@@ -0,0 +1,179 @@
+require 'rails_helper'
+
+RSpec.describe 'Article Bulk Actions API', type: :request do
+ include ActiveJob::TestHelper
+
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es fr] }) }
+ let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
+ let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') }
+ let!(:article_one) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) }
+ let!(:article_two) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) }
+
+ let(:translate_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions/translate" }
+
+ describe 'POST articles/bulk_actions/translate' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ post translate_url, params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as agent' do
+ it 'returns unauthorized' do
+ post translate_url,
+ headers: agent.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when captain is not enabled' do
+ it 'returns unprocessable entity' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'when authenticated as admin' do
+ before do
+ account.enable_features!('captain_tasks')
+ end
+
+ it 'enqueues translation jobs for each article' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_two.id], locale: 'es', category_id: category_es.id },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(2).times
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'enqueues job with correct arguments' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
+ account, article_one.id, 'es', category_es.id, admin
+ )
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns unprocessable entity for invalid locale' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'zh', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'returns unprocessable entity for invalid category' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: 0 },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'returns unprocessable entity when category locale does not match requested locale' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_en.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'enqueues job with nil category when category_id is omitted' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es' },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
+ account, article_one.id, 'es', nil, admin
+ )
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'enqueues job with nil category when category_id is blank' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: '' },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
+ account, article_one.id, 'es', nil, admin
+ )
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'returns unprocessable entity when no articles found' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [0], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ context 'when translations already exist' do
+ let!(:existing_translation) do
+ create(:article, portal: portal, category: category_es, account: account, author_id: admin.id,
+ locale: 'es', associated_article_id: article_one.id)
+ end
+
+ it 'returns conflict with duplicate articles' do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+
+ expect(response).to have_http_status(:conflict)
+ body = response.parsed_body
+ expect(body['duplicate_articles'].length).to eq(1)
+ expect(body['duplicate_articles'].first['id']).to eq(existing_translation.id)
+ end
+
+ it 'does not enqueue jobs when duplicates found without force' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
+ as: :json
+ end.not_to have_enqueued_job(Captain::Articles::TranslateJob)
+ end
+
+ it 'enqueues jobs when force is true' do
+ expect do
+ post translate_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], locale: 'es', category_id: category_es.id, force: true },
+ as: :json
+ end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(1).times
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/captain/articles/translate_job_spec.rb b/spec/enterprise/jobs/captain/articles/translate_job_spec.rb
new file mode 100644
index 000000000..119c93b1c
--- /dev/null
+++ b/spec/enterprise/jobs/captain/articles/translate_job_spec.rb
@@ -0,0 +1,134 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Articles::TranslateJob, type: :job do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account, role: :administrator) }
+ let!(:portal) { create(:portal, account: account, config: { allowed_locales: %w[en es] }) }
+ let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
+ let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') }
+ let!(:article) do
+ create(:article, portal: portal, category: category_en, account: account, author: user,
+ title: 'Getting Started', content: '# Welcome\nThis is a guide.')
+ end
+
+ let(:title_service) { instance_double(Captain::Llm::ArticleTranslationService) }
+ let(:content_service) { instance_double(Captain::Llm::ArticleTranslationService) }
+
+ before do
+ allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :title)).and_return(title_service)
+ allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :content)).and_return(content_service)
+ allow(title_service).to receive(:perform).and_return(message: 'Primeros pasos')
+ allow(content_service).to receive(:perform).and_return(message: '# Bienvenido\nEsta es una guía.')
+ end
+
+ it 'queues on the low queue' do
+ expect { described_class.perform_later(account, article.id, 'es', category_es.id, user) }
+ .to have_enqueued_job.on_queue('low')
+ end
+
+ it 'creates a translated article as draft' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+ end.to change(Article, :count).by(1)
+
+ translated = Article.last
+ expect(translated).to have_attributes(
+ title: 'Primeros pasos',
+ content: '# Bienvenido\nEsta es una guía.',
+ locale: 'es',
+ category_id: category_es.id,
+ author_id: user.id,
+ status: 'draft',
+ associated_article_id: article.id
+ )
+ end
+
+ it 'creates a translated article without a category when target_category_id is nil' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', nil, user)
+ end.to change(Article, :count).by(1)
+
+ translated = Article.last
+ expect(translated).to have_attributes(
+ title: 'Primeros pasos',
+ locale: 'es',
+ category_id: nil,
+ status: 'draft',
+ associated_article_id: article.id
+ )
+ end
+
+ it 'calls the translation service with the correct language' do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+
+ expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
+ account: account, text: 'Getting Started', target_language: 'Spanish', type: :title
+ )
+ expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
+ account: account, text: '# Welcome\nThis is a guide.', target_language: 'Spanish', type: :content
+ )
+ end
+
+ it 'uses language_map for locale name resolution' do
+ described_class.perform_now(account, article.id, 'pt_BR', category_es.id, user)
+
+ expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
+ hash_including(target_language: 'Portuguese (Brazil)', type: :title)
+ )
+ end
+
+ context 'when a translation already exists' do
+ let!(:existing_translation) do
+ create(:article, portal: portal, category: category_es, account: account, author: user,
+ title: 'Old title', content: 'Old content', locale: 'es',
+ associated_article_id: article.id)
+ end
+
+ it 'updates the existing translation instead of creating a new one' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+ end.not_to change(Article, :count)
+
+ existing_translation.reload
+ expect(existing_translation).to have_attributes(
+ title: 'Primeros pasos',
+ content: '# Bienvenido\nEsta es una guía.',
+ description: article.description
+ )
+ end
+ end
+
+ context 'when the source article has blank content' do
+ let!(:draft_article) do
+ create(:article, portal: portal, category: category_en, account: account, author: user,
+ title: 'Empty draft', content: nil, status: :draft)
+ end
+
+ it 'creates the translated article with the original blank content and skips the content LLM call' do
+ expect do
+ described_class.perform_now(account, draft_article.id, 'es', category_es.id, user)
+ end.to change(Article, :count).by(1)
+
+ expect(content_service).not_to have_received(:perform)
+ translated = Article.last
+ expect(translated).to have_attributes(
+ title: 'Primeros pasos',
+ content: nil,
+ locale: 'es',
+ associated_article_id: draft_article.id
+ )
+ end
+ end
+
+ context 'when translation service fails' do
+ before do
+ allow(title_service).to receive(:perform).and_return(error: 'LLM timeout')
+ end
+
+ it 'raises the error and does not create an article' do
+ expect do
+ described_class.perform_now(account, article.id, 'es', category_es.id, user)
+ end.to raise_error(RuntimeError, /LLM timeout/).and not_change(Article, :count)
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/llm/article_translation_service_spec.rb b/spec/enterprise/services/captain/llm/article_translation_service_spec.rb
new file mode 100644
index 000000000..1c0d83b65
--- /dev/null
+++ b/spec/enterprise/services/captain/llm/article_translation_service_spec.rb
@@ -0,0 +1,67 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Llm::ArticleTranslationService do
+ let(:account) { create(:account) }
+ let(:target_language) { 'Spanish' }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ describe '#perform with type: :title' do
+ let(:service) do
+ described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title)
+ end
+
+ it 'returns the stripped translated title' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to include('professional translator')
+ expect(args[:messages][0][:content]).to include(target_language)
+ expect(args[:messages][1][:content]).to eq('Getting Started')
+ { message: " Primeros pasos \n" }
+ end
+
+ expect(service.perform).to include(message: 'Primeros pasos')
+ end
+ end
+
+ describe '#perform with type: :content' do
+ let(:content) { "# Welcome\nSome markdown." }
+ let(:service) do
+ described_class.new(account: account, text: content, target_language: target_language, type: :content)
+ end
+
+ it 'returns the stripped translated content using the markdown system prompt' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to include('markdown')
+ expect(args[:messages][0][:content]).to include('Preserve ALL HTML tags')
+ expect(args[:messages][1][:content]).to eq(content)
+ { message: "# Bienvenido\nAlgo de markdown.\n" }
+ end
+
+ expect(service.perform).to include(message: "# Bienvenido\nAlgo de markdown.")
+ end
+ end
+
+ describe '#perform with an invalid type' do
+ it 'raises ArgumentError' do
+ service = described_class.new(account: account, text: 'hi', target_language: target_language, type: :invalid)
+
+ expect { service.perform }.to raise_error(ArgumentError, /Invalid type/)
+ end
+ end
+
+ describe '#perform when the API call fails' do
+ let(:service) do
+ described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title)
+ end
+
+ it 'returns the error hash unchanged' do
+ allow(service).to receive(:make_api_call).and_return(error: 'LLM timeout', error_code: 500)
+
+ expect(service.perform).to eq(error: 'LLM timeout', error_code: 500)
+ end
+ end
+end
From 2ada713f29b2cf5f864cccd3f74d570e73f6b82f Mon Sep 17 00:00:00 2001
From: Pranav
Date: Fri, 24 Apr 2026 09:13:43 -0700
Subject: [PATCH 20/24] feat: Add bulk actions for help center articles
(translate, status change, delete) (#14137)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes
https://linear.app/chatwoot/issue/CW-6950/support-bulk-actions-for-publish-archive-move-to-draft-delete-in
How to test
1. Go to Help Center → Articles
2. Select articles using checkboxes → bulk bar appears
3. Click Publish/Draft/Archive → articles update, list refreshes
4. Click Delete → confirmation dialog → articles removed
5. Click Translate (requires Captain enabled) → select locale + category
→ translation starts
6. Try translating to a locale that already has translations → warning
with links to existing articles → "Overwrite and translate" proceeds
8. Single article: click three-dot menu → Translate → same dialog opens
for that article
https://github.com/user-attachments/assets/7c76495e-f89e-4456-92bd-a6639a9992f4
---------
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../articles/bulk_actions_controller.rb | 24 ++
.../dashboard/api/helpCenter/articles.js | 14 ++
.../HelpCenter/ArticleCard/ArticleCard.vue | 47 +++-
.../Pages/ArticlePage/ArticleList.vue | 29 ++-
.../Pages/ArticlePage/ArticlesPage.vue | 231 +++++++++++++++++-
.../dashboard/i18n/locale/en/helpCenter.json | 18 ++
.../pages/PortalsArticlesIndexPage.vue | 1 +
config/locales/en.yml | 1 +
config/routes.rb | 2 +
.../articles/bulk_actions_controller_spec.rb | 141 +++++++++++
10 files changed, 486 insertions(+), 22 deletions(-)
create mode 100644 spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb
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 584e3dbf2..b45c16828 100644
--- a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
+++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb
@@ -1,11 +1,31 @@
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]
def translate
head :not_implemented
end
+ def update_status
+ 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.invalid_status')) unless Article.statuses.key?(params[:status])
+
+ ActiveRecord::Base.transaction do
+ @articles.find_each { |article| article.update!(status: params[:status]) }
+ 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?
+
+ @articles.destroy_all
+ head :ok
+ end
+
private
def portal
@@ -15,5 +35,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
def check_authorization
authorize(Article, :create?)
end
+
+ def set_articles
+ @articles = @portal.articles.where(id: params[:ids])
+ end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js
index 781570d0b..c79aa5da7 100644
--- a/app/javascript/dashboard/api/helpCenter/articles.js
+++ b/app/javascript/dashboard/api/helpCenter/articles.js
@@ -79,6 +79,20 @@ class ArticlesAPI extends PortalsAPI {
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
+
+ bulkUpdateStatus({ portalSlug, articleIds, status }) {
+ return axios.patch(
+ `${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
+ { ids: articleIds, status }
+ );
+ }
+
+ bulkDelete({ portalSlug, articleIds }) {
+ return axios.delete(
+ `${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
+ { data: { ids: articleIds } }
+ );
+ }
}
export default new ArticlesAPI();
diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
index 25eda255a..93ff15251 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
@@ -17,6 +17,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
+import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -47,9 +48,26 @@ const props = defineProps({
type: Number,
required: true,
},
+ isSelected: {
+ type: Boolean,
+ default: false,
+ },
+ selectable: {
+ type: Boolean,
+ default: false,
+ },
+ showSelectionControl: {
+ type: Boolean,
+ default: false,
+ },
});
-const emit = defineEmits(['openArticle', 'articleAction']);
+const emit = defineEmits([
+ 'openArticle',
+ 'articleAction',
+ 'toggleSelect',
+ 'hover',
+]);
const { t } = useI18n();
@@ -143,14 +161,27 @@ const handleClick = id => {
-
+
+
+
+
-
- {{ title }}
-
+
+
+ {{ title }}
+
+
new Set(),
+ },
});
-const emit = defineEmits(['translateArticle']);
+const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
@@ -32,12 +36,26 @@ const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
+const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
- // Enable dragging only for category articles and when there's more than one article
- return props.isCategoryArticles && localArticles.value?.length > 1;
+ return (
+ props.isCategoryArticles &&
+ localArticles.value?.length > 1 &&
+ props.selectedArticleIds.size === 0
+ );
});
+const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
+
+const shouldShowSelectionControl = id => {
+ return hoveredArticleId.value === id || hasBulkSelection.value;
+};
+
+const handleCardHover = (isHovered, id) => {
+ hoveredArticleId.value = isHovered ? id : null;
+};
+
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
@@ -193,9 +211,14 @@ watch(
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
+ :is-selected="selectedArticleIds.has(element.id)"
+ selectable
+ :show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
+ @toggle-select="emit('toggleSelect', $event)"
+ @hover="isHovered => handleCardHover(isHovered, element.id)"
/>
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
index f1e177505..325f6abe6 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
@@ -1,9 +1,13 @@
@@ -175,12 +284,91 @@ const handleTranslateArticle = articleId => {
>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index bb9bf2e99..9ae849d25 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -590,6 +590,10 @@
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
+ "SELECT_ALL": "Select all ({count})",
+ "SELECTED_COUNT": "{count} selected",
+ "CLEAR_SELECTION": "Clear selection",
+ "TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
@@ -597,6 +601,20 @@
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
+ },
+ "BULK_ACTIONS": {
+ "PUBLISH": "Publish",
+ "DRAFT": "Draft",
+ "ARCHIVE": "Archive",
+ "TRANSLATE": "Translate",
+ "DELETE": "Delete",
+ "STATUS_SUCCESS": "Articles updated successfully",
+ "STATUS_ERROR": "Failed to update articles",
+ "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
+ "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
+ "DELETE_CONFIRM": "Delete",
+ "DELETE_SUCCESS": "Articles deleted successfully",
+ "DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
index 7d636de70..c0fc800e6 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue
@@ -119,6 +119,7 @@ watch(
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
+ @refresh-articles="fetchArticles"
/>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 36f115bab..9ffd3f3d5 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -493,6 +493,7 @@ en:
locale_not_available: 'Locale not available in this portal'
category_not_found: 'Category not found in this portal'
no_articles_found: 'No articles found to process'
+ invalid_status: 'Invalid status value'
send_instructions:
email_required: 'Email is required'
invalid_email_format: 'Invalid email format'
diff --git a/config/routes.rb b/config/routes.rb
index c6111d317..a1d3d088e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -361,6 +361,8 @@ Rails.application.routes.draw do
namespace :articles do
resource :bulk_actions, only: [] do
post :translate
+ patch :update_status
+ delete :delete_articles
end
end
resources :articles do
diff --git a/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb
new file mode 100644
index 000000000..3dab5b60f
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb
@@ -0,0 +1,141 @@
+require 'rails_helper'
+
+RSpec.describe 'Article Bulk Actions API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es] }) }
+ let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
+ let!(:article_one) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
+ let!(:article_two) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
+ let!(:article_three) { create(:article, category: category, portal: portal, account: account, author: admin, status: :published) }
+
+ let(:base_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions" }
+
+ describe 'PATCH articles/bulk_actions/update_status' do
+ let(:update_status_url) { "#{base_url}/update_status" }
+
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ patch update_status_url, params: { ids: [article_one.id], status: 'published' }, as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as agent' do
+ it 'returns unauthorized' do
+ patch update_status_url,
+ headers: agent.create_new_auth_token,
+ params: { ids: [article_one.id], status: 'published' },
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as admin' do
+ it 'publishes multiple articles' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_two.id], status: 'published' },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(article_one.reload.status).to eq('published')
+ expect(article_two.reload.status).to eq('published')
+ end
+
+ it 'archives multiple articles' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_three.id], status: 'archived' },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(article_one.reload.status).to eq('archived')
+ expect(article_three.reload.status).to eq('archived')
+ end
+
+ it 'sets articles to draft' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_three.id], status: 'draft' },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(article_three.reload.status).to eq('draft')
+ end
+
+ it 'does not affect articles not in the list' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id], status: 'published' },
+ as: :json
+
+ expect(article_one.reload.status).to eq('published')
+ expect(article_three.reload.status).to eq('published')
+ end
+
+ it 'returns unprocessable entity when no articles found' do
+ patch update_status_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [0], status: 'published' },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe 'DELETE articles/bulk_actions/delete_articles' do
+ let(:destroy_url) { "#{base_url}/delete_articles" }
+
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ delete destroy_url, params: { ids: [article_one.id] }, as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as agent' do
+ it 'returns unauthorized' do
+ delete destroy_url,
+ headers: agent.create_new_auth_token,
+ params: { ids: [article_one.id] },
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as admin' do
+ it 'deletes multiple articles' do
+ expect do
+ delete destroy_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id, article_two.id] },
+ as: :json
+ end.to change(Article, :count).by(-2)
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'does not delete articles not in the list' do
+ delete destroy_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [article_one.id] },
+ as: :json
+
+ expect(Article.exists?(article_one.id)).to be(false)
+ expect(Article.exists?(article_three.id)).to be(true)
+ end
+
+ it 'returns unprocessable entity when no articles found' do
+ delete destroy_url,
+ headers: admin.create_new_auth_token,
+ params: { ids: [0] },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+end
From a651949c33383b57d08e21d2210916b1ae2e9363 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Mon, 27 Apr 2026 13:01:44 +0530
Subject: [PATCH 21/24] fix: improve FAQ generation [AI-145] (#14062)
# Pull Request Template
## Description
- Fetch main content only from Firecrawl, exclude some tags to remove
boilerplate
- Prompt changes for FAQ generation
## Type of change
Please delete options that are not relevant.
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
tested locally
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
---
.../captain/documents/response_builder_job.rb | 2 +-
enterprise/app/models/captain/document.rb | 4 +++
.../captain/llm/faq_generator_service.rb | 16 ++++++++----
.../llm/paginated_faq_generator_service.rb | 14 +++++------
.../captain/llm/system_prompts_service.rb | 25 ++++++++++++-------
.../captain/tools/firecrawl_service.rb | 17 ++++++++-----
.../documents/response_builder_job_spec.rb | 13 +++-------
.../captain/llm/faq_generator_service_spec.rb | 11 ++++----
.../captain/tools/firecrawl_service_spec.rb | 4 +--
9 files changed, 62 insertions(+), 44 deletions(-)
diff --git a/enterprise/app/jobs/captain/documents/response_builder_job.rb b/enterprise/app/jobs/captain/documents/response_builder_job.rb
index 60f1fa2cf..8f6643e36 100644
--- a/enterprise/app/jobs/captain/documents/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/documents/response_builder_job.rb
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
- Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
+ Captain::Llm::FaqGeneratorService.new(document: document).generate
end
def build_paginated_service(document, options)
diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb
index 2abf18437..c2b5fa214 100644
--- a/enterprise/app/models/captain/document.rb
+++ b/enterprise/app/models/captain/document.rb
@@ -117,6 +117,10 @@ class Captain::Document < ApplicationRecord
end
end
+ def to_llm_metadata
+ { document_id: id, assistant_id: assistant_id, external_link: external_link }
+ end
+
private
def enqueue_crawl_job
diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb
index 5f85ae467..b80382b3e 100644
--- a/enterprise/app/services/captain/llm/faq_generator_service.rb
+++ b/enterprise/app/services/captain/llm/faq_generator_service.rb
@@ -1,11 +1,12 @@
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
include Integrations::LlmInstrumentation
- def initialize(content, language = 'english', account_id: nil)
+ def initialize(document:)
super()
- @language = language
- @content = content
- @account_id = account_id
+ @document = document
+ @content = document.content
+ @language = document.account.locale_english_name
+ @account_id = document.account_id
end
def generate
@@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
- ]
+ ],
+ metadata: document_metadata
}
end
+ def document_metadata
+ @document&.to_llm_metadata || {}
+ end
+
def parse_response(content)
return [] if content.nil?
diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
index 3fe81c2ae..b567609e8 100644
--- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
+++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
@@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
account_id: @document&.account_id,
feature_name: 'faq_generation',
model: @model,
- messages: params[:messages]
+ messages: params[:messages],
+ metadata: document_metadata
}
response = instrument_llm_call(instrumentation_params) do
@@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
feature_name: 'paginated_faq_generation',
model: @model,
messages: params[:messages],
- metadata: {
- document_id: @document&.id,
- start_page: start_page,
- end_page: end_page,
- iteration: @iterations_completed + 1
- }
+ metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
}
end
+
+ def document_metadata
+ @document&.to_llm_metadata || {}
+ end
end
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index 9868f0360..dab147301 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService
class << self
def faq_generator(language = 'english')
<<~PROMPT
- You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
+ You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information.
## Core Requirements
- **Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
+ **Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely.
+
+ **Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found — without containing that information itself — omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all.
+
+ **Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome — navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
@@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService
## Guidelines
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
- - **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
- - **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
+ - **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source.
+ - **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity.
+ - **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it.
- **JSON Validity**: Always return properly formatted, valid JSON
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
## Process
1. Read the entire provided content carefully
- 2. Identify all key information points, procedures, and examples
- 3. Create questions that cover each information point
- 4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
- 5. Verify that combined FAQs represent the complete original content.
- 6. Format as valid JSON
+ 2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
+ 3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
+ 4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
+ 5. Create questions that cover each remaining substantive information point
+ 6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
+ 7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
+ 8. Format as valid JSON
PROMPT
end
diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb
index fc7448593..3d1b53b7a 100644
--- a/enterprise/app/services/captain/tools/firecrawl_service.rb
+++ b/enterprise/app/services/captain/tools/firecrawl_service.rb
@@ -1,5 +1,6 @@
class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
+ FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
@@ -33,16 +34,20 @@ class Captain::Tools::FirecrawlService
ignoreSitemap: false,
limit: crawl_limit,
webhook: webhook_url,
- scrapeOptions: {
- onlyMainContent: false,
- formats: ['markdown'],
- excludeTags: ['iframe']
- }
+ scrapeOptions: scrape_options
}.to_json
end
def scrape_payload(url)
- { url: url, formats: ['markdown'], excludeTags: ['iframe'] }.to_json
+ { url: url }.merge(scrape_options).to_json
+ end
+
+ def scrape_options
+ {
+ onlyMainContent: true,
+ formats: ['markdown'],
+ excludeTags: FIRECRAWL_EXCLUDE_TAGS
+ }
end
def headers
diff --git a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
index c3e5eab1c..4d1a07aa7 100644
--- a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
@@ -12,9 +12,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
end
before do
- allow(Captain::Llm::FaqGeneratorService).to receive(:new)
- .with(document.content, document.account.locale_english_name, account_id: document.account_id)
- .and_return(faq_generator)
+ allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: document).and_return(faq_generator)
allow(faq_generator).to receive(:generate).and_return(faqs)
end
@@ -51,17 +49,14 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
let(:spanish_faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
before do
- allow(Captain::Llm::FaqGeneratorService).to receive(:new)
- .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
- .and_return(spanish_faq_generator)
+ allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: spanish_document).and_return(spanish_faq_generator)
allow(spanish_faq_generator).to receive(:generate).and_return(faqs)
end
- it 'passes the correct locale to FAQ generator' do
+ it 'passes the correct document to FAQ generator' do
described_class.new.perform(spanish_document)
- expect(Captain::Llm::FaqGeneratorService).to have_received(:new)
- .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
+ expect(Captain::Llm::FaqGeneratorService).to have_received(:new).with(document: spanish_document)
end
end
diff --git a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
index 003d5b715..ff7138c9a 100644
--- a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
@@ -2,8 +2,8 @@ require 'rails_helper'
RSpec.describe Captain::Llm::FaqGeneratorService do
let(:content) { 'Sample content for FAQ generation' }
- let(:language) { 'english' }
- let(:service) { described_class.new(content, language) }
+ let(:document) { create(:captain_document, content: content) }
+ let(:service) { described_class.new(document: document) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:sample_faqs) do
[
@@ -36,14 +36,15 @@ RSpec.describe Captain::Llm::FaqGeneratorService do
service.generate
end
- it 'uses SystemPromptsService with the specified language' do
- expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language).at_least(:once).and_call_original
+ it 'uses SystemPromptsService with the account language' do
+ account_language = document.account.locale_english_name
+ expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(account_language).at_least(:once).and_call_original
service.generate
end
end
context 'with different language' do
- let(:language) { 'spanish' }
+ before { allow(document.account).to receive(:locale_english_name).and_return('spanish') }
it 'passes the correct language to SystemPromptsService' do
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish').at_least(:once).and_call_original
diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
index d6563b163..b46633a6e 100644
--- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
@@ -58,9 +58,9 @@ RSpec.describe Captain::Tools::FirecrawlService do
limit: crawl_limit,
webhook: webhook_url,
scrapeOptions: {
- onlyMainContent: false,
+ onlyMainContent: true,
formats: ['markdown'],
- excludeTags: ['iframe']
+ excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
}
}.to_json
end
From 8faa5a74b1ce27248f15aa2bce9a2c6c56d227d7 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Mon, 27 Apr 2026 13:30:51 +0530
Subject: [PATCH 22/24] fix: prevent focus jump to title after new article
auto-creates (#14145)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Pull Request Template
## Description
When creating a help center article, typing a title and navigating into
the content auto-creates the article and switches the route
(`/articles/new` → `/articles/.../edit/:slug`). During this transition,
focus was jumping back to the title, interrupting editing.
This happened because `ArticleEditor` always autofocuses the title. On
route change, the component remounts and re-triggers focus. Now, after
auto-create, focus stays in the body as expected.
Fixes
https://linear.app/chatwoot/issue/CW-6951/issue-with-the-cursor-position-on-the-help-center-article-when
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Screencast**
https://github.com/user-attachments/assets/dac3f7c6-08c4-4df2-afb0-7731ee76424b
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
index 22cb1441a..831312e0b 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
- autofocus
+ :autofocus="isNewArticle"
@blur="handleCreateArticle"
/>
{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
- :autofocus="false"
+ :autofocus="!isNewArticle"
/>
From 06467057be17d900450f8eac98cb7845a65bdf29 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Mon, 27 Apr 2026 13:31:43 +0530
Subject: [PATCH 23/24] fix: oversized email signature images in Letter render
(#14144)
# Pull Request Template
## Description
This PR fixes an issue where signature images (with
`?cw_image_height=...`) render at their original large size in the email
bubble.
### Cause
Renderer output:
```html
```
Email UI and clients (Gmail, Outlook) apply CSS like:
`img { max-width: 100%; height: auto; }`
This overrides `height="24px"`.
Other channels work because they use inline styles (`style="height:
24px;"`).
### Solution
Use inline style instead:
```html
```
### Why backend fix
* Fixes root cause and aligns Ruby + JS renderers
* Works in both Chatwoot UI and recipient inboxes
* Covers all email-rendered content
* Minimal change
Fixes
https://linear.app/chatwoot/issue/CW-6948/email-signature-image-renders-oversized-in-chatwoot-ui
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
**After**
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
lib/base_markdown_renderer.rb | 8 ++++++--
spec/lib/base_markdown_renderer_spec.rb | 2 +-
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb
index df49918b3..f530e71ee 100644
--- a/lib/base_markdown_renderer.rb
+++ b/lib/base_markdown_renderer.rb
@@ -29,11 +29,15 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
def render_img_tag(src, title, height = nil)
title_attribute = title.present? ? " title=\"#{title}\"" : ''
- height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : ''
+ # Use inline style instead of the HTML height attribute: email clients and
+ # the in-app Letter view both run images through CSS (e.g. prose /
+ # lettersanitizer's `img { height: auto }`) which overrides presentational
+ # attributes. Inline style has higher specificity and survives.
+ style_attribute = height ? " style=\"height: #{height};\"" : ''
plain do
# plain ensures that the content is not wrapped in a paragraph tag
- out(" ")
+ out(" ")
end
end
end
diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb
index 262e78daf..f8bdae4be 100644
--- a/spec/lib/base_markdown_renderer_spec.rb
+++ b/spec/lib/base_markdown_renderer_spec.rb
@@ -12,7 +12,7 @@ describe BaseMarkdownRenderer do
context 'when image has a height' do
it 'renders the img tag with the correct attributes' do
markdown = ''
- expect(render_markdown(markdown)).to include(' ')
+ expect(render_markdown(markdown)).to include(' ')
end
end
From 0920a01e662163c13714fd6bd393b292df89151d Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Mon, 27 Apr 2026 15:40:00 +0530
Subject: [PATCH 24/24] fix(i18n): align pluralization with locale rules
(#14266)
Loads Rails locale-specific pluralization rules so languages with an
`other`-only plural model can safely use Crowdin exports without
maintaining duplicate `one` keys.
## Closes
None
## Why
Crowdin exports Rails YAML pluralized strings using each target
language's plural categories. These categories come from Unicode CLDR
and represent grammatical forms, not a literal "number is 1" bucket.
Some languages need separate forms such as `one` and `other`, but
languages like Japanese, Korean, Indonesian, Thai, Vietnamese, and
Chinese use the same form for `1`, `2`, `5`, and larger counts in these
strings. For those locales, CLDR correctly models the plural category as
`other` only.
Before this change, Chatwoot still relied on Rails' default
English-style plural behavior for these locales. That meant a valid
Crowdin export containing only `other` could fail at runtime when Rails
received `count: 1` and looked for a missing `one` branch.
Keeping duplicate `one` keys would only fight Crowdin on every
translation sync. The runtime should instead follow the locale's plural
rules.
## What changed
- Added `rails-i18n` and enabled only its pluralization module.
- Added explicit `other`-only plural rules for Chatwoot's underscore
Chinese locale aliases, `zh_CN` and `zh_TW`.
- Removed redundant `one` keys from the affected Devise and `time_units`
translations.
## Validation
- Ran a Rails runner check across `id`, `ja`, `ko`, `ms`, `th`, `vi`,
`zh_CN`, and `zh_TW` to verify `errors.messages.not_saved` and
`time_units.days` resolve with only `other` for `count: 1`.
- Ran YAML parse validation for all edited locale files.
- Ran `bundle exec rubocop Gemfile config/application.rb
config/initializers/i18n_pluralization.rb`.
---
Gemfile | 1 +
Gemfile.lock | 4 ++++
config/application.rb | 1 +
config/initializers/i18n_pluralization.rb | 8 ++++++++
config/locales/devise.id.yml | 1 -
config/locales/devise.ja.yml | 1 -
config/locales/devise.ko.yml | 1 -
config/locales/devise.ms.yml | 1 -
config/locales/devise.th.yml | 1 -
config/locales/devise.vi.yml | 1 -
config/locales/devise.zh_CN.yml | 1 -
config/locales/devise.zh_TW.yml | 1 -
config/locales/id.yml | 4 ----
config/locales/ja.yml | 4 ----
config/locales/ko.yml | 4 ----
config/locales/ms.yml | 4 ----
config/locales/th.yml | 4 ----
config/locales/vi.yml | 4 ----
config/locales/zh_CN.yml | 4 ----
config/locales/zh_TW.yml | 4 ----
20 files changed, 14 insertions(+), 40 deletions(-)
create mode 100644 config/initializers/i18n_pluralization.rb
diff --git a/Gemfile b/Gemfile
index a5068e765..c4989c538 100644
--- a/Gemfile
+++ b/Gemfile
@@ -84,6 +84,7 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
+gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
diff --git a/Gemfile.lock b/Gemfile.lock
index b77e5880f..7d29e0b02 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -727,6 +727,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ rails-i18n (7.0.10)
+ i18n (>= 0.7, < 2)
+ railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
@@ -1125,6 +1128,7 @@ DEPENDENCIES
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
+ rails-i18n (~> 7.0)
redis
redis-namespace
responders (>= 3.1.1)
diff --git a/config/application.rb b/config/application.rb
index aa150794a..08f0451c1 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -37,6 +37,7 @@ module Chatwoot
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
+ config.rails_i18n.enabled_modules = [:pluralization]
config.eager_load_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('enterprise/lib')
diff --git a/config/initializers/i18n_pluralization.rb b/config/initializers/i18n_pluralization.rb
new file mode 100644
index 000000000..c4fc3fb4b
--- /dev/null
+++ b/config/initializers/i18n_pluralization.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+other_plural_rule = ->(_count) { :other }
+
+Rails.application.config.after_initialize do
+ I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } })
+ I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } })
+end
diff --git a/config/locales/devise.id.yml b/config/locales/devise.id.yml
index 71b9f46fb..fc6bfb26a 100644
--- a/config/locales/devise.id.yml
+++ b/config/locales/devise.id.yml
@@ -57,5 +57,4 @@ id:
not_found: "tidak ditemukan"
not_locked: "tidak terkunci"
not_saved:
- one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
diff --git a/config/locales/devise.ja.yml b/config/locales/devise.ja.yml
index 043cd8351..a5840c6fc 100644
--- a/config/locales/devise.ja.yml
+++ b/config/locales/devise.ja.yml
@@ -57,5 +57,4 @@ ja:
not_found: "見つかりませんでした"
not_locked: "はロックされていません"
not_saved:
- one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
diff --git a/config/locales/devise.ko.yml b/config/locales/devise.ko.yml
index 846664ec9..5afb6c1c2 100644
--- a/config/locales/devise.ko.yml
+++ b/config/locales/devise.ko.yml
@@ -57,5 +57,4 @@ ko:
not_found: "찾을 수 없습니다"
not_locked: "잠겨 있지 않습니다"
not_saved:
- one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
diff --git a/config/locales/devise.ms.yml b/config/locales/devise.ms.yml
index ebcfe89e3..cecd08588 100644
--- a/config/locales/devise.ms.yml
+++ b/config/locales/devise.ms.yml
@@ -57,5 +57,4 @@ ms:
not_found: "not found"
not_locked: "was not locked"
not_saved:
- one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/devise.th.yml b/config/locales/devise.th.yml
index c9f52018d..18e1572bb 100644
--- a/config/locales/devise.th.yml
+++ b/config/locales/devise.th.yml
@@ -57,5 +57,4 @@ th:
not_found: "not found"
not_locked: "was not locked"
not_saved:
- one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/devise.vi.yml b/config/locales/devise.vi.yml
index 947e756f3..15dca044a 100644
--- a/config/locales/devise.vi.yml
+++ b/config/locales/devise.vi.yml
@@ -57,5 +57,4 @@ vi:
not_found: "không tìm thấy"
not_locked: "không được khoá"
not_saved:
- one: "Có %{count} lỗi được tìm thấy từ %{resource}:"
other: "Có %{count} lỗi được tìm thấy từ %{resource}:"
diff --git a/config/locales/devise.zh_CN.yml b/config/locales/devise.zh_CN.yml
index 00f239948..2bf2831a8 100644
--- a/config/locales/devise.zh_CN.yml
+++ b/config/locales/devise.zh_CN.yml
@@ -57,5 +57,4 @@ zh_CN:
not_found: "找不到"
not_locked: "未锁定"
not_saved:
- one: "%{count} 个错误禁止保存 %{resource}:"
other: "%{count} 个错误禁止保存 %{resource}:"
diff --git a/config/locales/devise.zh_TW.yml b/config/locales/devise.zh_TW.yml
index c5bd49450..f892bf796 100644
--- a/config/locales/devise.zh_TW.yml
+++ b/config/locales/devise.zh_TW.yml
@@ -57,5 +57,4 @@ zh_TW:
not_found: "找不到。"
not_locked: "並未被鎖定。"
not_saved:
- one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
diff --git a/config/locales/id.yml b/config/locales/id.yml
index aefcff3fe..b097de83d 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -435,16 +435,12 @@ id:
button: Buka percakapan
time_units:
days:
- one: '%{count} days'
other: '%{count} days'
hours:
- one: '%{count} hours'
other: '%{count} hours'
minutes:
- one: '%{count} minutes'
other: '%{count} minutes'
seconds:
- one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 5e3412378..deca85ebf 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -435,16 +435,12 @@ ja:
button: 会話を開く
time_units:
days:
- one: '%{count} 日'
other: '%{count} 日'
hours:
- one: '%{count} 時間'
other: '%{count} 時間'
minutes:
- one: '%{count} 分'
other: '%{count} 分'
seconds:
- one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index c010ee2ac..153f39928 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -435,16 +435,12 @@ ko:
button: 대화 열기
time_units:
days:
- one: '%{count}일'
other: '%{count}일'
hours:
- one: '%{count}시간'
other: '%{count}시간'
minutes:
- one: '%{count}분'
other: '%{count}분'
seconds:
- one: '%{count}초'
other: '%{count}초'
auto_assignment:
default_policy_name: '기본 정책'
diff --git a/config/locales/ms.yml b/config/locales/ms.yml
index 617056d7e..e1ee39aee 100644
--- a/config/locales/ms.yml
+++ b/config/locales/ms.yml
@@ -435,16 +435,12 @@ ms:
button: Open conversation
time_units:
days:
- one: '%{count} days'
other: '%{count} days'
hours:
- one: '%{count} hours'
other: '%{count} hours'
minutes:
- one: '%{count} minutes'
other: '%{count} minutes'
seconds:
- one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
diff --git a/config/locales/th.yml b/config/locales/th.yml
index b278ec442..aeef51e95 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -435,16 +435,12 @@ th:
button: เปิดดูการสนทนา
time_units:
days:
- one: '%{count} days'
other: '%{count} days'
hours:
- one: '%{count} hours'
other: '%{count} hours'
minutes:
- one: '%{count} minutes'
other: '%{count} minutes'
seconds:
- one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index c53f8de4c..1facb248f 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -435,16 +435,12 @@ vi:
button: Mở cuộc trò chuyện
time_units:
days:
- one: '%{count} days'
other: '%{count} days'
hours:
- one: '%{count} hours'
other: '%{count} hours'
minutes:
- one: '%{count} minutes'
other: '%{count} minutes'
seconds:
- one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml
index 0f6cd069f..ab8005c5e 100644
--- a/config/locales/zh_CN.yml
+++ b/config/locales/zh_CN.yml
@@ -435,16 +435,12 @@ zh_CN:
button: 重新打开会话
time_units:
days:
- one: '%{count} 天'
other: '%{count} 天'
hours:
- one: '%{count} 小时'
other: '%{count} 小时'
minutes:
- one: '%{count} 分钟'
other: '%{count} 分钟'
seconds:
- one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml
index 775bcf000..d7dd33efa 100644
--- a/config/locales/zh_TW.yml
+++ b/config/locales/zh_TW.yml
@@ -435,16 +435,12 @@ zh_TW:
button: '開啟對話'
time_units:
days:
- one: '%{count} 天'
other: '%{count} 天'
hours:
- one: '%{count} 小時'
other: '%{count} 小時'
minutes:
- one: '%{count} 分鐘'
other: '%{count} 分鐘'
seconds:
- one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: '預設策略'