diff --git a/.circleci/config.yml b/.circleci/config.yml index 804c63857..99ac1c29a 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.13' - 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.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -144,11 +144,11 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 16 + parallelism: 18 steps: - checkout - node/install: - node-version: '23.7' + node-version: '24.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -218,6 +218,49 @@ jobs: source ~/.rvm/scripts/rvm bundle install + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -234,6 +277,7 @@ jobs: sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" # Database setup - run: @@ -306,12 +350,12 @@ jobs: destination: coverage build: - <<: *defaults - steps: - - run: - name: Legacy build aggregator - command: | - echo "All main jobs passed; build job kept only for GitHub required check compatibility." + <<: *defaults + steps: + - run: + name: Legacy build aggregator + command: | + echo "All main jobs passed; build job kept only for GitHub required check compatibility." workflows: version: 2 diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml index 6932b5f10..375742ff7 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.13.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..d696f99cc 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.13.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/.env.example b/.env.example index d5c7a76f9..bc7380a29 100644 --- a/.env.example +++ b/.env.example @@ -274,3 +274,6 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# REDIS_ALFRED_SIZE=10 +# REDIS_VELMA_SIZE=10 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..cf2efde81 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -23.7.0 \ No newline at end of file +24.13.0 \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 0a669b606..b7b7301d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -441,7 +441,8 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.21.0) + httparty (0.24.0) + csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -540,9 +541,9 @@ GEM net-smtp marcel (1.0.4) maxminddb (0.1.22) - meta_request (0.8.3) + meta_request (0.8.5) rack-contrib (>= 1.1, < 3) - railties (>= 3.0.0, < 8) + railties (>= 3.0.0, < 9) method_source (1.1.0) mime-types (3.4.1) mime-types-data (~> 3.2015) @@ -555,7 +556,8 @@ GEM ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.6.0) + multi_xml (0.8.0) + bigdecimal (>= 3.1, < 5) multipart-post (2.3.0) mutex_m (0.3.0) neighbor (0.2.3) diff --git a/README.md b/README.md index 21316b422..d8b8ae7a2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ ___ The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.

- Maintainability CircleCI Badge Docker Pull Badge Docker Build Badge @@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri -*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License. +*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License. diff --git a/VERSION_CW b/VERSION_CW index 88f181192..ad96464c4 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.8.0 +4.10.1 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/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 8b40ba3c9..8045e84c9 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil return if story_reply_attributes.blank? @message.save_story_info(story_reply_attributes) + create_story_reply_attachment(story_reply_attributes['url']) + end + + def create_story_reply_attachment(story_url) + return if story_url.blank? + + attachment = @message.attachments.new( + file_type: :ig_story, + account_id: @message.account_id, + external_url: story_url + ) + attachment.save! + begin + attach_file(attachment, story_url) + rescue Down::Error, StandardError => e + Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}" + end + @message.content_attributes[:image_type] = 'ig_story_reply' + @message.save! end def build_conversation @@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: message_type, + status: @outgoing_echo ? :delivered : :sent, source_id: message_identifier, content: message_content, sender: @outgoing_echo ? nil : contact, @@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil } } + params[:content_attributes][:external_echo] = true if @outgoing_echo params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params end 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/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb new file mode 100644 index 000000000..971542596 --- /dev/null +++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb @@ -0,0 +1,68 @@ +class V2::Reports::FirstResponseTimeDistributionBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + build_distribution + end + + private + + def build_distribution + results = fetch_aggregated_counts + map_to_channel_types(results) + end + + def fetch_aggregated_counts + ReportingEvent + .where(account_id: account.id, name: 'first_response') + .where(range_condition) + .group(:inbox_id) + .select( + :inbox_id, + bucket_case_statements + ) + end + + def bucket_case_statements + <<~SQL.squish + COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h, + COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h, + COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h, + COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h, + COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus + SQL + end + + def range_condition + range.present? ? { created_at: range } : {} + end + + def inbox_channel_types + @inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h + end + + def map_to_channel_types(results) + results.each_with_object({}) do |row, hash| + channel_type = inbox_channel_types[row.inbox_id] + next unless channel_type + + hash[channel_type] ||= empty_buckets + hash[channel_type]['0-1h'] += row.bucket_0_1h + hash[channel_type]['1-4h'] += row.bucket_1_4h + hash[channel_type]['4-8h'] += row.bucket_4_8h + hash[channel_type]['8-24h'] += row.bucket_8_24h + hash[channel_type]['24h+'] += row.bucket_24h_plus + end + end + + def empty_buckets + { '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 } + end +end diff --git a/app/builders/v2/reports/inbox_label_matrix_builder.rb b/app/builders/v2/reports/inbox_label_matrix_builder.rb new file mode 100644 index 000000000..c3715019d --- /dev/null +++ b/app/builders/v2/reports/inbox_label_matrix_builder.rb @@ -0,0 +1,65 @@ +class V2::Reports::InboxLabelMatrixBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + { + inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } }, + labels: filtered_labels.map { |label| { id: label.id, title: label.title } }, + matrix: build_matrix + } + end + + private + + def filtered_inboxes + @filtered_inboxes ||= begin + inboxes = account.inboxes + inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present? + inboxes.order(:name).to_a + end + end + + def filtered_labels + @filtered_labels ||= begin + labels = account.labels + labels = labels.where(id: params[:label_ids]) if params[:label_ids].present? + labels.order(:title).to_a + end + end + + def conversation_filter + filter = { account_id: account.id } + filter[:created_at] = range if range.present? + filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present? + filter + end + + def fetch_grouped_counts + label_names = filtered_labels.map(&:title) + return {} if label_names.empty? + + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter) + .where(tags: { name: label_names }) + .group('conversations.inbox_id', 'tags.name') + .count + end + + def build_matrix + counts = fetch_grouped_counts + filtered_inboxes.map do |inbox| + filtered_labels.map do |label| + counts[[inbox.id, label.title]] || 0 + end + end + end +end diff --git a/app/builders/v2/reports/outgoing_messages_count_builder.rb b/app/builders/v2/reports/outgoing_messages_count_builder.rb new file mode 100644 index 000000000..ac0de59f2 --- /dev/null +++ b/app/builders/v2/reports/outgoing_messages_count_builder.rb @@ -0,0 +1,79 @@ +class V2::Reports::OutgoingMessagesCountBuilder + include DateRangeHelper + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def build + send("build_by_#{params[:group_by]}") + end + + private + + def base_messages + account.messages.outgoing.unscope(:order).where(created_at: range) + end + + def build_by_agent + counts = base_messages + .where(sender_type: 'User') + .where.not(sender_id: nil) + .group(:sender_id) + .count + + user_names = account.users.where(id: counts.keys).index_by(&:id) + + counts.map do |user_id, count| + user = user_names[user_id] + { id: user_id, name: user&.name, outgoing_messages_count: count } + end + end + + def build_by_team + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .where.not(conversations: { team_id: nil }) + .group('conversations.team_id') + .count + + team_names = account.teams.where(id: counts.keys).index_by(&:id) + + counts.map do |team_id, count| + team = team_names[team_id] + { id: team_id, name: team&.name, outgoing_messages_count: count } + end + end + + def build_by_inbox + counts = base_messages + .group(:inbox_id) + .count + + inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id) + + counts.map do |inbox_id, count| + inbox = inbox_names[inbox_id] + { id: inbox_id, name: inbox&.name, outgoing_messages_count: count } + end + end + + def build_by_label + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id + AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'") + .joins('INNER JOIN tags ON tags.id = taggings.tag_id') + .group('tags.name') + .count + + label_ids = account.labels.where(title: counts.keys).index_by(&:title) + + counts.map do |label_name, count| + label = label_ids[label_name] + { id: label&.id, name: label_name, outgoing_messages_count: count } + end + 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/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index e6270c807..14d4f2c89 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -24,13 +24,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def search render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return - contacts = resolved_contacts.where( - 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search - OR contacts.additional_attributes->>\'company_name\' ILIKE :search', + contacts = Current.account.contacts.where( + 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', search: "%#{params[:q].strip}%" ) - @contacts = fetch_contacts(contacts) - @contacts_count = @contacts.total_count + @contacts = fetch_contacts_with_has_more(contacts) end def import @@ -143,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController .per(RESULTS_PER_PAGE) end + def fetch_contacts_with_has_more(contacts) + includes_hash = { avatar_attachment: [:blob] } + includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes + + # Calculate offset manually to fetch one extra record for has_more check + offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE + results = filtrate(contacts) + .includes(includes_hash) + .offset(offset) + .limit(RESULTS_PER_PAGE + 1) + .to_a + + @has_more = results.size > RESULTS_PER_PAGE + results = results.first(RESULTS_PER_PAGE) if @has_more + @contacts_count = results.size + results + end + def build_contact_inbox return if params[:inbox_id].blank? diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 4301eaa4a..b3151c8fa 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def transcript render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? + return head :too_many_requests unless @conversation.account.within_email_rate_limit? ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later + @conversation.account.increment_email_sent_count head :ok end @@ -110,6 +112,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def update_last_seen + # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. + # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. + # Always update immediately if there are unread messages to maintain accurate read/unread state. + return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? + return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? + + # No unread messages - apply throttling to limit DB writes + return unless should_update_last_seen? + update_last_seen_on_conversation(DateTime.now.utc, assignee?) end @@ -142,12 +153,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def update_last_seen_on_conversation(last_seen_at, update_assignee) + updates = { agent_last_seen_at: last_seen_at } + updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present? + # rubocop:disable Rails/SkipsModelValidations - @conversation.update_column(:agent_last_seen_at, last_seen_at) - @conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present? + @conversation.update_columns(updates) # rubocop:enable Rails/SkipsModelValidations end + def should_update_last_seen? + # Update if at least one relevant timestamp is older than 1 hour or not set + # This prevents redundant DB writes when agents repeatedly view the same conversation + agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago + return agent_needs_update unless assignee? + + # For assignees, check both timestamps - update if either is old + assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago + agent_needs_update || assignee_needs_update + end + def set_conversation_status @conversation.status = params[:status] @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb index f5bed6c34..0cde5f5c1 100644 --- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb +++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb @@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base @current_page = params[:page] || 1 end end + +Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController') 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/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 13e3a6a6c..7ee25e02e 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController search_type: search_type, params: params ).perform + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity end end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 57062a5b2..bcbf80355 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label, - conversation_required_attributes: []) + params.permit(*permitted_settings_attributes) + end + + def permitted_settings_attributes + [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label] end def check_signup_enabled @@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController } end end + +Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings') diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index fe5facc1a..96c15fde2 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def transcript - if conversation.present? && conversation.contact.present? && conversation.contact.email.present? - ConversationReplyMailer.with(account: conversation.account).conversation_transcript( - conversation, - conversation.contact.email - )&.deliver_later - end + return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit? + + send_transcript_email head :ok end @@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController private + def send_transcript_email + return if conversation.contact&.email.blank? + + ConversationReplyMailer.with(account: conversation.account).conversation_transcript( + conversation, + conversation.contact.email + )&.deliver_later + conversation.account.increment_email_sent_count + end + def trigger_typing_event(event) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact) end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 6e2d0ff4c..192b3619c 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 @@ -57,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: bot_metrics end + def inbox_label_matrix + builder = V2::Reports::InboxLabelMatrixBuilder.new( + account: Current.account, + params: inbox_label_matrix_params + ) + render json: builder.build + end + + def first_response_time_distribution + builder = V2::Reports::FirstResponseTimeDistributionBuilder.new( + account: Current.account, + params: first_response_time_distribution_params + ) + render json: builder.build + end + + OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze + + def outgoing_messages_count + return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by]) + + builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params) + render json: builder.build + end + private def generate_csv(filename, template) @@ -134,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController def conversation_metrics V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics end + + def inbox_label_matrix_params + { + since: params[:since], + until: params[:until], + inbox_ids: params[:inbox_ids], + label_ids: params[:label_ids] + } + end + + def first_response_time_distribution_params + { + since: params[:since], + until: params[:until] + } + end + + def outgoing_messages_count_params + { + group_by: params[:group_by], + since: params[:since], + until: params[:until] + } + end end 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/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index abf42517c..d57ad0e53 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -16,7 +16,7 @@ class DashboardController < ActionController::Base CHATWOOT_INBOX_TOKEN API_CHANNEL_NAME API_CHANNEL_THUMBNAIL - ANALYTICS_TOKEN + CLOUD_ANALYTICS_TOKEN DIRECT_UPLOADS_ENABLED MAXIMUM_FILE_UPLOAD_SIZE HCAPTCHA_SITE_KEY diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 000000000..fdf969a39 --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,7 @@ +# Inherits from ActionController::Base to skip all middleware, +# authentication, and callbacks. Used for health checks +class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController + def show + render json: { status: 'woot' } + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index ec51305b5..67d58aef1 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -42,19 +42,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], - 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], + 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], - 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET], + 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], - 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] + 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN], + 'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT] } @allowed_configs = mapping.fetch( @config, - %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE] + %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY] ) 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/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index 34c7a8138..f21a97f78 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -2,13 +2,6 @@ # No need to replicate the same values in two places # ------- Premium Features ------- # -captain: - name: 'Captain' - description: 'Enable AI-powered conversations with your customers.' - enabled: <%= (ChatwootHub.pricing_plan != 'community') %> - icon: 'icon-captain' - config_key: 'captain' - enterprise: true saml: name: 'SAML SSO' description: 'Configuration for controlling SAML Single Sign-On availability' @@ -48,6 +41,12 @@ help_center: description: 'Allow agents to create help center articles and publish them in a portal.' enabled: true icon: 'icon-book-2-line' +captain: + name: 'Captain' + description: 'Enable AI-powered conversations with your customers.' + enabled: true + icon: 'icon-captain' + config_key: 'captain' # ------- Communication Channels ------- # live_chat: diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index a63cb1a90..61d7631e7 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -131,7 +131,7 @@ export default {

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/captain/tasks.js b/app/javascript/dashboard/api/captain/tasks.js new file mode 100644 index 000000000..1b5a38335 --- /dev/null +++ b/app/javascript/dashboard/api/captain/tasks.js @@ -0,0 +1,107 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +/** + * A client for the Captain Tasks API. + * @extends ApiClient + */ +class TasksAPI extends ApiClient { + /** + * Creates a new TasksAPI instance. + */ + constructor() { + super('captain/tasks', { accountScoped: true }); + } + + /** + * Rewrites content with a specific operation. + * @param {Object} options - The rewrite options. + * @param {string} options.content - The content to rewrite. + * @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc). + * @param {string} [options.conversationId] - The conversation ID for context (required for 'improve'). + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the rewritten content. + */ + rewrite({ content, operation, conversationId }, signal) { + return axios.post( + `${this.url}/rewrite`, + { + content, + operation, + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Summarizes a conversation. + * @param {string} conversationId - The conversation ID to summarize. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the summary. + */ + summarize(conversationId, signal) { + return axios.post( + `${this.url}/summarize`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets a reply suggestion for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the reply suggestion. + */ + replySuggestion(conversationId, signal) { + return axios.post( + `${this.url}/reply_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets label suggestions for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with label suggestions. + */ + labelSuggestion(conversationId, signal) { + return axios.post( + `${this.url}/label_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Sends a follow-up message to continue refining a previous task result. + * @param {Object} options - The follow-up options. + * @param {Object} options.followUpContext - The follow-up context from a previous task. + * @param {string} options.message - The follow-up message/request from the user. + * @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context. + */ + followUp({ followUpContext, message, conversationId }, signal) { + return axios.post( + `${this.url}/follow_up`, + { + follow_up_context: followUpContext, + message, + conversation_display_id: conversationId, + }, + { signal } + ); + } +} + +export default new TasksAPI(); diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 2e940e876..88a328c86 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -38,6 +38,16 @@ class Inboxes extends BaseClass { syncTemplates(inboxId) { return axios.post(`${this.url}/${inboxId}/sync_templates`); } + + createCSATTemplate(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template`, { + template, + }); + } + + getCSATTemplateStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/csat_template`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js deleted file mode 100644 index 3fcf241ee..000000000 --- a/app/javascript/dashboard/api/integrations/openapi.js +++ /dev/null @@ -1,81 +0,0 @@ -/* global axios */ - -import ApiClient from '../ApiClient'; - -/** - * Represents the data object for a OpenAI hook. - * @typedef {Object} ConversationMessageData - * @property {string} [tone] - The tone of the message. - * @property {string} [content] - The content of the message. - * @property {string} [conversation_display_id] - The display ID of the conversation (optional). - */ - -/** - * A client for the OpenAI API. - * @extends ApiClient - */ -class OpenAIAPI extends ApiClient { - /** - * Creates a new OpenAIAPI instance. - */ - constructor() { - super('integrations', { accountScoped: true }); - - /** - * The conversation events supported by the API. - * @type {string[]} - */ - this.conversation_events = [ - 'summarize', - 'reply_suggestion', - 'label_suggestion', - ]; - - /** - * The message events supported by the API. - * @type {string[]} - */ - this.message_events = ['rephrase']; - } - - /** - * Processes an event using the OpenAI API. - * @param {Object} options - The options for the event. - * @param {string} [options.type='rephrase'] - The type of event to process. - * @param {string} [options.content] - The content of the event. - * @param {string} [options.tone] - The tone of the event. - * @param {string} [options.conversationId] - The ID of the conversation to process the event for. - * @param {string} options.hookId - The ID of the hook to use for processing the event. - * @returns {Promise} A promise that resolves with the result of the event processing. - */ - processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) { - /** - * @type {ConversationMessageData} - */ - let data = { - tone, - content, - }; - - // Always include conversation_display_id when available for session tracking - if (conversationId) { - data.conversation_display_id = conversationId; - } - - // For conversation-level events, only send conversation_display_id - if (this.conversation_events.includes(type)) { - data = { - conversation_display_id: conversationId, - }; - } - - return axios.post(`${this.url}/hooks/${hookId}/process_event`, { - event: { - name: type, - data, - }, - }); - } -} - -export default new OpenAIAPI(); 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/api/search.js b/app/javascript/dashboard/api/search.js index d533c2f28..10214f3f5 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -14,38 +14,48 @@ class SearchAPI extends ApiClient { }); } - contacts({ q, page = 1 }) { + contacts({ q, page = 1, since, until }) { return axios.get(`${this.url}/contacts`, { params: { q, page: page, + since, + until, }, }); } - conversations({ q, page = 1 }) { + conversations({ q, page = 1, since, until }) { return axios.get(`${this.url}/conversations`, { params: { q, page: page, + since, + until, }, }); } - messages({ q, page = 1 }) { + messages({ q, page = 1, since, until, from, inboxId }) { return axios.get(`${this.url}/messages`, { params: { q, page: page, + since, + until, + from, + inbox_id: inboxId, }, }); } - articles({ q, page = 1 }) { + articles({ q, page = 1, since, until }) { return axios.get(`${this.url}/articles`, { params: { q, page: page, + since, + until, }, }); } diff --git a/app/javascript/dashboard/api/specs/search.spec.js b/app/javascript/dashboard/api/specs/search.spec.js new file mode 100644 index 000000000..251ea760e --- /dev/null +++ b/app/javascript/dashboard/api/specs/search.spec.js @@ -0,0 +1,134 @@ +import searchAPI from '../search'; +import ApiClient from '../ApiClient'; + +describe('#SearchAPI', () => { + it('creates correct instance', () => { + expect(searchAPI).toBeInstanceOf(ApiClient); + expect(searchAPI).toHaveProperty('get'); + expect(searchAPI).toHaveProperty('contacts'); + expect(searchAPI).toHaveProperty('conversations'); + expect(searchAPI).toHaveProperty('messages'); + expect(searchAPI).toHaveProperty('articles'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + vi.clearAllMocks(); + }); + + it('#get', () => { + searchAPI.get({ q: 'test query' }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', { + params: { q: 'test query' }, + }); + }); + + it('#contacts', () => { + searchAPI.contacts({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#contacts with date filters', () => { + searchAPI.contacts({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + + it('#conversations', () => { + searchAPI.conversations({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + } + ); + }); + + it('#conversations with date filters', () => { + searchAPI.conversations({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 }, + } + ); + }); + + it('#messages', () => { + searchAPI.messages({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: undefined, + until: undefined, + from: undefined, + inbox_id: undefined, + }, + }); + }); + + it('#messages with all filters', () => { + searchAPI.messages({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inboxId: 10, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inbox_id: 10, + }, + }); + }); + + it('#articles', () => { + searchAPI.articles({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#articles with date filters', () => { + searchAPI.articles({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + }); +}); diff --git a/app/javascript/dashboard/assets/images/auth/signup-bg.jpg b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg new file mode 100644 index 000000000..884a57922 Binary files /dev/null and b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg differ diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss index 51ade3df5..604b6aea9 100644 --- a/app/javascript/dashboard/assets/scss/_next-colors.scss +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -96,18 +96,49 @@ --gray-11: 100 100 100; --gray-12: 32 32 32; - --background-color: 253 253 253; - --text-blue: 8 109 224; + --violet-1: 253 252 254; + --violet-2: 250 248 255; + --violet-3: 244 240 254; + --violet-4: 235 228 255; + --violet-5: 225 217 255; + --violet-6: 212 202 254; + --violet-7: 194 178 248; + --violet-8: 169 153 236; + --violet-9: 110 86 207; + --violet-10: 100 84 196; + --violet-11: 101 85 183; + --violet-12: 47 38 95; + + --background-color: 247 247 247; + --surface-1: 254 254 254; + --surface-2: 255 255 255; + --surface-active: 255 255 255; + --background-input-box: 0, 0, 0, 0.03; + --text-blue: 1 22 44; + --text-purple: 2 4 49; + --text-amber: 37 24 1; --border-container: 236 236 236; - --border-strong: 235 235 235; + --border-strong: 226 227 231; --border-weak: 234 234 234; + --border-blue-strong: 18 61 117; --solid-1: 255 255 255; --solid-2: 255 255 255; --solid-3: 255 255 255; --solid-active: 255 255 255; - --solid-amber: 252 232 193; + --solid-amber: 255 228 181; --solid-blue: 218 236 255; + --solid-blue-2: 251 253 255; --solid-iris: 230 231 255; + --solid-purple: 230 231 255; + --solid-red: 254 200 201; + --solid-amber-button: 255 221 141; + --card-color: 255 255 255; + --overlay: 0, 0, 0, 0.12; + --overlay-avatar: 255, 255, 255, 0.67; + --button-color: 255 255 255; + --button-hover-color: 255, 255, 255, 0.2; + --label-background: 247 247 247; + --label-border: 0, 0, 0, 0.04; // Bubble-specific colors (override these for custom theming) --bubble-agent-bg: var(--solid-blue); @@ -151,8 +182,8 @@ --bubble-bot-status: var(--slate-10); --bubble-bot-status-read: 126 182 255; - --alpha-1: 67, 67, 67, 0.06; - --alpha-2: 201, 202, 207, 0.15; + --alpha-1: 215, 215, 215, 0.22; + --alpha-2: 196, 197, 198, 0.22; --alpha-3: 255, 255, 255, 0.96; --black-alpha-1: 0, 0, 0, 0.12; --black-alpha-2: 0, 0, 0, 0.04; @@ -254,17 +285,48 @@ --gray-11: 180 180 180; --gray-12: 238 238 238; - --background-color: 18 18 19; - --border-strong: 52 52 52; - --border-weak: 38 38 42; + --violet-1: 20 17 31; + --violet-2: 27 21 37; + --violet-3: 41 31 67; + --violet-4: 50 37 85; + --violet-5: 60 46 105; + --violet-6: 71 56 135; + --violet-7: 86 70 151; + --violet-8: 110 86 171; + --violet-9: 110 86 207; + --violet-10: 125 109 217; + --violet-11: 169 153 236; + --violet-12: 226 221 254; + + --background-color: 28 29 32; + --surface-1: 20 21 23; + --surface-2: 22 23 26; + --surface-active: 53 57 66; + --background-input-box: 255, 255, 255, 0.02; + --text-blue: 213 234 255; + --text-purple: 232 233 254; + --text-amber: 255 247 234; + --border-strong: 46 45 50; + --border-weak: 31 31 37; + --border-blue-strong: 201 226 255; --solid-1: 23 23 26; --solid-2: 29 30 36; --solid-3: 44 45 54; --solid-active: 53 57 66; - --solid-amber: 42 37 30; - --solid-blue: 16 49 91; + --solid-amber: 56 50 41; + --solid-blue: 15 57 102; + --solid-blue-2: 26 29 35; --solid-iris: 38 42 101; - --text-blue: 126 182 255; + --solid-purple: 51 51 107; + --solid-red: 90 33 34; + --solid-amber-button: 255 221 141; + --card-color: 28 30 34; + --overlay: 0, 0, 0, 0.4; + --overlay-avatar: 0, 0, 0, 0.05; + --button-color: 42 43 51; + --button-hover-color: 0, 0, 0, 0.15; + --label-background: 36 38 45; + --label-border: 255, 255, 255, 0.03; // Bubble-specific colors (override these for custom theming) --bubble-agent-bg: var(--solid-blue); @@ -308,13 +370,13 @@ --bubble-bot-status: var(--slate-10); --bubble-bot-status-read: 126 182 255; - --alpha-1: 36, 36, 36, 0.8; - --alpha-2: 139, 147, 182, 0.15; - --alpha-3: 36, 38, 45, 0.9; + --alpha-1: 35, 36, 42, 0.8; + --alpha-2: 147, 153, 176, 0.12; + --alpha-3: 33, 34, 38, 0.95; --black-alpha-1: 0, 0, 0, 0.3; --black-alpha-2: 0, 0, 0, 0.2; --border-blue: 39, 129, 246, 0.5; - --border-container: 236, 236, 236, 0; + --border-container: 255, 255, 255, 0; --white-alpha: 255, 255, 255, 0.1; } } diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index b4154e4d8..9567d66c3 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -7,7 +7,7 @@ @apply bg-n-slate-3; &::after { - @apply text-n-blue-text; + @apply text-n-blue-11; } } } diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue index 351e3240f..965f532f4 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue @@ -119,7 +119,7 @@ onMounted(() => { ) " :items="filteredTags" - class="[&>button]:!text-n-blue-text [&>div]:min-w-64" + class="[&>button]:!text-n-blue-11 [&>div]:min-w-64" @add="onClickAddTag" />
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue index 184e8bd10..01a207797 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue @@ -20,7 +20,7 @@ const handleButtonClick = () => {