- {{ $t('CSAT_REPORTS.NO_RECORDS') }}
+
+
+
+
+
+
+ |
+ {{ header.column.columnDef.header }}
+ |
+
+
+
+
+
+ |
+
+ |
+
+
+
+ {{ $t(getRatingData(row.rating).translationKey) }}
+
+
+ |
+
+
+ {{ $t('CSAT_REPORTS.NO_FEEDBACK') }}
+
+
+
+
+ |
+
+
+
+ {{ $t('CSAT_REPORTS.NO_AGENT') }}
+
+ |
+
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+
+
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTableLoader.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTableLoader.vue
new file mode 100644
index 000000000..a24523017
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTableLoader.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js
index cfaa4cd51..1e743cfc9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js
@@ -6,7 +6,6 @@ describe('CsatMetrics.vue', () => {
let getters;
let store;
let wrapper;
- const filters = { rating: 3 };
beforeEach(() => {
getters = {
@@ -18,8 +17,16 @@ describe('CsatMetrics.vue', () => {
4: 30,
5: 10,
}),
+ 'csat/getRatingCount': () => ({
+ 1: 10,
+ 2: 20,
+ 3: 30,
+ 4: 30,
+ 5: 10,
+ }),
'csat/getSatisfactionScore': () => 85,
'csat/getResponseRate': () => 90,
+ 'csat/getUIFlags': () => ({ isFetchingMetrics: false }),
};
store = createStore({
@@ -28,40 +35,31 @@ describe('CsatMetrics.vue', () => {
wrapper = shallowMount(CsatMetrics, {
global: {
- plugins: [store], // Ensure the store is injected here
+ plugins: [store],
mocks: {
- $t: msg => msg, // mock translation function
+ $t: msg => msg,
},
stubs: {
- CsatMetricCard: '
',
- BarChart: '
',
+ CsatMetricCard: true,
+ CsatRatingDistribution: true,
},
},
- props: { filters },
});
});
it('computes response count correctly', () => {
expect(wrapper.vm.responseCount).toBe('100');
- expect(wrapper.html()).toMatchSnapshot();
});
- it('formats values to percent correctly', () => {
- expect(wrapper.vm.formatToPercent(85)).toBe('85%');
- expect(wrapper.vm.formatToPercent(null)).toBe('--');
+ it('renders metric cards with correct values', () => {
+ const metricCards = wrapper.findAllComponents({ name: 'CsatMetricCard' });
+ expect(metricCards).toHaveLength(3);
});
- it('maps rating value to emoji correctly', () => {
- const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
- expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
- });
-
- it('hides report card if rating filter is enabled', () => {
- expect(wrapper.html()).not.toContain('bar-chart-stub');
- });
-
- it('shows report card if rating filter is not enabled', async () => {
- await wrapper.setProps({ filters: {} });
- expect(wrapper.html()).toContain('bar-chart-stub');
+ it('renders rating distribution component', () => {
+ const distribution = wrapper.findComponent({
+ name: 'CsatRatingDistribution',
+ });
+ expect(distribution.exists()).toBe(true);
});
});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap
deleted file mode 100644
index 1f1e80f83..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap
+++ /dev/null
@@ -1,10 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`CsatMetrics.vue > computes response count correctly 1`] = `
-"
-
-
-
-
-
"
-`;
diff --git a/app/javascript/dashboard/store/modules/csat.js b/app/javascript/dashboard/store/modules/csat.js
index 2cf929862..b5b3e8c53 100644
--- a/app/javascript/dashboard/store/modules/csat.js
+++ b/app/javascript/dashboard/store/modules/csat.js
@@ -82,6 +82,9 @@ export const getters = {
),
};
},
+ getRatingCount(_state) {
+ return _state.metrics.ratingsCount;
+ },
};
export const actions = {
@@ -115,6 +118,13 @@ export const actions = {
});
});
},
+ update: async ({ commit }, { id, reviewNotes }) => {
+ const response = await CSATReports.update(id, {
+ csat_review_notes: reviewNotes,
+ });
+ commit(types.UPDATE_CSAT_RESPONSE, response.data);
+ return response.data;
+ },
};
export const mutations = {
@@ -144,6 +154,7 @@ export const mutations = {
};
_state.metrics.totalSentMessagesCount = totalSentMessagesCount || 0;
},
+ [types.UPDATE_CSAT_RESPONSE]: MutationHelpers.update,
};
export default {
diff --git a/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js b/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js
index 5a9057b70..f649d7d21 100644
--- a/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js
@@ -86,4 +86,19 @@ describe('#getters', () => {
})
).toEqual('50.00');
});
+
+ it('getRatingCount', () => {
+ const state = {
+ metrics: {
+ ratingsCount: { 1: 10, 2: 20, 3: 15, 4: 3, 5: 2 },
+ },
+ };
+ expect(getters.getRatingCount(state)).toEqual({
+ 1: 10,
+ 2: 20,
+ 3: 15,
+ 4: 3,
+ 5: 2,
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 48e6babdc..b76867360 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -241,6 +241,7 @@ export default {
SET_CSAT_RESPONSE_UI_FLAG: 'SET_CSAT_RESPONSE_UI_FLAG',
SET_CSAT_RESPONSE: 'SET_CSAT_RESPONSE',
SET_CSAT_RESPONSE_METRICS: 'SET_CSAT_RESPONSE_METRICS',
+ UPDATE_CSAT_RESPONSE: 'UPDATE_CSAT_RESPONSE',
// Custom Attributes
SET_CUSTOM_ATTRIBUTE_UI_FLAG: 'SET_CUSTOM_ATTRIBUTE_UI_FLAG',
diff --git a/app/jobs/conversations/resolution_job.rb b/app/jobs/conversations/resolution_job.rb
index d8f34f755..7f61f22ab 100644
--- a/app/jobs/conversations/resolution_job.rb
+++ b/app/jobs/conversations/resolution_job.rb
@@ -16,10 +16,12 @@ class Conversations::ResolutionJob < ApplicationJob
private
def conversation_scope(account)
- if account.auto_resolve_ignore_waiting
- account.conversations.resolvable_not_waiting(account.auto_resolve_after)
- else
- account.conversations.resolvable_all(account.auto_resolve_after)
- end
+ base_scope = if account.auto_resolve_ignore_waiting
+ account.conversations.resolvable_not_waiting(account.auto_resolve_after)
+ else
+ account.conversations.resolvable_all(account.auto_resolve_after)
+ end
+ # Exclude orphan conversations where contact was deleted but conversation cleanup is pending
+ base_scope.where.not(contact_id: nil)
end
end
diff --git a/app/models/concerns/conversation_mute_helpers.rb b/app/models/concerns/conversation_mute_helpers.rb
index c6ea4c7b1..ebc0542d2 100644
--- a/app/models/concerns/conversation_mute_helpers.rb
+++ b/app/models/concerns/conversation_mute_helpers.rb
@@ -2,17 +2,21 @@ module ConversationMuteHelpers
extend ActiveSupport::Concern
def mute!
+ return unless contact
+
resolved!
contact.update(blocked: true)
create_muted_message
end
def unmute!
+ return unless contact
+
contact.update(blocked: false)
create_unmuted_message
end
def muted?
- contact.blocked?
+ contact&.blocked? || false
end
end
diff --git a/app/models/csat_survey_response.rb b/app/models/csat_survey_response.rb
index 7bcc25d58..804dfd4b7 100644
--- a/app/models/csat_survey_response.rb
+++ b/app/models/csat_survey_response.rb
@@ -27,6 +27,7 @@ class CsatSurveyResponse < ApplicationRecord
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
+ belongs_to :review_notes_updated_by, class_name: 'User', optional: true
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
diff --git a/app/models/user.rb b/app/models/user.rb
index cc25357f6..b14bcd158 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -90,6 +90,8 @@ class User < ApplicationRecord
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
+ has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
+ dependent: :nullify, inverse_of: :review_notes_updated_by
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation
diff --git a/app/services/csat_survey_service.rb b/app/services/csat_survey_service.rb
index 6c8a288b8..cc38b820b 100644
--- a/app/services/csat_survey_service.rb
+++ b/app/services/csat_survey_service.rb
@@ -20,7 +20,7 @@ class CsatSurveyService
delegate :inbox, :contact, to: :conversation
def should_send_csat_survey?
- conversation_allows_csat? && csat_enabled? && !csat_already_sent?
+ conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
end
def conversation_allows_csat?
@@ -39,6 +39,37 @@ class CsatSurveyService
conversation.can_reply?
end
+ def csat_allowed_by_survey_rules?
+ return true unless survey_rules_configured?
+
+ labels = conversation.label_list
+ return true if rule_values.empty?
+
+ case rule_operator
+ when 'contains'
+ rule_values.any? { |label| labels.include?(label) }
+ when 'does_not_contain'
+ rule_values.none? { |label| labels.include?(label) }
+ else
+ true
+ end
+ end
+
+ def survey_rules_configured?
+ return false if csat_config.blank?
+ return false if csat_config['survey_rules'].blank?
+
+ rule_values.any?
+ end
+
+ def rule_operator
+ csat_config.dig('survey_rules', 'operator') || 'contains'
+ end
+
+ def rule_values
+ csat_config.dig('survey_rules', 'values') || []
+ end
+
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
@@ -113,6 +144,10 @@ class CsatSurveyService
)
end
+ def csat_config
+ inbox.csat_config || {}
+ end
+
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
diff --git a/app/services/message_templates/template/csat_survey.rb b/app/services/message_templates/template/csat_survey.rb
index dd9cf3bd6..4fcef3e87 100644
--- a/app/services/message_templates/template/csat_survey.rb
+++ b/app/services/message_templates/template/csat_survey.rb
@@ -2,8 +2,6 @@ class MessageTemplates::Template::CsatSurvey
pattr_initialize [:conversation!]
def perform
- return unless should_send_csat_survey?
-
ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params)
end
@@ -13,39 +11,6 @@ class MessageTemplates::Template::CsatSurvey
delegate :contact, :account, :inbox, to: :conversation
- def should_send_csat_survey?
- return true unless survey_rules_configured?
-
- labels = conversation.label_list
-
- return true if rule_values.empty?
-
- case rule_operator
- when 'contains'
- rule_values.any? { |label| labels.include?(label) }
- when 'does_not_contain'
- rule_values.none? { |label| labels.include?(label) }
- else
- true
- end
- end
-
- def survey_rules_configured?
- return false if csat_config.blank?
- return false if csat_config['survey_rules'].blank?
- return false if rule_values.empty?
-
- true
- end
-
- def rule_operator
- csat_config.dig('survey_rules', 'operator') || 'contains'
- end
-
- def rule_values
- csat_config.dig('survey_rules', 'values') || []
- end
-
def message_content
return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
diff --git a/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb b/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb
index 5c0524480..ba17c175b 100644
--- a/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb
+++ b/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb
@@ -1,5 +1,5 @@
-<%=
- CSV.generate_line([
+<%
+ headers = [
I18n.t('reports.csat.headers.agent_name'),
I18n.t('reports.csat.headers.rating'),
I18n.t('reports.csat.headers.feedback'),
@@ -8,24 +8,28 @@
I18n.t('reports.csat.headers.contact_phone_number'),
I18n.t('reports.csat.headers.link_to_the_conversation'),
I18n.t('reports.csat.headers.recorded_at')
- ])
+ ]
+ headers << I18n.t('reports.csat.headers.review_notes') if ChatwootApp.enterprise?
-%>
+<%= CSV.generate_line(headers) -%>
<% @csat_survey_responses.each do |csat_response| %>
<% assigned_agent = csat_response.assigned_agent %>
<% contact = csat_response.contact %>
<% conversation = csat_response.conversation %>
-<%=
- CSV.generate_line([
+<%
+ row = [
assigned_agent ? "#{assigned_agent.name} (#{assigned_agent.email})" : nil,
csat_response.rating,
- csat_response.feedback_message.present? ? csat_response.feedback_message : nil,
- contact&.name.present? ? contact&.name: nil,
- contact&.email.present? ? contact&.email: nil,
- contact&.phone_number.present? ? contact&.phone_number: nil,
- conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id): nil,
- csat_response.created_at,
-]).html_safe
+ csat_response.feedback_message.presence,
+ contact&.name.presence,
+ contact&.email.presence,
+ contact&.phone_number.presence,
+ conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id) : nil,
+ csat_response.created_at
+ ]
+ row << csat_response.csat_review_notes if ChatwootApp.enterprise?
-%>
+<%= CSV.generate_line(row).html_safe -%>
<% end %>
<%=
CSV.generate_line([
diff --git a/app/views/api/v1/accounts/csat_survey_responses/update.json.jbuilder b/app/views/api/v1/accounts/csat_survey_responses/update.json.jbuilder
new file mode 100644
index 000000000..065eb237b
--- /dev/null
+++ b/app/views/api/v1/accounts/csat_survey_responses/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/csat_survey_response', formats: [:json], resource: @csat_survey_response
diff --git a/app/views/api/v1/models/_csat_survey_response.json.jbuilder b/app/views/api/v1/models/_csat_survey_response.json.jbuilder
index 3470c4646..fbba50aa7 100644
--- a/app/views/api/v1/models/_csat_survey_response.json.jbuilder
+++ b/app/views/api/v1/models/_csat_survey_response.json.jbuilder
@@ -1,6 +1,14 @@
json.id resource.id
json.rating resource.rating
json.feedback_message resource.feedback_message
+json.csat_review_notes resource.csat_review_notes
+json.review_notes_updated_at resource.review_notes_updated_at&.to_i
+if resource.review_notes_updated_by
+ json.review_notes_updated_by do
+ json.id resource.review_notes_updated_by.id
+ json.name resource.review_notes_updated_by.name
+ end
+end
json.account_id resource.account_id
json.message_id resource.message_id
if resource.contact
diff --git a/config/app.yml b/config/app.yml
index 65d2e7886..98e523795 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.9.2'
+ version: '4.10.0'
development:
<<: *shared
diff --git a/config/features.yml b/config/features.yml
index 6792cbb07..36943ceff 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -230,3 +230,7 @@
- name: channel_tiktok
display_name: TikTok Channel
enabled: true
+- name: csat_review_notes
+ display_name: CSAT Review Notes
+ enabled: false
+ premium: true
diff --git a/config/locales/en.yml b/config/locales/en.yml
index bacd007bb..61a80d827 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -202,6 +202,7 @@ en:
rating: Rating
feedback: Feedback Comment
recorded_at: Recorded date
+ review_notes: Review Notes
notifications:
notification_title:
conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
diff --git a/config/routes.rb b/config/routes.rb
index bdc8e4fe6..50b426554 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -189,6 +189,9 @@ Rails.application.routes.draw do
get :metrics
get :download
end
+ member do
+ patch :update if ChatwootApp.enterprise?
+ end
end
resources :applied_slas, only: [:index] do
collection do
diff --git a/db/migrate/20260114192518_add_internal_observations_to_csat_survey_responses.rb b/db/migrate/20260114192518_add_internal_observations_to_csat_survey_responses.rb
new file mode 100644
index 000000000..687064b90
--- /dev/null
+++ b/db/migrate/20260114192518_add_internal_observations_to_csat_survey_responses.rb
@@ -0,0 +1,5 @@
+class AddInternalObservationsToCsatSurveyResponses < ActiveRecord::Migration[7.1]
+ def change
+ add_column :csat_survey_responses, :csat_review_notes, :text
+ end
+end
diff --git a/db/migrate/20260114201315_add_observations_audit_to_csat_survey_responses.rb b/db/migrate/20260114201315_add_observations_audit_to_csat_survey_responses.rb
new file mode 100644
index 000000000..9bf1c5d1f
--- /dev/null
+++ b/db/migrate/20260114201315_add_observations_audit_to_csat_survey_responses.rb
@@ -0,0 +1,6 @@
+class AddObservationsAuditToCsatSurveyResponses < ActiveRecord::Migration[7.1]
+ def change
+ add_column :csat_survey_responses, :review_notes_updated_at, :datetime
+ add_reference :csat_survey_responses, :review_notes_updated_by, index: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index bd4f0f968..e30f7cd8d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
+ActiveRecord::Schema[7.1].define(version: 2026_01_14_201315) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -733,11 +733,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
t.bigint "assigned_agent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.text "csat_review_notes"
+ t.datetime "review_notes_updated_at"
+ t.bigint "review_notes_updated_by_id"
t.index ["account_id"], name: "index_csat_survey_responses_on_account_id"
t.index ["assigned_agent_id"], name: "index_csat_survey_responses_on_assigned_agent_id"
t.index ["contact_id"], name: "index_csat_survey_responses_on_contact_id"
t.index ["conversation_id"], name: "index_csat_survey_responses_on_conversation_id"
t.index ["message_id"], name: "index_csat_survey_responses_on_message_id", unique: true
+ t.index ["review_notes_updated_by_id"], name: "index_csat_survey_responses_on_review_notes_updated_by_id"
end
create_table "custom_attribute_definitions", force: :cascade do |t|
diff --git a/docker/Dockerfile b/docker/Dockerfile
index ea15f0c85..645a61a55 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -2,7 +2,7 @@
FROM node:24-alpine as node
FROM ruby:3.4.4-alpine3.21 AS pre-builder
-ARG NODE_VERSION="24.12.0"
+ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -11,7 +11,7 @@ ENV PNPM_VERSION=${PNPM_VERSION}
# For development docker-compose file overrides ARGS
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
-ENV BUNDLER_VERSION=2.5.11
+ENV BUNDLER_VERSION=2.5.16
ARG RAILS_SERVE_STATIC_FILES=true
ENV RAILS_SERVE_STATIC_FILES ${RAILS_SERVE_STATIC_FILES}
@@ -35,7 +35,7 @@ RUN apk update && apk add --no-cache \
curl \
xz \
&& mkdir -p /var/app \
- && gem install bundler
+ && gem install bundler -v "$BUNDLER_VERSION"
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
@@ -98,14 +98,14 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
# final build stage
FROM ruby:3.4.4-alpine3.21
-ARG NODE_VERSION="24.12.0"
+ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
-ENV BUNDLER_VERSION=2.5.11
+ENV BUNDLER_VERSION=2.5.16
ARG EXECJS_RUNTIME="Disabled"
ENV EXECJS_RUNTIME ${EXECJS_RUNTIME}
@@ -128,7 +128,7 @@ RUN apk update && apk add --no-cache \
imagemagick \
git \
vips \
- && gem install bundler
+ && gem install bundler -v "$BUNDLER_VERSION"
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller.rb
new file mode 100644
index 000000000..ca1ebcefb
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller.rb
@@ -0,0 +1,12 @@
+module Enterprise::Api::V1::Accounts::CsatSurveyResponsesController
+ def update
+ @csat_survey_response = Current.account.csat_survey_responses.find(params[:id])
+ authorize @csat_survey_response
+
+ @csat_survey_response.update!(
+ csat_review_notes: params[:csat_review_notes],
+ review_notes_updated_by: Current.user,
+ review_notes_updated_at: Time.current
+ )
+ end
+end
diff --git a/enterprise/app/policies/enterprise/csat_survey_response_policy.rb b/enterprise/app/policies/enterprise/csat_survey_response_policy.rb
index 4b0f5816e..8f614f873 100644
--- a/enterprise/app/policies/enterprise/csat_survey_response_policy.rb
+++ b/enterprise/app/policies/enterprise/csat_survey_response_policy.rb
@@ -10,4 +10,8 @@ module Enterprise::CsatSurveyResponsePolicy
def download?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
+
+ def update?
+ @account_user.administrator? || @account_user.custom_role&.permissions&.include?('report_manage')
+ end
end
diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
index eab6a81dc..10887a1d7 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze
# Additional features available starting with the Business plan
- BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
+ BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml
index 366cc82fa..dbe50614f 100644
--- a/enterprise/config/premium_features.yml
+++ b/enterprise/config/premium_features.yml
@@ -5,3 +5,4 @@
- sla
- captain_integration
- custom_roles
+- csat_review_notes
diff --git a/package.json b/package.json
index 68d7df574..75b6c3d38 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.9.2",
+ "version": "4.10.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller_spec.rb
new file mode 100644
index 000000000..10ff87419
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller_spec.rb
@@ -0,0 +1,85 @@
+require 'rails_helper'
+
+RSpec.describe 'Enterprise CSAT Survey Responses API', type: :request do
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:csat_survey_response) { create(:csat_survey_response, account: account) }
+
+ describe 'PATCH /api/v1/accounts/{account.id}/csat_survey_responses/:id' do
+ let(:update_params) { { csat_review_notes: 'Customer was very satisfied with the resolution' } }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated agent without permissions' do
+ it 'returns unauthorized' do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ headers: agent.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated administrator' do
+ it 'updates the csat survey response review notes' do
+ freeze_time do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ headers: administrator.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ csat_survey_response.reload
+ expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
+ expect(csat_survey_response.review_notes_updated_by).to eq(administrator)
+ expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
+ end
+ end
+ end
+
+ context 'when it is an agent with report_manage permission' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
+ let(:agent_with_role) { create(:user) }
+
+ before do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+
+ it 'updates the csat survey response review notes' do
+ freeze_time do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ headers: agent_with_role.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ csat_survey_response.reload
+ expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
+ expect(csat_survey_response.review_notes_updated_by).to eq(agent_with_role)
+ expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
+ end
+ end
+ end
+
+ context 'when csat survey response does not exist' do
+ it 'returns not found' do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/0",
+ headers: administrator.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/jobs/conversations/resolution_job_spec.rb b/spec/jobs/conversations/resolution_job_spec.rb
index a80c39498..3bc443e20 100644
--- a/spec/jobs/conversations/resolution_job_spec.rb
+++ b/spec/jobs/conversations/resolution_job_spec.rb
@@ -48,6 +48,21 @@ RSpec.describe Conversations::ResolutionJob do
end
end
+ # When a contact is deleted, there's a brief window (~50-150ms) where contact_id becomes nil
+ # but conversations still exist. If ResolutionJob runs during this window, muted? can crash
+ # trying to call blocked? on nil. Fixes # (issue).
+ it 'skips orphan conversations without a contact' do
+ account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
+ orphan_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
+ orphan_conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ resolvable_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
+
+ described_class.perform_now(account: account)
+
+ expect(orphan_conversation.reload.status).to eq('open')
+ expect(resolvable_conversation.reload.status).to eq('resolved')
+ end
+
it 'adds a label after resolution' do
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 5a4acf329..e1883b54d 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -390,6 +390,20 @@ RSpec.describe Conversation do
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has muted the conversation" }))
end
+
+ context 'when contact is missing' do
+ before do
+ conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ it 'does not change conversation status' do
+ expect { mute! }.not_to(change { conversation.reload.status })
+ end
+
+ it 'does not enqueue an activity message' do
+ expect { mute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
+ end
+ end
end
describe '#unmute!' do
@@ -418,6 +432,22 @@ RSpec.describe Conversation do
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has unmuted the conversation" }))
end
+
+ context 'when contact is missing' do
+ let(:conversation) { create(:conversation) }
+
+ before do
+ conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ it 'does not change conversation status' do
+ expect { unmute! }.not_to(change { conversation.reload.status })
+ end
+
+ it 'does not enqueue an activity message' do
+ expect { unmute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
+ end
+ end
end
describe '#muted?' do
@@ -433,6 +463,16 @@ RSpec.describe Conversation do
it 'returns false if conversation is not muted' do
expect(muted?).to be(false)
end
+
+ context 'when contact is missing' do
+ before do
+ conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ it 'returns false' do
+ expect(muted?).to be(false)
+ end
+ end
end
describe 'unread_messages' do
diff --git a/spec/services/csat_survey_service_spec.rb b/spec/services/csat_survey_service_spec.rb
index 6359fbda1..5a62e32e5 100644
--- a/spec/services/csat_survey_service_spec.rb
+++ b/spec/services/csat_survey_service_spec.rb
@@ -88,6 +88,25 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
end
+
+ context 'when survey rules block sending' do
+ before do
+ inbox.update(csat_config: {
+ 'survey_rules' => {
+ 'operator' => 'does_not_contain',
+ 'values' => ['bot-detectado']
+ }
+ })
+ conversation.update(label_list: ['bot-detectado'])
+ end
+
+ it 'does not send CSAT' do
+ service.perform
+
+ expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
+ expect(conversation.messages.where(content_type: :input_csat)).to be_empty
+ end
+ end
end
context 'when it is a WhatsApp channel' do
@@ -306,6 +325,29 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
end
+
+ context 'when survey rules block sending' do
+ before do
+ whatsapp_inbox.update(csat_config: {
+ 'template' => { 'name' => 'customer_survey_template', 'language' => 'en' },
+ 'message' => 'Please rate your experience',
+ 'survey_rules' => {
+ 'operator' => 'does_not_contain',
+ 'values' => ['bot-detectado']
+ }
+ })
+ whatsapp_conversation.update(label_list: ['bot-detectado'])
+ end
+
+ it 'does not call WhatsApp template or create a CSAT message' do
+ expect(mock_provider_service).not_to receive(:get_template_status)
+ expect(mock_provider_service).not_to receive(:send_template)
+
+ whatsapp_service.perform
+
+ expect(whatsapp_conversation.messages.where(content_type: :input_csat)).to be_empty
+ end
+ end
end
end
diff --git a/spec/services/message_templates/template/csat_survey_spec.rb b/spec/services/message_templates/template/csat_survey_spec.rb
index a2cae684b..837a30012 100644
--- a/spec/services/message_templates/template/csat_survey_spec.rb
+++ b/spec/services/message_templates/template/csat_survey_spec.rb
@@ -17,83 +17,24 @@ describe MessageTemplates::Template::CsatSurvey do
expect(conversation.messages.template.first.content_type).to eq('input_csat')
end
end
- end
- describe '#perform with contains operator' do
- let(:csat_config) do
- {
- 'display_type' => 'emoji',
- 'message' => 'Please rate your experience',
- 'survey_rules' => {
- 'operator' => 'contains',
- 'values' => %w[support help]
+ context 'when csat config is provided' do
+ let(:csat_config) do
+ {
+ 'display_type' => 'star',
+ 'message' => 'Please rate your experience'
}
- }
- end
+ end
- before do
- inbox.update(csat_config: csat_config)
- end
-
- context 'when conversation has matching labels' do
- it 'creates a CSAT survey message' do
- conversation.update(label_list: %w[support urgent])
+ before { inbox.update(csat_config: csat_config) }
+ it 'creates a CSAT message with configured attributes' do
service.perform
- expect(conversation.messages.template.count).to eq(1)
- message = conversation.messages.template.first
+ message = conversation.messages.template.last
expect(message.content_type).to eq('input_csat')
expect(message.content).to eq('Please rate your experience')
- expect(message.content_attributes['display_type']).to eq('emoji')
- end
- end
-
- context 'when conversation has no matching labels' do
- it 'does not create a CSAT survey message' do
- conversation.update(label_list: %w[billing-support payment])
-
- service.perform
-
- expect(conversation.messages.template.count).to eq(0)
- end
- end
- end
-
- describe '#perform with does_not_contain operator' do
- let(:csat_config) do
- {
- 'display_type' => 'emoji',
- 'message' => 'Please rate your experience',
- 'survey_rules' => {
- 'operator' => 'does_not_contain',
- 'values' => %w[support help]
- }
- }
- end
-
- before do
- inbox.update(csat_config: csat_config)
- end
-
- context 'when conversation does not have matching labels' do
- it 'creates a CSAT survey message' do
- conversation.update(label_list: %w[billing payment])
-
- service.perform
-
- expect(conversation.messages.template.count).to eq(1)
- expect(conversation.messages.template.first.content_type).to eq('input_csat')
- end
- end
-
- context 'when conversation has matching labels' do
- it 'does not create a CSAT survey message' do
- conversation.update(label_list: %w[support urgent])
-
- service.perform
-
- expect(conversation.messages.template.count).to eq(0)
+ expect(message.content_attributes['display_type']).to eq('star')
end
end
end