diff --git a/.gitignore b/.gitignore index c64fb5c1b..bb0df62a8 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,4 @@ yarn-debug.log* .vscode .claude/settings.local.json .cursor +CLAUDE.local.md diff --git a/AGENTS.md b/AGENTS.md index ad374799c..e3b022a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,4 +55,21 @@ ## Ruby Best Practices -- Use compact `module/class` definitions; avoid nested styles \ No newline at end of file +- Use compact `module/class` definitions; avoid nested styles + +## Enterprise Edition Notes + +- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code. +- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible. +- Follow the Enterprise development practices documented here: + - https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38 + +Practical checklist for any change impacting core logic or public APIs +- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`). +- If adding new endpoints, services, or models, consider whether Enterprise needs: + - An override (e.g., `enterprise/app/...`), or + - An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks. +- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise. +- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new 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. diff --git a/Gemfile b/Gemfile index 269614d8b..a3b3c0ae8 100644 --- a/Gemfile +++ b/Gemfile @@ -108,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0' ##-- apm and error monitoring ---# # loaded only when environment variables are set. # ref application.rb -gem 'ddtrace', require: false +gem 'datadog', '~> 2.0', require: false gem 'elastic-apm', require: false gem 'newrelic_rpm', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false @@ -179,7 +179,10 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.2.1' +gem 'ai-agents', '>= 0.4.3' + +# TODO: Move this gem as a dependency of ai-agents +gem 'ruby_llm-schema' gem 'shopify_api' diff --git a/Gemfile.lock b/Gemfile.lock index 405300a7c..be258f524 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,35 +25,35 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.5.1) - actionpack (= 7.1.5.1) - activesupport (= 7.1.5.1) + actioncable (7.1.5.2) + actionpack (= 7.1.5.2) + activesupport (= 7.1.5.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.5.1) - actionpack (= 7.1.5.1) - activejob (= 7.1.5.1) - activerecord (= 7.1.5.1) - activestorage (= 7.1.5.1) - activesupport (= 7.1.5.1) + actionmailbox (7.1.5.2) + actionpack (= 7.1.5.2) + activejob (= 7.1.5.2) + activerecord (= 7.1.5.2) + activestorage (= 7.1.5.2) + activesupport (= 7.1.5.2) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.5.1) - actionpack (= 7.1.5.1) - actionview (= 7.1.5.1) - activejob (= 7.1.5.1) - activesupport (= 7.1.5.1) + actionmailer (7.1.5.2) + actionpack (= 7.1.5.2) + actionview (= 7.1.5.2) + activejob (= 7.1.5.2) + activesupport (= 7.1.5.2) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.5.1) - actionview (= 7.1.5.1) - activesupport (= 7.1.5.1) + actionpack (7.1.5.2) + actionview (= 7.1.5.2) + activesupport (= 7.1.5.2) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -61,38 +61,38 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.5.1) - actionpack (= 7.1.5.1) - activerecord (= 7.1.5.1) - activestorage (= 7.1.5.1) - activesupport (= 7.1.5.1) + actiontext (7.1.5.2) + actionpack (= 7.1.5.2) + activerecord (= 7.1.5.2) + activestorage (= 7.1.5.2) + activesupport (= 7.1.5.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.5.1) - activesupport (= 7.1.5.1) + actionview (7.1.5.2) + activesupport (= 7.1.5.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) active_record_query_trace (1.8) - activejob (7.1.5.1) - activesupport (= 7.1.5.1) + activejob (7.1.5.2) + activesupport (= 7.1.5.2) globalid (>= 0.3.6) - activemodel (7.1.5.1) - activesupport (= 7.1.5.1) - activerecord (7.1.5.1) - activemodel (= 7.1.5.1) - activesupport (= 7.1.5.1) + activemodel (7.1.5.2) + activesupport (= 7.1.5.2) + activerecord (7.1.5.2) + activemodel (= 7.1.5.2) + activesupport (= 7.1.5.2) timeout (>= 0.4.0) activerecord-import (2.1.0) activerecord (>= 4.2) - activestorage (7.1.5.1) - actionpack (= 7.1.5.1) - activejob (= 7.1.5.1) - activerecord (= 7.1.5.1) - activesupport (= 7.1.5.1) + activestorage (7.1.5.2) + actionpack (= 7.1.5.2) + activejob (= 7.1.5.2) + activerecord (= 7.1.5.2) + activesupport (= 7.1.5.2) marcel (~> 1.0) - activesupport (7.1.5.1) + activesupport (7.1.5.2) base64 benchmark (>= 0.3) bigdecimal @@ -126,7 +126,7 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.2.1) + ai-agents (0.4.3) ruby_llm (~> 1.3) annotate (3.2.0) activerecord (>= 3.2, < 8.0) @@ -155,10 +155,10 @@ GEM barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.0) - bigdecimal (3.1.9) + benchmark (0.4.1) + bigdecimal (3.2.2) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -194,10 +194,14 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.4.1) - ddtrace (0.48.0) - ffi (~> 1.0) + datadog (2.19.0) + datadog-ruby_core_source (~> 3.4, >= 3.4.1) + libdatadog (~> 18.1.0.1.0) + libddwaf (~> 1.24.1.0.3) + logger msgpack + datadog-ruby_core_source (3.4.1) + date (3.4.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -444,6 +448,16 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) + libdatadog (18.1.0.1.0) + libdatadog (18.1.0.1.0-x86_64-linux) + libddwaf (1.24.1.0.3) + ffi (~> 1.0) + libddwaf (1.24.1.0.3-arm64-darwin) + ffi (~> 1.0) + libddwaf (1.24.1.0.3-x86_64-darwin) + ffi (~> 1.0) + libddwaf (1.24.1.0.3-x86_64-linux) + ffi (~> 1.0) line-bot-api (1.28.0) lint_roller (1.1.0) liquid (5.4.0) @@ -599,20 +613,20 @@ GEM rackup (1.0.1) rack (< 3) webrick - rails (7.1.5.1) - actioncable (= 7.1.5.1) - actionmailbox (= 7.1.5.1) - actionmailer (= 7.1.5.1) - actionpack (= 7.1.5.1) - actiontext (= 7.1.5.1) - actionview (= 7.1.5.1) - activejob (= 7.1.5.1) - activemodel (= 7.1.5.1) - activerecord (= 7.1.5.1) - activestorage (= 7.1.5.1) - activesupport (= 7.1.5.1) + rails (7.1.5.2) + actioncable (= 7.1.5.2) + actionmailbox (= 7.1.5.2) + actionmailer (= 7.1.5.2) + actionpack (= 7.1.5.2) + actiontext (= 7.1.5.2) + actionview (= 7.1.5.2) + activejob (= 7.1.5.2) + activemodel (= 7.1.5.2) + activerecord (= 7.1.5.2) + activestorage (= 7.1.5.2) + activesupport (= 7.1.5.2) bundler (>= 1.15.0) - railties (= 7.1.5.1) + railties (= 7.1.5.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -620,9 +634,9 @@ GEM rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.1.5.1) - actionpack (= 7.1.5.1) - activesupport (= 7.1.5.1) + railties (7.1.5.2) + actionpack (= 7.1.5.2) + activesupport (= 7.1.5.2) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -720,7 +734,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.3.1) + ruby_llm (1.5.1) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -729,6 +743,7 @@ GEM faraday-retry (>= 1) marcel (~> 1.0) zeitwerk (~> 2) + ruby_llm-schema (0.1.0) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -910,7 +925,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.2.1) + ai-agents (>= 0.4.3) annotate attr_extras audited (~> 5.4, >= 5.4.1) @@ -927,7 +942,7 @@ DEPENDENCIES commonmarker csv-safe database_cleaner - ddtrace + datadog (~> 2.0) debug (~> 1.8) devise (>= 4.9.4) devise-secure_password! @@ -1004,6 +1019,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai + ruby_llm-schema scout_apm scss_lint seed_dump diff --git a/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb new file mode 100644 index 000000000..ac1d0a712 --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb @@ -0,0 +1,20 @@ +class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController + before_action :fetch_assignment_policy + before_action -> { check_authorization(AssignmentPolicy) } + + def index + @inboxes = @assignment_policy.inboxes + end + + private + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find( + params[:assignment_policy_id] + ) + end + + def permitted_params + params.permit(:assignment_policy_id) + end +end diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb new file mode 100644 index 000000000..1807d6afb --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_assignment_policy, only: [:show, :update, :destroy] + before_action :check_authorization + + def index + @assignment_policies = Current.account.assignment_policies + end + + def show; end + + def create + @assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params) + end + + def update + @assignment_policy.update!(assignment_policy_params) + end + + def destroy + @assignment_policy.destroy! + head :ok + end + + private + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find(params[:id]) + end + + def assignment_policy_params + params.require(:assignment_policy).permit( + :name, :description, :assignment_order, :conversation_priority, + :fair_distribution_limit, :fair_distribution_window, :enabled + ) + end +end diff --git a/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb new file mode 100644 index 000000000..cf52951a5 --- /dev/null +++ b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb @@ -0,0 +1,46 @@ +class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_inbox + before_action :fetch_assignment_policy, only: [:create] + before_action -> { check_authorization(AssignmentPolicy) } + before_action :validate_assignment_policy, only: [:show, :destroy] + + def show + @assignment_policy = @inbox.assignment_policy + end + + def create + # There should be only one assignment policy for an inbox. + # If there is a new request to add an assignment policy, we will + # delete the old one and attach the new policy + remove_inbox_assignment_policy + @inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy) + @assignment_policy = @inbox.assignment_policy + end + + def destroy + remove_inbox_assignment_policy + head :ok + end + + private + + def remove_inbox_assignment_policy + @inbox.inbox_assignment_policy&.destroy + end + + def fetch_inbox + @inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) + end + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id]) + end + + def permitted_params + params.permit(:assignment_policy_id, :inbox_id) + end + + def validate_assignment_policy + return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy + end +end diff --git a/app/helpers/reporting_event_helper.rb b/app/helpers/reporting_event_helper.rb index e08b5691c..f0a419cc8 100644 --- a/app/helpers/reporting_event_helper.rb +++ b/app/helpers/reporting_event_helper.rb @@ -18,12 +18,25 @@ module ReportingEventHelper end def last_non_human_activity(conversation) - # check if a handoff event already exists - handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last + # Try to get either a handoff or reopened event first + # These will always take precedence over any other activity + # Also, any of these events can happen at any time in the course of a conversation lifecycle. + # So we pick the latest event + event = ReportingEvent.where( + conversation_id: conversation.id, + name: %w[conversation_bot_handoff conversation_opened] + ).order(event_end_time: :desc).first - # if a handoff exists, last non human activity is when the handoff ended, - # otherwise it's when the conversation was created - handoff_event&.event_end_time || conversation.created_at + return event.event_end_time if event&.event_end_time + + # Fallback to bot resolved event + # Because this will be closest to the most accurate activity instead of conversation.created_at + bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last + + return bot_event.event_end_time if bot_event&.event_end_time + + # If no events found, return conversation creation time + conversation.created_at end private diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue index df76ae901..0babd11ec 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue @@ -9,6 +9,7 @@ import Input from 'dashboard/components-next/input/Input.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue'; +import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue'; const emit = defineEmits(['submit', 'cancel']); @@ -18,7 +19,9 @@ const formState = { uiFlags: useMapGetter('campaigns/getUIFlags'), labels: useMapGetter('labels/getLabels'), inboxes: useMapGetter('inboxes/getWhatsAppInboxes'), - getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'), + getFilteredWhatsAppTemplates: useMapGetter( + 'inboxes/getFilteredWhatsAppTemplates' + ), }; const initialState = { @@ -30,7 +33,7 @@ const initialState = { }; const state = reactive({ ...initialState }); -const processedParams = ref({}); +const templateParserRef = ref(null); const rules = { title: { required, minLength: minLength(1) }, @@ -67,7 +70,7 @@ const inboxOptions = computed(() => const templateOptions = computed(() => { if (!state.inboxId) return []; - const templates = formState.getWhatsAppTemplates.value(state.inboxId); + const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId); return templates.map(template => { // Create a more user-friendly label from template name const friendlyName = template.name @@ -88,26 +91,6 @@ const selectedTemplate = computed(() => { ?.template; }); -const templateString = computed(() => { - if (!selectedTemplate.value) return ''; - try { - return ( - selectedTemplate.value.components?.find( - component => component.type === 'BODY' - )?.text || '' - ); - } catch (error) { - return ''; - } -}); - -const processedString = computed(() => { - if (!templateString.value) return ''; - return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => { - return processedParams.value[variable] || `{{${variable}}}`; - }); -}); - const getErrorMessage = (field, errorKey) => { const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM'; return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : ''; @@ -122,8 +105,7 @@ const formErrors = computed(() => ({ })); const hasRequiredTemplateParams = computed(() => { - const params = Object.values(processedParams.value); - return params.length === 0 || params.every(param => param.trim() !== ''); + return templateParserRef.value?.v$?.$invalid === false || true; }); const isSubmitDisabled = computed( @@ -135,32 +117,18 @@ const formatToUTCString = localDateTime => const resetState = () => { Object.assign(state, initialState); - processedParams.value = {}; v$.value.$reset(); }; const handleCancel = () => emit('cancel'); -const generateVariables = () => { - const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); - if (!matchedVariables) { - processedParams.value = {}; - return; - } - - const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, '')); - processedParams.value = finalVars.reduce((acc, variable) => { - acc[variable] = processedParams.value[variable] || ''; - return acc; - }, {}); -}; - const prepareCampaignDetails = () => { // Find the selected template to get its content const currentTemplate = selectedTemplate.value; + const parserData = templateParserRef.value; // Extract template content - this should be the template message body - const templateContent = templateString.value; + const templateContent = parserData?.renderedTemplate || ''; // Prepare template_params object with the same structure as used in contacts const templateParams = { @@ -168,7 +136,7 @@ const prepareCampaignDetails = () => { namespace: currentTemplate?.namespace || '', category: currentTemplate?.category || 'UTILITY', language: currentTemplate?.language || 'en_US', - processed_params: processedParams.value, + processed_params: parserData?.processedParams || {}, }; return { @@ -198,15 +166,6 @@ watch( () => state.inboxId, () => { state.templateId = null; - processedParams.value = {}; - } -); - -// Generate variables when template changes -watch( - () => state.templateId, - () => { - generateVariables(); } ); @@ -254,62 +213,12 @@ watch(

- -
+ -
-

- {{ selectedTemplate.name }} -

- - {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}: - {{ selectedTemplate.language || 'en' }} - -
- -
-
-
- {{ processedString }} -
-
-
- -
- {{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}: - {{ selectedTemplate.category || 'UTILITY' }} -
-
- - -
- -
-
- -
-
-
+ ref="templateParserRef" + :template="selectedTemplate" + />