diff --git a/Gemfile b/Gemfile index 46b11ef1d..1ae6cf093 100644 --- a/Gemfile +++ b/Gemfile @@ -215,7 +215,7 @@ group :production do end group :development do - gem 'annotate' + gem 'annotaterb' gem 'bullet' gem 'letter_opener' gem 'scss_lint', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 55cfdef7e..15ed841ac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -128,9 +128,9 @@ GEM selectize-rails (~> 0.6) ai-agents (0.7.0) ruby_llm (~> 1.8.2) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) + annotaterb (4.20.0) + activerecord (>= 6.0.0) + activesupport (>= 6.0.0) ast (2.4.3) attr_extras (7.1.0) audited (5.4.1) @@ -827,6 +827,7 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) ruby_llm-schema (0.2.5) ruby_parser (3.20.0) @@ -1018,7 +1019,7 @@ DEPENDENCIES administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) ai-agents (>= 0.7.0) - annotate + annotaterb attr_extras audited (~> 5.4, >= 5.4.1) aws-actionmailbox-ses (~> 0) diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb new file mode 100644 index 000000000..d17fe35fb --- /dev/null +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -0,0 +1,160 @@ +class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController + DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze + DEFAULT_LANGUAGE = 'en'.freeze + + before_action :fetch_inbox + before_action :validate_whatsapp_channel + + def show + template = @inbox.csat_config&.dig('template') + return render json: { template_exists: false } unless template + + template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + status_result = @inbox.channel.provider_service.get_template_status(template_name) + + render_template_status_response(status_result, template_name) + rescue StandardError => e + Rails.logger.error "Error fetching CSAT template status: #{e.message}" + render json: { error: e.message }, status: :internal_server_error + end + + def create + template_params = extract_template_params + return render_missing_message_error if template_params[:message].blank? + + # Delete existing template even though we are using a new one. + # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails. + delete_existing_template_if_needed + + result = create_template_via_provider(template_params) + render_template_creation_result(result) + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "Error creating CSAT template: #{e.message}" + render json: { error: 'Template creation failed' }, status: :internal_server_error + end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :show? + end + + def validate_whatsapp_channel + return if @inbox.whatsapp? + + render json: { error: 'CSAT template operations only available for WhatsApp channels' }, + status: :bad_request + end + + def extract_template_params + params.require(:template).permit(:message, :button_text, :language) + end + + def render_missing_message_error + render json: { error: 'Message is required' }, status: :unprocessable_entity + end + + def create_template_via_provider(template_params) + template_config = { + message: template_params[:message], + button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT, + base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'), + language: template_params[:language] || DEFAULT_LANGUAGE, + template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + } + + @inbox.channel.provider_service.create_csat_template(template_config) + end + + def render_template_creation_result(result) + if result[:success] + render_successful_template_creation(result) + else + render_failed_template_creation(result) + end + end + + def render_successful_template_creation(result) + render json: { + template: { + name: result[:template_name], + template_id: result[:template_id], + status: 'PENDING', + language: result[:language] || DEFAULT_LANGUAGE + } + }, status: :created + end + + def render_failed_template_creation(result) + whatsapp_error = parse_whatsapp_error(result[:response_body]) + error_message = whatsapp_error[:user_message] || result[:error] + + render json: { + error: error_message, + details: whatsapp_error[:technical_details] + }, status: :unprocessable_entity + end + + def delete_existing_template_if_needed + template = @inbox.csat_config&.dig('template') + return true if template.blank? + + template_name = template['name'] + return true if template_name.blank? + + template_status = @inbox.channel.provider_service.get_template_status(template_name) + return true unless template_status[:success] + + deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name) + if deletion_result[:success] + Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}" + true + else + Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}" + false + end + rescue StandardError => e + Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}" + false + end + + def render_template_status_response(status_result, template_name) + if status_result[:success] + render json: { + template_exists: true, + template_name: template_name, + status: status_result[:template][:status], + template_id: status_result[:template][:id] + } + else + render json: { + template_exists: false, + error: 'Template not found' + } + end + end + + def parse_whatsapp_error(response_body) + return { user_message: nil, technical_details: nil } if response_body.blank? + + begin + error_data = JSON.parse(response_body) + whatsapp_error = error_data['error'] || {} + + user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message'] + technical_details = { + code: whatsapp_error['code'], + subcode: whatsapp_error['error_subcode'], + type: whatsapp_error['type'], + title: whatsapp_error['error_user_title'] + }.compact + + { user_message: user_message, technical_details: technical_details } + rescue JSON::ParserError + { user_message: nil, technical_details: response_body } + end + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index ae1d4369a..1c8845c04 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def format_csat_config(config) - { - display_type: config['display_type'] || 'emoji', - message: config['message'] || '', - survey_rules: { - operator: config.dig('survey_rules', 'operator') || 'contains', - values: config.dig('survey_rules', 'values') || [] - } + formatted = { + 'display_type' => config['display_type'] || 'emoji', + 'message' => config['message'] || '', + :survey_rules => { + 'operator' => config.dig('survey_rules', 'operator') || 'contains', + 'values' => config.dig('survey_rules', 'values') || [] + }, + 'button_text' => config['button_text'] || 'Please rate us', + 'language' => config['language'] || 'en' } + format_template_config(config, formatted) + formatted + end + + def format_template_config(config, formatted) + formatted['template'] = config['template'] if config['template'].present? end def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, - { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }] + { csat_config: [:display_type, :message, :button_text, :language, + { survey_rules: [:operator, { values: [] }], + template: [:name, :template_id, :created_at, :language] }] }] end def permitted_params(channel_attributes = []) # We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend params.each { |k, v| params[k] = params[k] == 'null' ? nil : v } - - params.permit( - *inbox_attributes, - channel: [:type, *channel_attributes] - ) + params.permit(*inbox_attributes, channel: [:type, *channel_attributes]) end def channel_type_from_params @@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def get_channel_attributes(channel_type) - if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) - channel_type.constantize::EDITABLE_ATTRS.presence - else - [] - end + channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] end def whatsapp_channel? diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 67936fa59..90b7a0c31 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -19,12 +19,12 @@ const props = defineProps({ }, enableVariables: { type: Boolean, default: false }, enableCannedResponses: { type: Boolean, default: true }, - enabledMenuOptions: { type: Array, default: () => [] }, enableCaptainTools: { type: Boolean, default: false }, signature: { type: String, default: '' }, allowSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false }, channelType: { type: String, default: '' }, + medium: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -102,12 +102,12 @@ watch( :disabled="disabled" :enable-variables="enableVariables" :enable-canned-responses="enableCannedResponses" - :enabled-menu-options="enabledMenuOptions" :enable-captain-tools="enableCaptainTools" :signature="signature" :allow-signature="allowSignature" :send-with-signature="sendWithSignature" :channel-type="channelType" + :medium="medium" @input="handleInput" @focus="handleFocus" @blur="handleBlur" @@ -139,19 +139,6 @@ watch( .editor-wrapper { ::v-deep { .ProseMirror-menubar-wrapper { - @apply gap-2 !important; - - .ProseMirror-menubar { - @apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important; - - .ProseMirror-menuitem { - @apply h-5 !important; - } - - .ProseMirror-icon { - @apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important; - } - } .ProseMirror.ProseMirror-woot-style { p { @apply first:mt-0 !important; 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 7beff200e..4c4d95f0c 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -172,7 +172,7 @@ const previewArticle = () => { @apply mr-0; .ProseMirror-icon { - @apply p-0 mt-1 !mr-0; + @apply p-0 mt-0 !mr-0; svg { width: 20px !important; diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 92c5850de..cb1f9d99d 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components'; import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import FileUpload from 'vue-upload-component'; -import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; import Button from 'dashboard/components-next/button/Button.vue'; import WhatsAppOptions from './WhatsAppOptions.vue'; @@ -50,12 +50,6 @@ const EmojiInput = defineAsyncComponent( () => import('shared/components/emoji/EmojiInput.vue') ); -const signatureToApply = computed(() => - props.isEmailOrWebWidgetInbox - ? props.messageSignature - : extractTextFromMarkdown(props.messageSignature) -); - const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox, @@ -80,12 +74,20 @@ const isRegularMessageMode = computed(() => { return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; }); +const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE); + +const shouldShowSignatureButton = computed(() => { + return ( + props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value + ); +}); + const setSignature = () => { - if (signatureToApply.value) { + if (props.messageSignature) { if (sendWithSignature.value) { - emit('addSignature', signatureToApply.value); + emit('addSignature', props.messageSignature); } else { - emit('removeSignature', signatureToApply.value); + emit('removeSignature', props.messageSignature); } } }; @@ -101,7 +103,7 @@ watch( () => props.hasSelectedInbox, newValue => { nextTick(() => { - if (newValue && props.isEmailOrWebWidgetInbox) setSignature(); + if (newValue && !isVoiceInbox.value) setSignature(); }); }, { immediate: true } @@ -220,7 +222,7 @@ useKeyboardEvents(keyboardEvents); />