diff --git a/.circleci/config.yml b/.circleci/config.yml index 24119bc75..ff82abd24 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ jobs: bundle install - node/install: - node-version: '23.7' + node-version: '24.12' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -117,7 +117,7 @@ jobs: steps: - checkout - node/install: - node-version: '23.7' + node-version: '24.12' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -148,7 +148,7 @@ jobs: steps: - checkout - node/install: - node-version: '23.7' + node-version: '24.12' - node/install-pnpm - node/install-packages: pkg-manager: pnpm diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml index 6932b5f10..b7db87097 100644 --- a/.devcontainer/docker-compose.base.yml +++ b/.devcontainer/docker-compose.base.yml @@ -10,7 +10,7 @@ services: dockerfile: .devcontainer/Dockerfile.base args: VARIANT: 'ubuntu-22.04' - NODE_VERSION: '23.7.0' + NODE_VERSION: '24.12.0' RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a9185ea09..3618b0681 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -11,7 +11,7 @@ services: dockerfile: .devcontainer/Dockerfile args: VARIANT: 'ubuntu-22.04' - NODE_VERSION: '23.7.0' + NODE_VERSION: '24.12.0' RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 45ff25203..1d1116d0c 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 011f862b0..c2a626388 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -28,7 +28,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -43,7 +43,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -94,7 +94,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index c2a4bd174..7869bf89c 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: pnpm diff --git a/.nvmrc b/.nvmrc index b88575e38..1e4f3920b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -23.7.0 \ No newline at end of file +24.12.0 \ No newline at end of file diff --git a/VERSION_CW b/VERSION_CW index 5b341fd79..dad10c76d 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.9.1 +4.9.2 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 6cb9d3dd0..1545d9665 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.4.3 +3.5.0 diff --git a/app/builders/v2/reports/channel_summary_builder.rb b/app/builders/v2/reports/channel_summary_builder.rb new file mode 100644 index 000000000..2df8fc081 --- /dev/null +++ b/app/builders/v2/reports/channel_summary_builder.rb @@ -0,0 +1,38 @@ +class V2::Reports::ChannelSummaryBuilder + include DateRangeHelper + + pattr_initialize [:account!, :params!] + + def build + conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) } + end + + private + + def conversations_by_channel_and_status + account.conversations + .joins(:inbox) + .where(created_at: range) + .group('inboxes.channel_type', 'conversations.status') + .count + .each_with_object({}) do |((channel_type, status), count), grouped| + grouped[channel_type] ||= {} + grouped[channel_type][status] = count + end + end + + def build_channel_stats(status_counts) + open_count = status_counts['open'] || 0 + resolved_count = status_counts['resolved'] || 0 + pending_count = status_counts['pending'] || 0 + snoozed_count = status_counts['snoozed'] || 0 + + { + open: open_count, + resolved: resolved_count, + pending: pending_count, + snoozed: snoozed_count, + total: open_count + resolved_count + pending_count + snoozed_count + } + end +end diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb new file mode 100644 index 000000000..156c031fa --- /dev/null +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -0,0 +1,76 @@ +class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController + before_action :current_account + before_action :authorize_account_update, only: [:update] + + def show + render json: preferences_payload + end + + def update + params_to_update = captain_params + @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models] + @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features] + @current_account.save! + + render json: preferences_payload + end + + private + + def preferences_payload + { + providers: Llm::Models.providers, + models: Llm::Models.models, + features: features_with_account_preferences + } + end + + def authorize_account_update + authorize @current_account, :update? + end + + def captain_params + permitted = {} + permitted[:captain_models] = merged_captain_models if params[:captain_models].present? + permitted[:captain_features] = merged_captain_features if params[:captain_features].present? + permitted + end + + def merged_captain_models + existing_models = @current_account.captain_models || {} + existing_models.merge(permitted_captain_models) + end + + def merged_captain_features + existing_features = @current_account.captain_features || {} + existing_features.merge(permitted_captain_features) + end + + def permitted_captain_models + params.require(:captain_models).permit( + :editor, :assistant, :copilot, :label_suggestion, + :audio_transcription, :help_center_search + ).to_h.stringify_keys + end + + def permitted_captain_features + params.require(:captain_features).permit( + :editor, :assistant, :copilot, :label_suggestion, + :audio_transcription, :help_center_search + ).to_h.stringify_keys + end + + def features_with_account_preferences + preferences = Current.account.captain_preferences + account_features = preferences[:features] || {} + account_models = preferences[:models] || {} + + Llm::Models.feature_keys.index_with do |feature_key| + config = Llm::Models.feature_config(feature_key) + config.merge( + enabled: account_features[feature_key] == true, + selected: account_models[feature_key] || config[:default] + ) + end + end +end diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb index d17fe35fb..bb5dab680 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -1,38 +1,27 @@ 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 + service = CsatTemplateManagementService.new(@inbox) + result = service.template_status - 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 + if result[:service_error] + render json: { error: result[:service_error] }, status: :internal_server_error + else + render json: result + end 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) + service = CsatTemplateManagementService.new(@inbox) + result = service.create_template(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 @@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC end def validate_whatsapp_channel - return if @inbox.whatsapp? + return if @inbox.whatsapp? || @inbox.twilio_whatsapp? - render json: { error: 'CSAT template operations only available for WhatsApp channels' }, + render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' }, status: :bad_request end @@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC 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) + elsif result[:service_error] + render json: { error: result[:service_error] }, status: :internal_server_error 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 + if @inbox.twilio_whatsapp? + render json: { + template: { + friendly_name: result[:friendly_name], + content_sid: result[:content_sid], + status: result[:status] || 'pending', + language: result[:language] || 'en' + } + }, status: :created + else + render json: { + template: { + name: result[:template_name], + template_id: result[:template_id], + status: 'PENDING', + language: result[:language] || 'en' + } + }, status: :created + end end def render_failed_template_creation(result) @@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC }, 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? diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 1c8845c04..322c7c7fe 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, { csat_config: [:display_type, :message, :button_text, :language, { survey_rules: [:operator, { values: [] }], - template: [:name, :template_id, :created_at, :language] }] }] + template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }] end def permitted_params(channel_attributes = []) diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 6e2d0ff4c..714aeb0c9 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController generate_csv('teams_report', 'api/v2/accounts/reports/teams') end + def conversations_summary + @report_data = generate_conversations_report + generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary') + end + def conversation_traffic @report_data = generate_conversations_heatmap_report timezone_offset = (params[:timezone_offset] || 0).to_f diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index f31a53c7e..98b3f05d7 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -1,6 +1,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] + before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::LabelSummaryBuilder) end + def channel + return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long? + + render_report_with(V2::Reports::ChannelSummaryBuilder) + end + private def check_authorization @@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr def permitted_params params.permit(:since, :until, :business_hours) end + + def date_range_too_long? + return false if permitted_params[:since].blank? || permitted_params[:until].blank? + + since_time = Time.zone.at(permitted_params[:since].to_i) + until_time = Time.zone.at(permitted_params[:until].to_i) + (until_time - since_time) > 6.months + end end diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 23694d08d..1f34d7e97 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper end end + def generate_conversations_report + builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account)) + summary = builder.summary + + [generate_conversation_report_metrics(summary)] + end + private def build_params(base_params) @@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper report[:resolved_conversations_count] ] end + + def generate_conversation_report_metrics(summary) + [ + summary[:conversations_count], + summary[:incoming_messages_count], + summary[:outgoing_messages_count], + Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format, + Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format, + summary[:resolutions_count], + Reports::TimeFormatPresenter.new(summary[:reply_time]).format + ] + end end diff --git a/app/javascript/dashboard/api/captain/preferences.js b/app/javascript/dashboard/api/captain/preferences.js new file mode 100644 index 000000000..f1ce30582 --- /dev/null +++ b/app/javascript/dashboard/api/captain/preferences.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainPreferences extends ApiClient { + constructor() { + super('captain/preferences', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + updatePreferences(data) { + return axios.put(this.url, data); + } +} + +export default new CaptainPreferences(); diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index c87dfc82b..00f040f8e 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient { }); } + getConversationsSummaryReports({ from: since, to: until, businessHours }) { + return axios.get(`${this.url}/conversations_summary`, { + params: { since, until, business_hours: businessHours }, + }); + } + getConversationTrafficCSV({ daysBefore = 6 } = {}) { return axios.get(`${this.url}/conversation_traffic`, { params: { timezone_offset: getTimeOffset(), days_before: daysBefore }, diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 90b7a0c31..c2cde6d17 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; const props = defineProps({ modelValue: { type: String, default: '' }, + editorKey: { type: String, default: '' }, label: { type: String, default: '' }, placeholder: { type: String, default: '' }, focusOnMount: { type: Boolean, default: false }, @@ -96,6 +97,7 @@ watch( ]" > { + attachmentId.value += 1; + return `attachment-${attachmentId.value}`; +}; + const uploadAttachment = ref(null); const isEmojiPickerOpen = ref(false); @@ -176,7 +182,8 @@ const onPaste = e => { .filter(file => file.size > 0) .forEach(file => { const { name, type, size } = file; - onFileUpload({ file, name, type, size }); + // Add unique ID for clipboard-pasted files + onFileUpload({ file, name, type, size, id: generateUid() }); }); }; diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 74836219b..82f87a08d 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -7,6 +7,7 @@ import { appendSignature, removeSignature, getEffectiveChannelType, + stripUnsupportedMarkdown, } from 'dashboard/helper/editorHelper'; import { buildContactableInboxesList, @@ -47,6 +48,8 @@ const emit = defineEmits([ 'createConversation', ]); +const DEFAULT_FORMATTING = 'Context::Default'; + const showContactsDropdown = ref(false); const showInboxesDropdown = ref(false); const showCcEmailsDropdown = ref(false); @@ -198,10 +201,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => { showInboxesDropdown.value = true; }; -const handleInboxAction = ({ value, action, ...rest }) => { +const stripMessageFormatting = channelType => { + if (!state.message || !channelType) return; + + state.message = stripUnsupportedMarkdown(state.message, channelType, false); +}; + +const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => { v$.value.$reset(); - state.message = ''; - emit('updateTargetInbox', { ...rest }); + + // Strip unsupported formatting when changing the target inbox + if (channelType) { + const newChannelType = getEffectiveChannelType(channelType, medium); + stripMessageFormatting(newChannelType); + } + + emit('updateTargetInbox', { ...rest, channelType, medium }); showInboxesDropdown.value = false; state.attachedFiles = []; }; @@ -221,7 +236,9 @@ const removeSignatureFromMessage = () => { const removeTargetInbox = value => { v$.value.$reset(); removeSignatureFromMessage(); - state.message = ''; + + stripMessageFormatting(DEFAULT_FORMATTING); + emit('updateTargetInbox', value); state.attachedFiles = []; }; @@ -324,67 +341,68 @@ const shouldShowMessageEditor = computed(() => {