chatwoot/app/models/article.rb
Alex ecd9c26c8c
feat: Implemented search results page functionality (#11086)
# Pull Request Template

## Description
Implemented search results page functionality. Now you can press "Enter"
to search by term and display results in a results page. Also now you
can link to /hc/{account}/en/search?query=XXXXXX to view search results
for XXXXXX query.

fixes: https://github.com/chatwoot/chatwoot/issues/10945

## Screenshots

Classic layout search results:
<img width="3840" height="2160" alt="classic-results"
src="https://github.com/user-attachments/assets/3bbb3272-33ca-4eb4-b80a-76ed77442088"
/>

Classic layout pagination:
<img width="3840" height="2160" alt="classic-page-two"
src="https://github.com/user-attachments/assets/062b09d3-7c58-4d3b-8611-b94375e7db51"
/>

Classic layout empty search:
<img width="3840" height="2160" alt="no-results"
src="https://github.com/user-attachments/assets/c5e3f47a-cd9a-4e14-ae92-ccba00c89e98"
/>

Documentation layout search results:
<img width="3840" height="2160" alt="documentation-results"
src="https://github.com/user-attachments/assets/9e45d8d9-c975-4589-b6c6-3bc7bb3c588e"
/>

Documentation layout dark theme:
<img width="3840" height="2160" alt="documentation-dark"
src="https://github.com/user-attachments/assets/cdb6ed63-4241-4b32-9f79-7d92ed479fc8"
/>

Plain embedded dark layout:
<img width="3840" height="2160" alt="plain-embedded-dark"
src="https://github.com/user-attachments/assets/7deb02b9-9f24-48fb-8979-a2ecd7002c05"
/>

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
2026-06-02 15:19:23 +05:30

202 lines
6.9 KiB
Ruby

# == Schema Information
#
# Table name: articles
#
# id :bigint not null, primary key
# content :text
# description :text
# locale :string default("en"), not null
# meta :jsonb
# position :integer
# slug :string not null
# status :integer
# title :string
# views :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# associated_article_id :bigint
# author_id :bigint
# category_id :integer
# folder_id :integer
# portal_id :integer not null
#
# Indexes
#
# index_articles_on_account_id (account_id)
# index_articles_on_associated_article_id (associated_article_id)
# index_articles_on_author_id (author_id)
# index_articles_on_portal_id (portal_id)
# index_articles_on_slug (slug) UNIQUE
# index_articles_on_status (status)
# index_articles_on_views (views)
#
class Article < ApplicationRecord
include PgSearch::Model
include LlmFormattable
has_many :associated_articles,
class_name: :Article,
foreign_key: :associated_article_id,
dependent: :nullify,
inverse_of: 'root_article'
belongs_to :root_article,
class_name: :Article,
foreign_key: :associated_article_id,
inverse_of: :associated_articles,
optional: true
belongs_to :account
belongs_to :category, optional: true
belongs_to :portal
belongs_to :author, class_name: 'User', inverse_of: :articles
before_validation :ensure_account_id
before_validation :ensure_article_slug
before_validation :ensure_locale_in_article
# Slugs that collide with help center routes (e.g. /hc/:slug/:locale/search)
RESERVED_SLUGS = %w[search articles categories].freeze
validates :account_id, presence: true
validates :author_id, presence: true
validates :title, presence: true
validates :content, presence: true, if: :published?
validates :slug, exclusion: { in: RESERVED_SLUGS }
# ensuring that the position is always set correctly
before_create :add_position_to_article
after_save :category_id_changed_action, if: :saved_change_to_category_id?
enum status: { draft: 0, published: 1, archived: 2 }
scope :search_by_category_slug, ->(category_slug) { where(categories: { slug: category_slug }) if category_slug.present? }
scope :search_by_category_locale, ->(locale) { where(categories: { locale: locale }) if locale.present? }
scope :search_by_locale, ->(locale) { where(locale: locale) if locale.present? }
scope :search_by_author, ->(author_id) { where(author_id: author_id) if author_id.present? }
scope :search_by_status, ->(status) { where(status: status) if status.present? }
scope :order_by_updated_at, -> { reorder(updated_at: :desc) }
scope :order_by_position, -> { reorder(position: :asc) }
scope :order_by_views, -> { reorder(views: :desc) }
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
# - the A, B and C are for weightage. See: https://github.com/Casecommons/pg_search#weighting
# - the normalization is for ensuring the long articles that mention the search term too many times are not ranked higher.
# it divides rank by log(document_length) to prevent longer articles from ranking higher just due to sizeSee: https://github.com/Casecommons/pg_search#normalization
# - the ranking is to ensure that articles with higher weightage are ranked higher
pg_search_scope(
:text_search,
against: {
title: 'A',
description: 'B',
content: 'C'
},
using: {
tsearch: {
prefix: true,
normalization: 2
}
},
ranked_by: ':tsearch'
)
def self.search(params)
records = left_outer_joins(
:category
).search_by_category_slug(
params[:category_slug]
).search_by_locale(params[:locale]).search_by_author(params[:author_id]).search_by_status(params[:status])
records = records.text_search(params[:query]) if params[:query].present?
records
end
def associate_root_article(associated_article_id)
article = portal.articles.find(associated_article_id) if associated_article_id.present?
return if article.nil?
root_article_id = self.class.find_root_article_id(article)
update(associated_article_id: root_article_id) if root_article_id.present?
end
# Make sure we always associate the parent's associated id to avoid the deeper associations od articles.
def self.find_root_article_id(article)
article.associated_article_id || article.id
end
def draft!
update(status: :draft)
end
def increment_view_count
# rubocop:disable Rails/SkipsModelValidations
update_column(:views, views? ? views + 1 : 1)
# rubocop:enable Rails/SkipsModelValidations
end
def self.update_positions(portal:, positions_hash:)
return if positions_hash.blank?
transaction do
positions_hash.each do |article_id, new_position|
portal.articles.find(article_id).update!(position: new_position)
end
end
end
private
def category_id_changed_action
# We need to update the position of the article in the new category
return unless persisted?
# this means the article is just created
# and the category_id is newly set
# and the position is already present
return if created_at_before_last_save.nil? && position.present? && category_id_before_last_save.nil?
update_article_position_in_category
end
def ensure_locale_in_article
self.locale = if category.present?
category.locale
else
locale.presence || portal.default_locale
end
end
def add_position_to_article
# on creation if a position is already present, ignore it
return if position.present?
update_article_position_in_category
end
def update_article_position_in_category
max_position = Article.where(category_id: category_id, account_id: account_id).maximum(:position)
new_position = max_position.present? ? max_position + 10 : 10
# update column to avoid validations if the article is already persisted
if persisted?
# rubocop:disable Rails/SkipsModelValidations
update_column(:position, new_position)
# rubocop:enable Rails/SkipsModelValidations
else
self.position = new_position
end
end
def ensure_account_id
self.account_id = portal&.account_id
end
def ensure_article_slug
self.slug ||= "#{Time.now.utc.to_i}-#{title.underscore.parameterize(separator: '-')}" if title.present?
end
end
Article.include_mod_with('Concerns::Article')