diff --git a/.circleci/config.yml b/.circleci/config.yml index c0320652b..99ac1c29a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 20 + parallelism: 18 steps: - checkout - node/install: @@ -350,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/.gitignore b/.gitignore index bcc83c1ef..dbdd35bcd 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,7 @@ yarn-debug.log* .vscode .claude/settings.local.json .cursor +.codex/ CLAUDE.local.md # Histoire deployment @@ -101,3 +102,4 @@ CLAUDE.local.md .histoire .pnpm-store/* local/ +Procfile.worktree diff --git a/AGENTS.md b/AGENTS.md index 474fe6e7f..301633d7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,11 @@ - **Setup**: `bundle install && pnpm install` - **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` +- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification) +- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios) +- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too: + - UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`). + - CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find())"` (or call `Seeders::AccountSeeder.new(account: Account.find()).perform!` directly). - **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` - **Lint Ruby**: `bundle exec rubocop -a` - **Test JS**: `pnpm test` or `pnpm test:watch` @@ -50,6 +55,13 @@ - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs - Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors +## Codex Worktree Workflow + +- Use a separate git worktree + branch per task to keep changes isolated. +- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration. +- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions. +- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time. + ## Commit Messages - Prefer Conventional Commits: `type(scope): subject` (scope optional) @@ -86,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs - When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. - Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. - When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`. + +## Branding / White-labeling note + +- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy. diff --git a/Gemfile b/Gemfile index 2d789ba1d..ecbd9a193 100644 --- a/Gemfile +++ b/Gemfile @@ -192,12 +192,14 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.7.0' +gem 'ai-agents' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm-schema' +gem 'cld3', '~> 3.7' + # OpenTelemetry for LLM observability gem 'opentelemetry-sdk' gem 'opentelemetry-exporter-otlp' diff --git a/Gemfile.lock b/Gemfile.lock index 96ce85f3b..b31031566 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.7.0) - ruby_llm (~> 1.8.2) + ai-agents (0.9.0) + ruby_llm (~> 1.9.1) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -186,6 +186,7 @@ GEM byebug (11.1.3) childprocess (5.1.0) logger (~> 1.5) + cld3 (3.7.0) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -300,7 +301,7 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger @@ -311,12 +312,12 @@ GEM hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.4.0) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) - faraday-retry (2.2.1) + faraday-retry (2.4.0) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) @@ -467,7 +468,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.13.2) + json (2.18.1) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -542,11 +543,11 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) 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) @@ -561,12 +562,12 @@ GEM multi_json (1.15.0) multi_xml (0.8.0) bigdecimal (>= 3.1, < 5) - multipart-post (2.3.0) + multipart-post (2.4.1) mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) - net-http (0.6.0) - uri + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) net-imap (0.4.20) @@ -679,7 +680,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.3) + rack (3.2.5) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -827,7 +828,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.8.2) + ruby_llm (1.9.2) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -971,7 +972,7 @@ GEM unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.17.0) - uri (1.0.4) + uri (1.1.1) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) @@ -1006,7 +1007,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.17) + zeitwerk (2.7.4) PLATFORMS arm64-darwin-20 @@ -1026,7 +1027,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.7.0) + ai-agents annotaterb attr_extras audited (~> 5.4, >= 5.4.1) @@ -1040,6 +1041,7 @@ DEPENDENCIES bullet bundle-audit byebug + cld3 (~> 3.7) climate_control commonmarker counter_culture diff --git a/VERSION_CW b/VERSION_CW index ad96464c4..a162ea75a 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.10.1 +4.11.0 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/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index b3151c8fa..7c21844f5 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -70,6 +70,7 @@ 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 render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled? return head :too_many_requests unless @conversation.account.within_email_rate_limit? ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 96c15fde2..00e718614 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -35,7 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def transcript - return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit? + return head :too_many_requests if conversation.blank? + return head :payment_required unless conversation.account.email_transcript_enabled? + return head :too_many_requests unless conversation.account.within_email_rate_limit? send_transcript_email head :ok diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index ddd629048..192b3619c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -78,6 +78,15 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController 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) @@ -171,4 +180,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController 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/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index 00976c3cd..c69541f6f 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController def create @user = User.from_email(params[:email]) - if @user - @user.send_reset_password_instructions - build_response(I18n.t('messages.reset_password_success'), 200) - else - build_response(I18n.t('messages.reset_password_failure'), 404) - end + @user&.send_reset_password_instructions + build_response(I18n.t('messages.reset_password'), 200) end def update diff --git a/app/controllers/webhooks/shopify_controller.rb b/app/controllers/webhooks/shopify_controller.rb new file mode 100644 index 000000000..efc6a5122 --- /dev/null +++ b/app/controllers/webhooks/shopify_controller.rb @@ -0,0 +1,35 @@ +class Webhooks::ShopifyController < ActionController::API + before_action :verify_hmac! + + def events + case request.headers['X-Shopify-Topic'] + when 'shop/redact' + handle_shop_redact + end + + head :ok + end + + private + + def verify_hmac! + secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) + return head :unauthorized if secret.blank? + + data = request.body.read + request.body.rewind + + hmac_header = request.headers['X-Shopify-Hmac-SHA256'] + return head :unauthorized if hmac_header.blank? + + computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data)) + return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header) + end + + def handle_shop_redact + shop_domain = params[:shop_domain] + return if shop_domain.blank? + + Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all + end +end diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue index cd6f1d49b..20ab38d58 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue @@ -39,7 +39,6 @@ const policyA = withCount({ description: 'Distributes conversations evenly among available agents', assignmentOrder: 'round_robin', conversationPriority: 'high', - enabled: true, inboxes: [mockInboxes[0], mockInboxes[1]], isFetchingInboxes: false, }); @@ -50,7 +49,6 @@ const policyB = withCount({ description: 'Assigns based on capacity and workload', assignmentOrder: 'capacity_based', conversationPriority: 'medium', - enabled: true, inboxes: [mockInboxes[2], mockInboxes[3]], isFetchingInboxes: false, }); @@ -61,7 +59,6 @@ const emptyPolicy = withCount({ description: 'Policy with no assigned inboxes', assignmentOrder: 'manual', conversationPriority: 'low', - enabled: false, inboxes: [], isFetchingInboxes: false, }); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue index fe9965777..cedfb0009 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -15,7 +15,6 @@ const props = defineProps({ assignmentOrder: { type: String, default: '' }, conversationPriority: { type: String, default: '' }, assignedInboxCount: { type: Number, default: 0 }, - enabled: { type: Boolean, default: false }, inboxes: { type: Array, default: () => [] }, isFetchingInboxes: { type: Boolean, default: false }, }); @@ -65,22 +64,6 @@ const handleFetchInboxes = () => { {{ name }}
-
- - {{ - enabled - ? t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE' - ) - : t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE' - ) - }} - -
-
+
+ +
-import { ref, onMounted } from 'vue'; +import { ref, computed, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import Input from 'dashboard/components-next/input/Input.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; @@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', { }, }); +// The model value is in seconds (for the backend/DB) +// DurationInput works in minutes internally +// We need to convert between seconds and minutes const fairDistributionWindow = defineModel('fairDistributionWindow', { type: Number, default: 3600, @@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', { const windowUnit = ref(DURATION_UNITS.MINUTES); +// Convert seconds to minutes for DurationInput +const windowInMinutes = computed({ + get() { + return Math.floor((fairDistributionWindow.value || 0) / 60); + }, + set(minutes) { + fairDistributionWindow.value = minutes * 60; + }, +}); + +// Detect unit based on minutes (converted from seconds) const detectUnit = minutes => { const m = Number(minutes) || 0; if (m === 0) return DURATION_UNITS.MINUTES; @@ -34,7 +48,7 @@ const detectUnit = minutes => { }; onMounted(() => { - windowUnit.value = detectUnit(fairDistributionWindow.value); + windowUnit.value = detectUnit(windowInMinutes.value); }); @@ -73,9 +87,9 @@ onMounted(() => {
- + +import { useI18n } from 'vue-i18n'; + const props = defineProps({ id: { type: String, @@ -16,12 +18,22 @@ const props = defineProps({ type: Boolean, default: false, }, + disabled: { + type: Boolean, + default: false, + }, + disabledMessage: { + type: String, + default: '', + }, }); const emit = defineEmits(['select']); +const { t } = useI18n(); + const handleChange = () => { - if (!props.isActive) { + if (!props.isActive && !props.disabled) { emit('select', props.id); } }; @@ -29,9 +41,11 @@ const handleChange = () => {