Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,32 @@
class Public::Api::V1::CsatSurveyController < PublicController
before_action :set_conversation
before_action :set_message
def show; end
def update
render json: { error: 'You cannot update the CSAT survey after 14 days' }, status: :unprocessable_entity and return if check_csat_locked
@message.update!(message_update_params[:message])
end
private
def set_conversation
return if params[:id].blank?
@conversation = Conversation.find_by!(uuid: params[:id])
end
def set_message
@message = @conversation.messages.find_by!(content_type: 'input_csat')
end
def message_update_params
params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }] }])
end
def check_csat_locked
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14
end
end

View File

@@ -0,0 +1,48 @@
class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesController
before_action :contact_inbox, except: [:create]
before_action :process_hmac
def show; end
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: @inbox_channel.inbox,
contact_attributes: permitted_params.except(:identifier_hash)
).perform
end
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
render json: contact_identify_action.perform
end
private
def contact_inbox
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:id])
end
def process_hmac
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present?
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
end
def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end
end

View File

@@ -0,0 +1,67 @@
class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController
include Events::Types
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
def index
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
end
def show; end
def create
@conversation = create_conversation
end
def toggle_status
# Check if the conversation is already resolved to prevent redundant operations
return if @conversation.resolved?
# Assign the conversation's contact as the resolver
# This step attributes the resolution action to the contact involved in the conversation
# If this assignment is not made, the system implicitly becomes the resolver by default
Current.contact = @conversation.contact
# Update the conversation's status to 'resolved' to reflect its closure
@conversation.status = :resolved
@conversation.save!
end
def toggle_typing
case params[:typing_status]
when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON)
when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF)
end
head :ok
end
def update_last_seen
@conversation.contact_last_seen_at = DateTime.now.utc
@conversation.save!
::Conversations::UpdateMessageStatusJob.perform_later(@conversation.id, @conversation.contact_last_seen_at)
head :ok
end
private
def set_conversation
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:id])
else
@contact_inbox.conversations.find_by!(display_id: params[:id])
end
end
def create_conversation
ConversationBuilder.new(params: conversation_params, contact_inbox: @contact_inbox).perform
end
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: @conversation.contact)
end
def conversation_params
params.permit(custom_attributes: {})
end
end

View File

@@ -0,0 +1,73 @@
class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesController
before_action :set_message, only: [:update]
def index
@messages = @conversation.nil? ? [] : message_finder.perform
end
def create
@message = @conversation.messages.new(message_params)
build_attachment
@message.save!
end
def update
render json: { error: 'You cannot update the CSAT survey after 14 days' }, status: :unprocessable_entity and return if check_csat_locked
@message.update!(message_update_params)
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error
end
private
def build_attachment
return if params[:attachments].blank?
params[:attachments].each do |uploaded_attachment|
@message.attachments.new(
account_id: @message.account_id,
file_type: helpers.file_type(uploaded_attachment&.content_type),
file: uploaded_attachment
)
end
end
def message_finder_params
{
filter_internal_messages: true,
before: params[:before]
}
end
def message_finder
@message_finder ||= MessageFinder.new(@conversation, message_finder_params)
end
def message_update_params
params.permit(submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }])
end
def permitted_params
params.permit(:content, :echo_id)
end
def set_message
@message = @conversation.messages.find(params[:id])
end
def message_params
{
account_id: @conversation.account_id,
sender: @contact_inbox.contact,
content: permitted_params[:content],
inbox_id: @conversation.inbox_id,
echo_id: permitted_params[:echo_id],
message_type: :incoming
}
end
def check_csat_locked
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 and @message.content_type == 'input_csat'
end
end

View File

@@ -0,0 +1,29 @@
class Public::Api::V1::InboxesController < PublicController
before_action :set_inbox_channel
before_action :set_contact_inbox
before_action :set_conversation
def show
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:id])
end
private
def set_inbox_channel
return if params[:inbox_id].blank?
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
end
def set_contact_inbox
return if params[:contact_id].blank?
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:contact_id])
end
def set_conversation
return if params[:conversation_id].blank?
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
end
end

View File

@@ -0,0 +1,88 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
layout 'portal'
def index
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count
search_articles
order_by_sort_param
limit_results
end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private
def limit_results
return if list_params[:per_page].blank?
per_page = [list_params[:per_page].to_i, 100].min
per_page = 25 if per_page < 1
@articles = @articles.page(list_params[:page]).per(per_page)
end
def search_articles
@articles = @articles.search(list_params) if list_params.present?
end
def order_by_sort_param
@articles = if list_params[:sort].present? && list_params[:sort] == 'views'
@articles.order_by_views
else
@articles.order_by_position
end
end
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content)
end
def set_category
return if permitted_params[:category_slug].blank?
@category = @portal.categories.find_by!(
slug: permitted_params[:category_slug],
locale: permitted_params[:locale]
)
end
def list_params
params.permit(:query, :locale, :sort, :status, :page, :per_page)
end
def permitted_params
params.permit(:slug, :category_slug, :locale, :id, :article_slug)
end
def render_article_content(content)
ChatwootMarkdownRenderer.new(content).render_article
end
end
Public::Api::V1::Portals::ArticlesController.prepend_mod_with('Public::Api::V1::Portals::ArticlesController')

View File

@@ -0,0 +1,63 @@
class Public::Api::V1::Portals::BaseController < PublicController
include SwitchLocale
before_action :show_plain_layout
before_action :set_color_scheme
before_action :set_global_config
around_action :set_locale
after_action :allow_iframe_requests
private
def show_plain_layout
@is_plain_layout_enabled = params[:show_plain_layout] == 'true'
end
def set_color_scheme
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present?
yield
end
def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale])
I18n.with_locale(@locale, &)
end
def switch_locale_with_article(&)
article = Article.find_by(slug: params[:article_slug])
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
article_locale = if article.category.present?
article.category.locale
else
article.portal.default_locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
end
def allow_iframe_requests
response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled
end
def render_404
portal
render 'public/api/v1/portals/error/404', status: :not_found
end
def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME')
end
end

View File

@@ -0,0 +1,23 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, only: [:show]
layout 'portal'
def index
@categories = @portal.categories.order(position: :asc)
end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @category.name)
end
private
def set_category
@category = @portal.categories.find_by(locale: params[:locale], slug: params[:category_slug])
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank?
end
end

View File

@@ -0,0 +1,29 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
# if help_center_url does not contain a protocol, prepend it with https
@help_center_url = "https://#{@help_center_url}" unless @help_center_url.include?('://')
end
private
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
@locale = params[:locale] || @portal.default_locale
end
def redirect_to_portal_with_locale
return if params[:locale].present?
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end