-
-
-
+
+
-
-
- {{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{{ $t('REPORT.BUSINESS_HOURS') }}
@@ -77,36 +48,54 @@
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue
new file mode 100644
index 000000000..4baecbb48
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Agents.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateGroupBy.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateGroupBy.vue
new file mode 100644
index 000000000..a360c5e8d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateGroupBy.vue
@@ -0,0 +1,67 @@
+
+
+
+ {{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue
new file mode 100644
index 000000000..b98283ced
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Inboxes.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Inboxes.vue
new file mode 100644
index 000000000..652cb06a6
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Inboxes.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Labels.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Labels.vue
new file mode 100644
index 000000000..89491b788
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Labels.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+ {{ props.option.title }}
+
+
+
+
+
+
+
+
+
+ {{ props.option.title }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Ratings.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Ratings.vue
new file mode 100644
index 000000000..49d6689ae
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Ratings.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Teams.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Teams.vue
new file mode 100644
index 000000000..f834a9e71
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/Teams.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
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
new file mode 100644
index 000000000..90578153d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js
@@ -0,0 +1,64 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import CsatMetrics from '../CsatMetrics.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const mountParams = {
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['csat-metric-card', 'woot-horizontal-bar'],
+};
+
+describe('CsatMetrics.vue', () => {
+ let getters;
+ let store;
+ let wrapper;
+ const filters = { rating: 3 };
+
+ beforeEach(() => {
+ getters = {
+ 'csat/getMetrics': () => ({ totalResponseCount: 100 }),
+ 'csat/getRatingPercentage': () => ({ 1: 10, 2: 20, 3: 30, 4: 30, 5: 10 }),
+ 'csat/getSatisfactionScore': () => 85,
+ 'csat/getResponseRate': () => 90,
+ };
+
+ store = new Vuex.Store({
+ getters,
+ });
+
+ wrapper = shallowMount(CsatMetrics, {
+ store,
+ localVue,
+ propsData: { filters },
+ ...mountParams,
+ });
+ });
+
+ 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('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.find('.report-card').exists()).toBe(false);
+ });
+
+ it('shows report card if rating filter is not enabled', async () => {
+ await wrapper.setProps({ filters: {} });
+ expect(wrapper.find('.report-card').exists()).toBe(true);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CsatMetricCard.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CsatMetricCard.spec.js
new file mode 100644
index 000000000..8c52d2e1a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CsatMetricCard.spec.js
@@ -0,0 +1,46 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import CsatMetricCard from '../CsatMetricCard.vue';
+
+import VTooltip from 'v-tooltip';
+
+const localVue = createLocalVue();
+localVue.use(VTooltip);
+
+describe('CsatMetricCard.vue', () => {
+ it('renders props correctly', () => {
+ const label = 'Total Responses';
+ const value = '100';
+ const infoText = 'Total number of responses';
+ const wrapper = shallowMount(CsatMetricCard, {
+ propsData: { label, value, infoText },
+ localVue,
+ stubs: ['fluent-icon'],
+ });
+
+ expect(wrapper.find('.heading span').text()).toMatch(label);
+ expect(wrapper.find('.metric').text()).toMatch(value);
+ expect(wrapper.find('.csat--icon').classes()).toContain('has-tooltip');
+ });
+
+ it('adds disabled class when disabled prop is true', () => {
+ const wrapper = shallowMount(CsatMetricCard, {
+ propsData: { label: '', value: '', infoText: '', disabled: true },
+ localVue,
+ stubs: ['fluent-icon'],
+ });
+
+ expect(wrapper.find('.csat--metric-card').classes()).toContain('disabled');
+ });
+
+ it('does not add disabled class when disabled prop is false', () => {
+ const wrapper = shallowMount(CsatMetricCard, {
+ propsData: { label: '', value: '', infoText: '', disabled: false },
+ localVue,
+ stubs: ['fluent-icon'],
+ });
+
+ expect(wrapper.find('.csat--metric-card').classes()).not.toContain(
+ 'disabled'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersAgents.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersAgents.spec.js
new file mode 100644
index 000000000..bb41685f9
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersAgents.spec.js
@@ -0,0 +1,59 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import ReportsFiltersAgents from '../../Filters/Agents';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const mockStore = new Vuex.Store({
+ modules: {
+ agents: {
+ namespaced: true,
+ state: {
+ agents: [],
+ },
+ getters: {
+ getAgents: state => state.agents,
+ },
+ actions: {
+ get: jest.fn(),
+ },
+ },
+ },
+});
+
+const mountParams = {
+ localVue,
+ store: mockStore,
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['multiselect'],
+};
+
+describe('ReportsFiltersAgents.vue', () => {
+ it('emits "agents-filter-selection" event when handleInput is called', () => {
+ const wrapper = shallowMount(ReportsFiltersAgents, mountParams);
+
+ const selectedAgents = [
+ { id: 1, name: 'Agent 1' },
+ { id: 2, name: 'Agent 2' },
+ ];
+ wrapper.setData({ selectedOptions: selectedAgents });
+
+ wrapper.vm.handleInput();
+
+ expect(wrapper.emitted('agents-filter-selection')).toBeTruthy();
+ expect(wrapper.emitted('agents-filter-selection')[0]).toEqual([
+ selectedAgents,
+ ]);
+ });
+
+ it('dispatches the "agents/get" action when the component is mounted', () => {
+ const dispatchSpy = jest.spyOn(mockStore, 'dispatch');
+
+ shallowMount(ReportsFiltersAgents, mountParams);
+
+ expect(dispatchSpy).toHaveBeenCalledWith('agents/get');
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateGroupBy.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateGroupBy.spec.js
new file mode 100644
index 000000000..0823b7582
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateGroupBy.spec.js
@@ -0,0 +1,45 @@
+import { shallowMount } from '@vue/test-utils';
+import ReportsFiltersDateGroupBy from '../../Filters/DateGroupBy';
+import { GROUP_BY_OPTIONS } from '../../../constants';
+
+const mountParams = {
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['multiselect'],
+};
+
+describe('ReportsFiltersDateGroupBy.vue', () => {
+ it('emits "on-grouping-change" event when changeFilterSelection is called', () => {
+ const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
+
+ const selectedFilter = GROUP_BY_OPTIONS.DAY;
+ wrapper.vm.changeFilterSelection(selectedFilter);
+
+ expect(wrapper.emitted('on-grouping-change')).toBeTruthy();
+ expect(wrapper.emitted('on-grouping-change')[0]).toEqual([selectedFilter]);
+ });
+
+ it('updates currentSelectedFilter when selectedOption is changed', async () => {
+ const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
+
+ const newSelectedOption = GROUP_BY_OPTIONS.MONTH;
+ await wrapper.setProps({ selectedOption: newSelectedOption });
+
+ expect(wrapper.vm.currentSelectedFilter).toEqual({
+ ...newSelectedOption,
+ groupBy: newSelectedOption.translationKey,
+ });
+ });
+
+ it('initializes translatedOptions correctly', () => {
+ const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
+
+ const expectedOptions = wrapper.vm.validGroupOptions.map(option => ({
+ ...option,
+ groupBy: option.translationKey,
+ }));
+
+ expect(wrapper.vm.translatedOptions).toEqual(expectedOptions);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateRange.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateRange.spec.js
new file mode 100644
index 000000000..6da4172fb
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersDateRange.spec.js
@@ -0,0 +1,42 @@
+import { shallowMount } from '@vue/test-utils';
+import ReportFiltersDateRange from '../../Filters/DateRange';
+import { DATE_RANGE_OPTIONS } from '../../../constants';
+
+const mountParams = {
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['multiselect'],
+};
+
+describe('ReportFiltersDateRange.vue', () => {
+ it('emits "on-range-change" event when updateRange is called', () => {
+ const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
+
+ const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS;
+ wrapper.vm.updateRange(selectedRange);
+
+ expect(wrapper.emitted('on-range-change')).toBeTruthy();
+ expect(wrapper.emitted('on-range-change')[0]).toEqual([selectedRange]);
+ });
+
+ it('initializes options correctly', () => {
+ const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
+
+ const expectedOptions = Object.values(DATE_RANGE_OPTIONS).map(option => ({
+ ...option,
+ name: option.translationKey,
+ }));
+
+ expect(wrapper.vm.options).toEqual(expectedOptions);
+ });
+
+ it('initializes selectedOption correctly', () => {
+ const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
+ const expectedSelectedOption = Object.values(DATE_RANGE_OPTIONS)[0];
+ expect(wrapper.vm.selectedOption).toEqual({
+ ...expectedSelectedOption,
+ name: expectedSelectedOption.translationKey,
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersInboxes.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersInboxes.spec.js
new file mode 100644
index 000000000..7b35a17e0
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersInboxes.spec.js
@@ -0,0 +1,66 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import ReportsFiltersInboxes from '../../Filters/Inboxes';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const mountParams = {
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['multiselect'],
+};
+
+describe('ReportsFiltersInboxes.vue', () => {
+ let store;
+ let inboxesModule;
+
+ beforeEach(() => {
+ inboxesModule = {
+ namespaced: true,
+ getters: {
+ getInboxes: () => () => [
+ { id: 1, name: 'Inbox 1' },
+ { id: 2, name: 'Inbox 2' },
+ ],
+ },
+ actions: {
+ get: jest.fn(),
+ },
+ };
+
+ store = new Vuex.Store({
+ modules: {
+ inboxes: inboxesModule,
+ },
+ });
+ });
+
+ it('dispatches "inboxes/get" action when component is mounted', () => {
+ shallowMount(ReportsFiltersInboxes, {
+ store,
+ localVue,
+ ...mountParams,
+ });
+ expect(inboxesModule.actions.get).toHaveBeenCalled();
+ });
+
+ it('emits "inbox-filter-selection" event when handleInput is called', () => {
+ const wrapper = shallowMount(ReportsFiltersInboxes, {
+ store,
+ localVue,
+ ...mountParams,
+ });
+
+ const selectedInbox = { id: 1, name: 'Inbox 1' };
+ wrapper.setData({ selectedOption: selectedInbox });
+
+ wrapper.vm.handleInput();
+
+ expect(wrapper.emitted('inbox-filter-selection')).toBeTruthy();
+ expect(wrapper.emitted('inbox-filter-selection')[0]).toEqual([
+ selectedInbox,
+ ]);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersLabels.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersLabels.spec.js
new file mode 100644
index 000000000..a95edefe4
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersLabels.spec.js
@@ -0,0 +1,66 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import ReportsFiltersLabels from '../../Filters/Labels';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const mountParams = {
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['multiselect'],
+};
+
+describe('ReportsFiltersLabels.vue', () => {
+ let store;
+ let labelsModule;
+
+ beforeEach(() => {
+ labelsModule = {
+ namespaced: true,
+ getters: {
+ getLabels: () => () => [
+ { id: 1, title: 'Label 1', color: 'red' },
+ { id: 2, title: 'Label 2', color: 'blue' },
+ ],
+ },
+ actions: {
+ get: jest.fn(),
+ },
+ };
+
+ store = new Vuex.Store({
+ modules: {
+ labels: labelsModule,
+ },
+ });
+ });
+
+ it('dispatches "labels/get" action when component is mounted', () => {
+ shallowMount(ReportsFiltersLabels, {
+ store,
+ localVue,
+ ...mountParams,
+ });
+ expect(labelsModule.actions.get).toHaveBeenCalled();
+ });
+
+ it('emits "labels-filter-selection" event when handleInput is called', () => {
+ const wrapper = shallowMount(ReportsFiltersLabels, {
+ store,
+ localVue,
+ ...mountParams,
+ });
+
+ const selectedLabel = { id: 1, title: 'Label 1', color: 'red' };
+ wrapper.setData({ selectedOption: selectedLabel });
+
+ wrapper.vm.handleInput();
+
+ expect(wrapper.emitted('labels-filter-selection')).toBeTruthy();
+ expect(wrapper.emitted('labels-filter-selection')[0]).toEqual([
+ selectedLabel,
+ ]);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersRatings.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersRatings.spec.js
new file mode 100644
index 000000000..f5d46b053
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersRatings.spec.js
@@ -0,0 +1,45 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import ReportFiltersRatings from '../../Filters/Ratings';
+import { CSAT_RATINGS } from 'shared/constants/messages';
+
+const mountParams = {
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['multiselect'],
+};
+
+const localVue = createLocalVue();
+
+describe('ReportFiltersRatings.vue', () => {
+ it('emits "rating-filter-selection" event when handleInput is called', () => {
+ const wrapper = shallowMount(ReportFiltersRatings, {
+ localVue,
+ ...mountParams,
+ });
+
+ const selectedRating = { value: 1, label: 'Rating 1' };
+ wrapper.setData({ selectedOption: selectedRating });
+
+ wrapper.vm.handleInput(selectedRating);
+
+ expect(wrapper.emitted('rating-filter-selection')).toBeTruthy();
+ expect(wrapper.emitted('rating-filter-selection')[0]).toEqual([
+ selectedRating,
+ ]);
+ });
+
+ it('initializes options correctly', () => {
+ const wrapper = shallowMount(ReportFiltersRatings, {
+ localVue,
+ ...mountParams,
+ });
+
+ const expectedOptions = CSAT_RATINGS.map(option => ({
+ ...option,
+ label: option.translationKey,
+ }));
+
+ expect(wrapper.vm.options).toEqual(expectedOptions);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersTeams.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersTeams.spec.js
new file mode 100644
index 000000000..3a693c32a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/Filters/FiltersTeams.spec.js
@@ -0,0 +1,62 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import ReportsFiltersTeams from '../../Filters/Teams.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const mountParams = {
+ mocks: {
+ $t: msg => msg,
+ },
+ stubs: ['multiselect'],
+};
+
+describe('ReportsFiltersTeams.vue', () => {
+ let store;
+ let teamsModule;
+
+ beforeEach(() => {
+ teamsModule = {
+ namespaced: true,
+ getters: {
+ getTeams: () => () => [
+ { id: 1, name: 'Team 1' },
+ { id: 2, name: 'Team 2' },
+ ],
+ },
+ actions: {
+ get: jest.fn(),
+ },
+ };
+
+ store = new Vuex.Store({
+ modules: {
+ teams: teamsModule,
+ },
+ });
+ });
+
+ it('dispatches "teams/get" action when component is mounted', () => {
+ shallowMount(ReportsFiltersTeams, {
+ store,
+ localVue,
+ ...mountParams,
+ });
+ expect(teamsModule.actions.get).toHaveBeenCalled();
+ });
+
+ it('emits "team-filter-selection" event when handleInput is called', () => {
+ const wrapper = shallowMount(ReportsFiltersTeams, {
+ store,
+ localVue,
+ ...mountParams,
+ });
+ wrapper.setData({ selectedOption: { id: 1, name: 'Team 1' } });
+ wrapper.vm.handleInput();
+ expect(wrapper.emitted('team-filter-selection')).toBeTruthy();
+ expect(wrapper.emitted('team-filter-selection')[0]).toEqual([
+ { id: 1, name: 'Team 1' },
+ ]);
+ });
+});
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
new file mode 100644
index 000000000..357d4dfa8
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap
@@ -0,0 +1,10 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CsatMetrics.vue computes response count correctly 1`] = `
+
+
+
+
+
+
+`;
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js
index 1e345fe22..46e7549f0 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js
@@ -7,6 +7,85 @@ export const GROUP_BY_FILTER = {
4: { id: 4, period: 'year' },
};
+export const GROUP_BY_OPTIONS = {
+ DAY: {
+ id: 'DAY',
+ period: 'day',
+ translationKey: 'REPORT.GROUPING_OPTIONS.DAY',
+ },
+ WEEK: {
+ id: 'WEEK',
+ period: 'week',
+ translationKey: 'REPORT.GROUPING_OPTIONS.WEEK',
+ },
+ MONTH: {
+ id: 'MONTH',
+ period: 'month',
+ translationKey: 'REPORT.GROUPING_OPTIONS.MONTH',
+ },
+ YEAR: {
+ id: 'YEAR',
+ period: 'year',
+ translationKey: 'REPORT.GROUPING_OPTIONS.YEAR',
+ },
+};
+
+export const DATE_RANGE_OPTIONS = {
+ LAST_7_DAYS: {
+ id: 'LAST_7_DAYS',
+ translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS',
+ offset: 6,
+ groupByOptions: [GROUP_BY_OPTIONS.DAY],
+ },
+ LAST_30_DAYS: {
+ id: 'LAST_30_DAYS',
+ translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS',
+ offset: 29,
+ groupByOptions: [GROUP_BY_OPTIONS.DAY, GROUP_BY_OPTIONS.WEEK],
+ },
+ LAST_3_MONTHS: {
+ id: 'LAST_3_MONTHS',
+ translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
+ offset: 89,
+ groupByOptions: [
+ GROUP_BY_OPTIONS.DAY,
+ GROUP_BY_OPTIONS.WEEK,
+ GROUP_BY_OPTIONS.MONTH,
+ ],
+ },
+ LAST_6_MONTHS: {
+ id: 'LAST_6_MONTHS',
+ translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
+ offset: 179,
+ groupByOptions: [
+ GROUP_BY_OPTIONS.DAY,
+ GROUP_BY_OPTIONS.WEEK,
+ GROUP_BY_OPTIONS.MONTH,
+ ],
+ },
+ LAST_YEAR: {
+ id: 'LAST_YEAR',
+ translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_YEAR',
+ offset: 364,
+ groupByOptions: [
+ GROUP_BY_OPTIONS.DAY,
+ GROUP_BY_OPTIONS.WEEK,
+ GROUP_BY_OPTIONS.MONTH,
+ ],
+ },
+ CUSTOM_DATE_RANGE: {
+ id: 'CUSTOM_DATE_RANGE',
+ translationKey: 'REPORT.DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE',
+ offset: null,
+ groupByOptions: [
+ GROUP_BY_OPTIONS.DAY,
+ GROUP_BY_OPTIONS.WEEK,
+ GROUP_BY_OPTIONS.MONTH,
+ GROUP_BY_OPTIONS.YEAR,
+ ],
+ },
+};
+
export const CHART_FONT_FAMILY =
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js
index 7b898cbea..9e264d186 100644
--- a/app/javascript/dashboard/store/modules/contacts/actions.js
+++ b/app/javascript/dashboard/store/modules/contacts/actions.js
@@ -140,6 +140,20 @@ export const actions = {
}
},
+ export: async ({ commit }) => {
+ try {
+ await ContactAPI.exportContacts();
+ commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
+ } catch (error) {
+ commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
+ if (error.response?.data?.message) {
+ throw new Error(error.response.data.message);
+ } else {
+ throw new Error(error);
+ }
+ }
+ },
+
delete: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true });
try {
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index a5d4a25df..8db97fc88 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -86,6 +86,18 @@ const actions = {
}
},
+ fetchAllAttachments: async ({ commit }, conversationId) => {
+ try {
+ const { data } = await ConversationApi.getAllAttachments(conversationId);
+ commit(types.SET_ALL_ATTACHMENTS, {
+ id: conversationId,
+ data: data.payload,
+ });
+ } catch (error) {
+ // Handle error
+ }
+ },
+
syncActiveConversationMessages: async (
{ commit, state, dispatch },
{ conversationId }
@@ -247,6 +259,10 @@ const actions = {
...response.data,
status: MESSAGE_STATUS.SENT,
});
+ commit(types.ADD_CONVERSATION_ATTACHMENTS, {
+ ...response.data,
+ status: MESSAGE_STATUS.SENT,
+ });
} catch (error) {
const errorMessage = error.response
? error.response.data.error
@@ -269,6 +285,7 @@ const actions = {
conversationId: message.conversation_id,
canReply: true,
});
+ commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
},
@@ -283,6 +300,7 @@ const actions = {
try {
const { data } = await MessageApi.delete(conversationId, messageId);
commit(types.ADD_MESSAGE, data);
+ commit(types.DELETE_CONVERSATION_ATTACHMENTS, data);
} catch (error) {
throw new Error(error);
}
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index b5b38da0f..0b1ca73d3 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -32,6 +32,11 @@ const getters = {
);
return selectedChat || {};
},
+ getSelectedChatAttachments: (_state, _getters) => {
+ const selectedChat = _getters.getSelectedChat;
+ const { attachments } = selectedChat;
+ return attachments;
+ },
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat;
diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js
index 04db1fddf..026d51d55 100644
--- a/app/javascript/dashboard/store/modules/conversations/index.js
+++ b/app/javascript/dashboard/store/modules/conversations/index.js
@@ -3,6 +3,7 @@ import types from '../../mutation-types';
import getters, { getSelectedChatConversation } from './getters';
import actions from './actions';
import { findPendingMessageIndex } from './helpers';
+import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
@@ -56,6 +57,12 @@ export const mutations = {
chat.messages.unshift(...data);
}
},
+ [types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
+ const [chat] = _state.allConversations.filter(c => c.id === id);
+ if (!chat) return;
+ Vue.set(chat, 'attachments', []);
+ chat.attachments.push(...data);
+ },
[types.SET_MISSING_MESSAGES](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return;
@@ -115,6 +122,44 @@ export const mutations = {
Vue.set(chat, 'muted', false);
},
+ [types.ADD_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
+ const { conversation_id: conversationId } = message;
+ const [chat] = getSelectedChatConversation({
+ allConversations,
+ selectedChatId: conversationId,
+ });
+
+ if (!chat) return;
+
+ const isMessageSent =
+ message.status === MESSAGE_STATUS.SENT && message.attachments;
+ if (isMessageSent) {
+ message.attachments.forEach(attachment => {
+ if (!chat.attachments.some(a => a.id === attachment.id)) {
+ chat.attachments.push(attachment);
+ }
+ });
+ }
+ },
+
+ [types.DELETE_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
+ const { conversation_id: conversationId } = message;
+ const [chat] = getSelectedChatConversation({
+ allConversations,
+ selectedChatId: conversationId,
+ });
+
+ if (!chat) return;
+
+ const isMessageSent = message.status === MESSAGE_STATUS.SENT;
+ if (isMessageSent) {
+ const attachmentIndex = chat.attachments.findIndex(
+ a => a.message_id === message.id
+ );
+ if (attachmentIndex !== -1) chat.attachments.splice(attachmentIndex, 1);
+ }
+ },
+
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
diff --git a/app/javascript/dashboard/store/modules/csat.js b/app/javascript/dashboard/store/modules/csat.js
index 4806ef3a0..2cf929862 100644
--- a/app/javascript/dashboard/store/modules/csat.js
+++ b/app/javascript/dashboard/store/modules/csat.js
@@ -85,13 +85,10 @@ export const getters = {
};
export const actions = {
- get: async function getResponses(
- { commit },
- { page = 1, from, to, user_ids } = {}
- ) {
+ get: async function getResponses({ commit }, params) {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: true });
try {
- const response = await CSATReports.get({ page, from, to, user_ids });
+ const response = await CSATReports.get(params);
commit(types.SET_CSAT_RESPONSE, response.data);
} catch (error) {
// Ignore error
@@ -99,10 +96,10 @@ export const actions = {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: false });
}
},
- getMetrics: async function getMetrics({ commit }, { from, to, user_ids }) {
+ getMetrics: async function getMetrics({ commit }, params) {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetchingMetrics: true });
try {
- const response = await CSATReports.getMetrics({ from, to, user_ids });
+ const response = await CSATReports.getMetrics(params);
commit(types.SET_CSAT_RESPONSE_METRICS, response.data);
} catch (error) {
// Ignore error
diff --git a/app/javascript/dashboard/store/modules/customViews.js b/app/javascript/dashboard/store/modules/customViews.js
index 40b126663..ba31dd815 100644
--- a/app/javascript/dashboard/store/modules/customViews.js
+++ b/app/javascript/dashboard/store/modules/customViews.js
@@ -49,6 +49,18 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
+ update: async function updateCustomViews({ commit }, obj) {
+ commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
+ try {
+ const response = await CustomViewsAPI.update(obj.id, obj);
+ commit(types.UPDATE_CUSTOM_VIEW, response.data);
+ } catch (error) {
+ const errorMessage = error?.response?.data?.message;
+ throw new Error(errorMessage);
+ } finally {
+ commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
+ }
+ },
delete: async ({ commit }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
@@ -72,6 +84,7 @@ export const mutations = {
[types.ADD_CUSTOM_VIEW]: MutationHelpers.create,
[types.SET_CUSTOM_VIEW]: MutationHelpers.set,
+ [types.UPDATE_CUSTOM_VIEW]: MutationHelpers.update,
[types.DELETE_CUSTOM_VIEW]: MutationHelpers.destroy,
};
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
index b2d68c94f..5ff27c03e 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
@@ -204,6 +204,7 @@ describe('#actions', () => {
]);
});
});
+
describe('#addMessage', () => {
it('sends correct mutations if message is incoming', () => {
const message = {
@@ -218,6 +219,7 @@ describe('#actions', () => {
types.SET_CONVERSATION_CAN_REPLY,
{ conversationId: 1, canReply: true },
],
+ [types.ADD_CONVERSATION_ATTACHMENTS, message],
]);
});
it('sends correct mutations if message is not an incoming message', () => {
@@ -436,10 +438,13 @@ describe('#actions', () => {
describe('#deleteMessage', () => {
it('sends correct actions if API is success', async () => {
const [conversationId, messageId] = [1, 1];
- axios.delete.mockResolvedValue({ data: { id: 1, content: 'deleted' } });
+ axios.delete.mockResolvedValue({
+ data: { id: 1, content: 'deleted' },
+ });
await actions.deleteMessage({ commit }, { conversationId, messageId });
expect(commit.mock.calls).toEqual([
[types.ADD_MESSAGE, { id: 1, content: 'deleted' }],
+ [types.DELETE_CONVERSATION_ATTACHMENTS, { id: 1, content: 'deleted' }],
]);
});
it('sends no actions if API is error', async () => {
@@ -554,4 +559,40 @@ describe('#addMentions', () => {
],
]);
});
+
+ describe('#fetchAllAttachments', () => {
+ it('fetches all attachments', async () => {
+ axios.get.mockResolvedValue({
+ data: {
+ payload: [
+ {
+ id: 1,
+ message_id: 1,
+ file_type: 'image',
+ data_url: '',
+ thumb_url: '',
+ },
+ ],
+ },
+ });
+ await actions.fetchAllAttachments({ commit }, 1);
+ expect(commit.mock.calls).toEqual([
+ [
+ types.SET_ALL_ATTACHMENTS,
+ {
+ id: 1,
+ data: [
+ {
+ id: 1,
+ message_id: 1,
+ file_type: 'image',
+ data_url: '',
+ thumb_url: '',
+ },
+ ],
+ },
+ ],
+ ]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
index 2a012fd53..e874b05ec 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
@@ -305,4 +305,34 @@ describe('#getters', () => {
});
});
});
+
+ describe('#getSelectedChatAttachments', () => {
+ it('Returns attachments in selected chat', () => {
+ const state = {};
+ const getSelectedChat = {
+ attachments: [
+ {
+ id: 1,
+ file_name: 'test1',
+ },
+ {
+ id: 2,
+ file_name: 'test2',
+ },
+ ],
+ };
+ expect(
+ getters.getSelectedChatAttachments(state, { getSelectedChat })
+ ).toEqual([
+ {
+ id: 1,
+ file_name: 'test1',
+ },
+ {
+ id: 2,
+ file_name: 'test2',
+ },
+ ]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
index 2c4bdb2cc..279b36872 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
@@ -278,4 +278,129 @@ describe('#mutations', () => {
expect(state.appliedFilters).toEqual([]);
});
});
+
+ describe('#SET_ALL_ATTACHMENTS', () => {
+ it('set all attachments', () => {
+ const state = {
+ allConversations: [{ id: 1 }],
+ };
+ const data = [{ id: 1, name: 'test' }];
+ mutations[types.SET_ALL_ATTACHMENTS](state, { id: 1, data });
+ expect(state.allConversations[0].attachments).toEqual(data);
+ });
+ it('set attachments key even if the attachments are empty', () => {
+ const state = {
+ allConversations: [{ id: 1 }],
+ };
+ const data = [];
+ mutations[types.SET_ALL_ATTACHMENTS](state, { id: 1, data });
+ expect(state.allConversations[0].attachments).toEqual([]);
+ });
+ });
+
+ describe('#ADD_CONVERSATION_ATTACHMENTS', () => {
+ it('add conversation attachments', () => {
+ const state = {
+ allConversations: [{ id: 1, attachments: [] }],
+ };
+ const message = {
+ conversation_id: 1,
+ status: 'sent',
+ attachments: [{ id: 1, name: 'test' }],
+ };
+
+ mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
+ expect(state.allConversations[0].attachments).toEqual(
+ message.attachments
+ );
+ });
+
+ it('should not add duplicate attachments', () => {
+ const state = {
+ allConversations: [
+ {
+ id: 1,
+ attachments: [{ id: 1, name: 'existing' }],
+ },
+ ],
+ };
+ const message = {
+ conversation_id: 1,
+ status: 'sent',
+ attachments: [
+ { id: 1, name: 'existing' },
+ { id: 2, name: 'new' },
+ ],
+ };
+
+ mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
+ expect(state.allConversations[0].attachments).toHaveLength(2);
+ expect(state.allConversations[0].attachments).toContainEqual({
+ id: 1,
+ name: 'existing',
+ });
+ expect(state.allConversations[0].attachments).toContainEqual({
+ id: 2,
+ name: 'new',
+ });
+ });
+
+ it('should not add attachments if chat not found', () => {
+ const state = {
+ allConversations: [{ id: 1, attachments: [] }],
+ };
+ const message = {
+ conversation_id: 2,
+ status: 'sent',
+ attachments: [{ id: 1, name: 'test' }],
+ };
+
+ mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
+ expect(state.allConversations[0].attachments).toHaveLength(0);
+ });
+ });
+
+ describe('#DELETE_CONVERSATION_ATTACHMENTS', () => {
+ it('delete conversation attachments', () => {
+ const state = {
+ allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
+ };
+ const message = {
+ conversation_id: 1,
+ status: 'sent',
+ id: 1,
+ };
+
+ mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
+ expect(state.allConversations[0].attachments).toHaveLength(0);
+ });
+
+ it('should not delete attachments for non-matching message id', () => {
+ const state = {
+ allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
+ };
+ const message = {
+ conversation_id: 1,
+ status: 'sent',
+ id: 2,
+ };
+
+ mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
+ expect(state.allConversations[0].attachments).toHaveLength(1);
+ });
+
+ it('should not delete attachments if chat not found', () => {
+ const state = {
+ allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
+ };
+ const message = {
+ conversation_id: 2,
+ status: 'sent',
+ id: 1,
+ };
+
+ mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
+ expect(state.allConversations[0].attachments).toHaveLength(1);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
index 5de9732c6..23dca5f3b 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
@@ -1,7 +1,7 @@
import axios from 'axios';
import { actions } from '../../customViews';
import * as types from '../../../mutation-types';
-import customViewList from './fixtures';
+import { customViewList, updateCustomViewList } from './fixtures';
const commit = jest.fn();
global.axios = axios;
@@ -67,4 +67,28 @@ describe('#actions', () => {
]);
});
});
+
+ describe('#update', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.patch.mockResolvedValue({ data: updateCustomViewList[0] });
+ await actions.update(
+ { commit },
+ updateCustomViewList[0].id,
+ updateCustomViewList[0]
+ );
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
+ [types.default.UPDATE_CUSTOM_VIEW, updateCustomViewList[0]],
+ [types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.patch.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(actions.update({ commit }, 1)).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
+ [types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js b/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js
index 1a620add9..3cd57478e 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js
@@ -1,4 +1,4 @@
-export default [
+export const customViewList = [
{
name: 'Custom view',
filter_type: 0,
@@ -34,3 +34,31 @@ export default [
},
},
];
+
+export const updateCustomViewList = [
+ {
+ id: 1,
+ name: 'Open',
+ filter_type: 'conversation',
+ query: {
+ payload: [
+ {
+ attribute_key: 'status',
+ attribute_model: 'standard',
+ filter_operator: 'equal_to',
+ values: ['open'],
+ query_operator: 'and',
+ custom_attribute_type: '',
+ },
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'equal_to',
+ values: [52],
+ custom_attribute_type: '',
+ },
+ ],
+ },
+ created_at: '2022-02-08T03:17:38.761Z',
+ updated_at: '2023-06-05T13:57:48.478Z',
+ },
+];
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js b/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js
index ec50a7d5d..5428e4461 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js
@@ -1,5 +1,5 @@
import { getters } from '../../customViews';
-import customViewList from './fixtures';
+import { customViewList } from './fixtures';
describe('#getters', () => {
it('getCustomViews', () => {
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/customViews/mutations.spec.js
index 05da48b6d..d5eacb236 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/mutations.spec.js
@@ -1,6 +1,6 @@
import types from '../../../mutation-types';
import { mutations } from '../../customViews';
-import customViewList from './fixtures';
+import { customViewList, updateCustomViewList } from './fixtures';
describe('#mutations', () => {
describe('#SET_CUSTOM_VIEW', () => {
@@ -26,4 +26,12 @@ describe('#mutations', () => {
expect(state.records).toEqual([customViewList[0]]);
});
});
+
+ describe('#UPDATE_CUSTOM_VIEW', () => {
+ it('update custom view record', () => {
+ const state = { records: [updateCustomViewList[0]] };
+ mutations[types.UPDATE_CUSTOM_VIEW](state, updateCustomViewList[0]);
+ expect(state.records).toEqual(updateCustomViewList);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index ae15ff572..905b62b2e 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -49,6 +49,10 @@ export default {
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
+ SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
+ ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
+ DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
+
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
// Inboxes
@@ -217,6 +221,7 @@ export default {
SET_CUSTOM_VIEW_UI_FLAG: 'SET_CUSTOM_VIEW_UI_FLAG',
SET_CUSTOM_VIEW: 'SET_CUSTOM_VIEW',
ADD_CUSTOM_VIEW: 'ADD_CUSTOM_VIEW',
+ UPDATE_CUSTOM_VIEW: 'UPDATE_CUSTOM_VIEW',
DELETE_CUSTOM_VIEW: 'DELETE_CUSTOM_VIEW',
// Bulk Actions
diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js
index 80cbb6de0..9bb8314ca 100755
--- a/app/javascript/packs/sdk.js
+++ b/app/javascript/packs/sdk.js
@@ -11,6 +11,7 @@ import {
hasUserKeys,
} from '../sdk/cookieHelpers';
import { addClasses, removeClasses } from '../sdk/DOMHelpers';
+import { setCookieWithDomain } from '../sdk/cookieHelpers';
import { SDK_SET_BUBBLE_VISIBILITY } from 'shared/constants/sharedFrameEvents';
const runSDK = ({ baseUrl, websiteToken }) => {
if (window.$chatwoot) {
@@ -19,6 +20,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
const chatwootSettings = window.chatwootSettings || {};
let locale = chatwootSettings.locale;
+ let baseDomain = chatwootSettings.baseDomain;
if (chatwootSettings.useBrowserLanguage) {
locale = window.navigator.language.replace('-', '_');
@@ -26,6 +28,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
window.$chatwoot = {
baseUrl,
+ baseDomain,
hasLoaded: false,
hideMessageBubble: chatwootSettings.hideMessageBubble || false,
isOpen: false,
@@ -90,9 +93,9 @@ const runSDK = ({ baseUrl, websiteToken }) => {
window.$chatwoot.identifier = identifier;
window.$chatwoot.user = user;
IFrameHelper.sendMessage('set-user', { identifier, user });
- Cookies.set(userCookieName, hashToBeStored, {
- expires: 365,
- sameSite: 'Lax',
+
+ setCookieWithDomain(userCookieName, hashToBeStored, {
+ baseDomain,
});
},
@@ -146,6 +149,12 @@ const runSDK = ({ baseUrl, websiteToken }) => {
IFrameHelper.sendMessage('set-locale', { locale: localeToBeUsed });
},
+ setColorScheme(darkMode = 'light') {
+ IFrameHelper.sendMessage('set-color-scheme', {
+ darkMode: getDarkMode(darkMode),
+ });
+ },
+
reset() {
if (window.$chatwoot.isOpen) {
IFrameHelper.events.toggleBubble();
diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js
index 9ded2c9bf..9d51599b9 100644
--- a/app/javascript/sdk/IFrameHelper.js
+++ b/app/javascript/sdk/IFrameHelper.js
@@ -29,7 +29,7 @@ import {
CHATWOOT_READY,
} from '../widget/constants/sdkEvents';
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
-import { getUserCookieName } from './cookieHelpers';
+import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
import {
getAlertAudio,
initOnEvents,
@@ -38,17 +38,16 @@ import { isFlatWidgetStyle } from './settingsHelper';
import { popoutChatWindow } from '../widget/helpers/popoutHelper';
import addHours from 'date-fns/addHours';
-const updateAuthCookie = cookieContent =>
- Cookies.set('cw_conversation', cookieContent, {
- expires: 365,
- sameSite: 'Lax',
+const updateAuthCookie = (cookieContent, baseDomain = '') =>
+ setCookieWithDomain('cw_conversation', cookieContent, {
+ baseDomain,
});
-const updateCampaignReadStatus = () => {
+const updateCampaignReadStatus = baseDomain => {
const expireBy = addHours(new Date(), 1);
- Cookies.set('cw_snooze_campaigns_till', Number(expireBy), {
+ setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), {
expires: expireBy,
- sameSite: 'Lax',
+ baseDomain,
});
};
@@ -154,7 +153,7 @@ export const IFrameHelper = {
events: {
loaded: message => {
- updateAuthCookie(message.config.authToken);
+ updateAuthCookie(message.config.authToken, window.$chatwoot.baseDomain);
window.$chatwoot.hasLoaded = true;
const campaignsSnoozedTill = Cookies.get('cw_snooze_campaigns_till');
IFrameHelper.sendMessage('config-set', {
@@ -200,11 +199,11 @@ export const IFrameHelper = {
},
setAuthCookie({ data: { widgetAuthToken } }) {
- updateAuthCookie(widgetAuthToken);
+ updateAuthCookie(widgetAuthToken, window.$chatwoot.baseDomain);
},
setCampaignReadOn() {
- updateCampaignReadStatus();
+ updateCampaignReadStatus(window.$chatwoot.baseDomain);
},
toggleBubble: state => {
diff --git a/app/javascript/sdk/constants.js b/app/javascript/sdk/constants.js
index 7bf84d430..7c172d159 100644
--- a/app/javascript/sdk/constants.js
+++ b/app/javascript/sdk/constants.js
@@ -1,3 +1,3 @@
export const BUBBLE_DESIGN = ['standard', 'expanded_bubble'];
export const WIDGET_DESIGN = ['standard', 'flat'];
-export const DARK_MODE = ['light', 'auto'];
+export const DARK_MODE = ['light', 'auto', 'dark'];
diff --git a/app/javascript/sdk/cookieHelpers.js b/app/javascript/sdk/cookieHelpers.js
index afdc12cc2..64a7afbf7 100644
--- a/app/javascript/sdk/cookieHelpers.js
+++ b/app/javascript/sdk/cookieHelpers.js
@@ -1,4 +1,5 @@
import md5 from 'md5';
+import Cookies from 'js-cookie';
const REQUIRED_USER_KEYS = ['avatar_url', 'email', 'name'];
const ALLOWED_USER_ATTRIBUTES = [...REQUIRED_USER_KEYS, 'identifier_hash'];
@@ -21,3 +22,17 @@ export const computeHashForUserData = (...args) => md5(getUserString(...args));
export const hasUserKeys = user =>
REQUIRED_USER_KEYS.reduce((acc, key) => acc || !!user[key], false);
+
+export const setCookieWithDomain = (
+ name,
+ value,
+ { expires = 365, baseDomain = undefined } = {}
+) => {
+ const cookieOptions = {
+ expires,
+ sameSite: 'Lax',
+ domain: baseDomain,
+ };
+
+ Cookies.set(name, value, cookieOptions);
+};
diff --git a/app/javascript/sdk/specs/cookieHelpers.spec.js b/app/javascript/sdk/specs/cookieHelpers.spec.js
index 103224760..7da09315f 100644
--- a/app/javascript/sdk/specs/cookieHelpers.spec.js
+++ b/app/javascript/sdk/specs/cookieHelpers.spec.js
@@ -1,7 +1,9 @@
+import Cookies from 'js-cookie';
import {
getUserCookieName,
getUserString,
hasUserKeys,
+ setCookieWithDomain,
} from '../cookieHelpers';
describe('#getUserCookieName', () => {
@@ -47,3 +49,78 @@ describe('#hasUserKeys', () => {
expect(hasUserKeys({ avatar_url: 'randomValue' })).toBe(true);
});
});
+
+// Mock the 'set' method of the 'Cookies' object
+jest.mock('js-cookie', () => ({
+ set: jest.fn(),
+}));
+
+describe('setCookieWithDomain', () => {
+ afterEach(() => {
+ // Clear mock calls after each test
+ Cookies.set.mockClear();
+ });
+
+ it('should set a cookie with default parameters', () => {
+ setCookieWithDomain('myCookie', 'cookieValue');
+
+ expect(Cookies.set).toHaveBeenCalledWith(
+ 'myCookie',
+ 'cookieValue',
+ expect.objectContaining({
+ expires: 365,
+ sameSite: 'Lax',
+ domain: undefined,
+ })
+ );
+ });
+
+ it('should set a cookie with custom expiration and sameSite attribute', () => {
+ setCookieWithDomain('myCookie', 'cookieValue', {
+ expires: 30,
+ });
+
+ expect(Cookies.set).toHaveBeenCalledWith(
+ 'myCookie',
+ 'cookieValue',
+ expect.objectContaining({
+ expires: 30,
+ sameSite: 'Lax',
+ domain: undefined,
+ })
+ );
+ });
+
+ it('should set a cookie with a specific base domain', () => {
+ setCookieWithDomain('myCookie', 'cookieValue', {
+ baseDomain: 'example.com',
+ });
+
+ expect(Cookies.set).toHaveBeenCalledWith(
+ 'myCookie',
+ 'cookieValue',
+ expect.objectContaining({
+ expires: 365,
+ sameSite: 'Lax',
+ domain: 'example.com',
+ })
+ );
+ });
+
+ it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => {
+ setCookieWithDomain('myCookie', 'cookieValue', {
+ expires: 7,
+ baseDomain: 'example.com',
+ });
+
+ expect(Cookies.set).toHaveBeenCalledWith(
+ 'myCookie',
+ 'cookieValue',
+ expect.objectContaining({
+ expires: 7,
+ sameSite: 'Lax',
+ domain: 'example.com',
+ })
+ );
+ });
+});
diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
index f19a9e57c..992be124f 100644
--- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json
+++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
@@ -196,6 +196,10 @@
"M5 18C5 17.4477 5.44772 17 6 17H10C10.5523 17 11 17.4477 11 18V21C11 21.5523 10.5523 22 10 22H6C5.44772 22 5 21.5523 5 21V18Z",
"M13 18C13 17.4477 13.4477 17 14 17H18C18.5523 17 19 17.4477 19 18V21C19 21.5523 18.5523 22 18 22H14C13.4477 22 13 21.5523 13 21V18Z"
],
+ "preview-link-outline": [
+ "M4.524 6.25a.75.75 0 0 1 .75-.75H18.73a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-.75.75H5.274a.75.75 0 0 1-.75-.75v-3.5Zm1.5.75v2H17.98V7H6.024ZM14.23 11.979a.75.75 0 0 0-.75.75v4.5c0 .414.335.75.75.75h4.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-4.5Zm.75 4.5v-3h3v3h-3ZM4.524 13.25a.75.75 0 0 1 .75-.75h5.976a.75.75 0 0 1 0 1.5H5.274a.75.75 0 0 1-.75-.75ZM5.274 16a.75.75 0 0 0 0 1.5h5.976a.75.75 0 0 0 0-1.5H5.274Z",
+ "M2 5.75A2.75 2.75 0 0 1 4.75 3h14.5A2.75 2.75 0 0 1 22 5.75v12.5A2.75 2.75 0 0 1 19.25 21H4.75A2.75 2.75 0 0 1 2 18.25V5.75ZM4.75 4.5c-.69 0-1.25.56-1.25 1.25v12.5c0 .69.56 1.25 1.25 1.25h14.5c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25H4.75Z"
+ ],
"priority-urgent-outline": [
"M2.33325 2.91667C2.33325 2.27233 2.85559 1.75 3.49992 1.75C4.14425 1.75 4.66659 2.27233 4.66659 2.91667V8.16667C4.66659 8.811 4.14425 9.33333 3.49992 9.33333C2.85559 9.33333 2.33325 8.811 2.33325 8.16667V2.91667Z",
"M2.33325 11.0833C2.33325 10.439 2.85559 9.91667 3.49992 9.91667C4.14425 9.91667 4.66659 10.439 4.66659 11.0833C4.66659 11.7277 4.14425 12.25 3.49992 12.25C2.85559 12.25 2.33325 11.7277 2.33325 11.0833Z",
diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js
index c21248607..1713fe70c 100644
--- a/app/javascript/shared/constants/messages.js
+++ b/app/javascript/shared/constants/messages.js
@@ -59,24 +59,28 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
export const CSAT_RATINGS = [
{
key: 'disappointed',
+ translationKey: 'CSAT.RATINGS.POOR',
emoji: '😞',
value: 1,
color: '#FDAD2A',
},
{
key: 'expressionless',
+ translationKey: 'CSAT.RATINGS.FAIR',
emoji: '😑',
value: 2,
color: '#FFC532',
},
{
key: 'neutral',
+ translationKey: 'CSAT.RATINGS.AVERAGE',
emoji: '😐',
value: 3,
color: '#FCEC56',
},
{
key: 'grinning',
+ translationKey: 'CSAT.RATINGS.GOOD',
emoji: '😀',
value: 4,
color: '#6FD86F',
@@ -84,6 +88,7 @@ export const CSAT_RATINGS = [
{
key: 'smiling',
emoji: '😍',
+ translationKey: 'CSAT.RATINGS.EXCELLENT',
value: 5,
color: '#44CE4B',
},
diff --git a/app/javascript/shared/helpers/DateHelper.js b/app/javascript/shared/helpers/DateHelper.js
index 18e4efdf1..dcff6e4c5 100644
--- a/app/javascript/shared/helpers/DateHelper.js
+++ b/app/javascript/shared/helpers/DateHelper.js
@@ -2,6 +2,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import isToday from 'date-fns/isToday';
import isYesterday from 'date-fns/isYesterday';
+import { endOfDay, getUnixTime, startOfDay } from 'date-fns';
export const formatUnixDate = (date, dateFormat = 'MMM dd, yyyy') => {
const unixDate = fromUnixTime(date);
@@ -31,6 +32,12 @@ export const isTimeAfter = (h1, m1, h2, m2) => {
return true;
};
+/** Get start of day as a UNIX timestamp */
+export const getUnixStartOfDay = date => getUnixTime(startOfDay(date));
+
+/** Get end of day as a UNIX timestamp */
+export const getUnixEndOfDay = date => getUnixTime(endOfDay(date));
+
export const generateRelativeTime = (value, unit, languageCode) => {
const code = languageCode?.replace(/_/g, '-'); // Hacky fix we need to handle it from source
const rtf = new Intl.RelativeTimeFormat(code, {
diff --git a/app/javascript/shared/helpers/KeyboardHelpers.js b/app/javascript/shared/helpers/KeyboardHelpers.js
index 5ef9d9f6a..11e5af598 100644
--- a/app/javascript/shared/helpers/KeyboardHelpers.js
+++ b/app/javascript/shared/helpers/KeyboardHelpers.js
@@ -94,6 +94,14 @@ export const hasPressedArrowDownKey = e => {
return e.keyCode === 40;
};
+export const hasPressedArrowLeftKey = e => {
+ return e.keyCode === 37;
+};
+
+export const hasPressedArrowRightKey = e => {
+ return e.keyCode === 39;
+};
+
export const hasPressedCommandPlusKKey = e => {
return e.metaKey && e.keyCode === 75;
};
diff --git a/app/javascript/shared/helpers/localStorage.js b/app/javascript/shared/helpers/localStorage.js
index 9f46c48ca..2c002bd5c 100644
--- a/app/javascript/shared/helpers/localStorage.js
+++ b/app/javascript/shared/helpers/localStorage.js
@@ -4,8 +4,9 @@ export const LocalStorage = {
},
get(key) {
- const value = window.localStorage.getItem(key);
+ let value = null;
try {
+ value = window.localStorage.getItem(key);
return typeof value === 'string' ? JSON.parse(value) : value;
} catch (error) {
return value;
diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue
index 7d158eb74..d8a809d5d 100755
--- a/app/javascript/widget/App.vue
+++ b/app/javascript/widget/App.vue
@@ -2,6 +2,7 @@
@@ -13,6 +14,7 @@
'is-widget-right': isRightAligned,
'is-bubble-hidden': hideMessageBubble,
'is-flat-design': isWidgetStyleFlat,
+ dark: prefersDarkMode,
}"
>
@@ -65,6 +67,7 @@ export default {
isFetchingList: 'conversation/getIsFetchingList',
isRightAligned: 'appConfig/isRightAligned',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
+ darkMode: 'appConfig/darkMode',
messageCount: 'conversation/getMessageCount',
unreadMessageCount: 'conversation/getUnreadMessageCount',
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
@@ -75,6 +78,12 @@ export default {
isRNWebView() {
return RNHelper.isRNWebView();
},
+ prefersDarkMode() {
+ const isOSOnDarkMode =
+ this.darkMode === 'auto' &&
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
+ return isOSOnDarkMode || this.darkMode === 'dark';
+ },
},
watch: {
activeCampaign() {
@@ -108,6 +117,7 @@ export default {
'setReferrerHost',
'setWidgetColor',
'setBubbleVisibility',
+ 'setColorScheme',
]),
...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
...mapActions('campaign', [
@@ -298,6 +308,8 @@ export default {
} else if (message.event === 'set-locale') {
this.setLocale(message.locale);
this.setBubbleLabel();
+ } else if (message.event === 'set-color-scheme') {
+ this.setColorScheme(message.darkMode);
} else if (message.event === 'toggle-open') {
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue
index 32dda6b76..ae1c10c37 100755
--- a/app/javascript/widget/components/AgentMessage.vue
+++ b/app/javascript/widget/components/AgentMessage.vue
@@ -42,11 +42,10 @@
- {{ agentName }}
-
+ />
diff --git a/app/javascript/widget/components/ConversationWrap.vue b/app/javascript/widget/components/ConversationWrap.vue
index eff57fd86..1c589488d 100755
--- a/app/javascript/widget/components/ConversationWrap.vue
+++ b/app/javascript/widget/components/ConversationWrap.vue
@@ -60,7 +60,7 @@ export default {
isAgentTyping: 'conversation/getIsAgentTyping',
}),
colorSchemeClass() {
- return `${this.darkMode === 'light' ? 'light' : 'dark'}`;
+ return `${this.darkMode === 'dark' ? 'dark-scheme' : 'light-scheme'}`;
},
},
watch: {
@@ -117,10 +117,10 @@ export default {
overflow-y: auto;
color-scheme: light dark;
- &.light {
+ &.light-scheme {
color-scheme: light;
}
- &.dark {
+ &.dark-scheme {
color-scheme: dark;
}
}
diff --git a/app/javascript/widget/mixins/darkModeMixin.js b/app/javascript/widget/mixins/darkModeMixin.js
index dd8f19cfc..87d7c8048 100644
--- a/app/javascript/widget/mixins/darkModeMixin.js
+++ b/app/javascript/widget/mixins/darkModeMixin.js
@@ -9,6 +9,9 @@ export default {
if (this.darkMode === 'light') {
return light;
}
+ if (this.darkMode === 'dark') {
+ return dark;
+ }
return light + ' ' + dark;
},
},
diff --git a/app/javascript/widget/store/modules/appConfig.js b/app/javascript/widget/store/modules/appConfig.js
index 91149a2c3..a87276672 100644
--- a/app/javascript/widget/store/modules/appConfig.js
+++ b/app/javascript/widget/store/modules/appConfig.js
@@ -1,5 +1,6 @@
import {
SET_BUBBLE_VISIBILITY,
+ SET_COLOR_SCHEME,
SET_REFERRER_HOST,
SET_WIDGET_APP_CONFIG,
SET_WIDGET_COLOR,
@@ -55,6 +56,9 @@ export const actions = {
setWidgetColor({ commit }, widgetColor) {
commit(SET_WIDGET_COLOR, widgetColor);
},
+ setColorScheme({ commit }, darkMode) {
+ commit(SET_COLOR_SCHEME, darkMode);
+ },
setReferrerHost({ commit }, referrerHost) {
commit(SET_REFERRER_HOST, referrerHost);
},
@@ -83,6 +87,9 @@ export const mutations = {
[SET_BUBBLE_VISIBILITY]($state, hideMessageBubble) {
$state.hideMessageBubble = hideMessageBubble;
},
+ [SET_COLOR_SCHEME]($state, darkMode) {
+ $state.darkMode = darkMode;
+ },
};
export default {
diff --git a/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js b/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js
index 58441e0c8..89aea11a0 100644
--- a/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js
+++ b/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js
@@ -24,4 +24,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([['SET_WIDGET_COLOR', '#eaeaea']]);
});
});
+
+ describe('#setColorScheme', () => {
+ it('creates actions for dark mode properly', () => {
+ actions.setColorScheme({ commit }, 'dark');
+ expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
+ });
+ });
});
diff --git a/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js b/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js
index dc17014fc..e79235e28 100644
--- a/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js
+++ b/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js
@@ -24,4 +24,12 @@ describe('#mutations', () => {
expect(state.widgetColor).toEqual('#00bcd4');
});
});
+
+ describe('#SET_COLOR_SCHEME', () => {
+ it('sets dark mode properly', () => {
+ const state = { darkMode: 'light' };
+ mutations.SET_COLOR_SCHEME(state, 'dark');
+ expect(state.darkMode).toEqual('dark');
+ });
+ });
});
diff --git a/app/javascript/widget/store/types.js b/app/javascript/widget/store/types.js
index dca4d5dc9..17398b682 100644
--- a/app/javascript/widget/store/types.js
+++ b/app/javascript/widget/store/types.js
@@ -2,6 +2,7 @@ export const CLEAR_CONVERSATION_ATTRIBUTES = 'CLEAR_CONVERSATION_ATTRIBUTES';
export const SET_CONVERSATION_ATTRIBUTES = 'SET_CONVERSATION_ATTRIBUTES';
export const SET_WIDGET_APP_CONFIG = 'SET_WIDGET_APP_CONFIG';
export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR';
+export const SET_COLOR_SCHEME = 'SET_COLOR_SCHEME';
export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb
new file mode 100644
index 000000000..0ae8b3892
--- /dev/null
+++ b/app/jobs/account/contacts_export_job.rb
@@ -0,0 +1,47 @@
+class Account::ContactsExportJob < ApplicationJob
+ queue_as :low
+
+ def perform(account_id, column_names)
+ account = Account.find(account_id)
+ headers = valid_headers(column_names)
+ generate_csv(account, headers)
+ file_url = account_contact_export_url(account)
+
+ AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
+ end
+
+ def generate_csv(account, headers)
+ csv_data = CSV.generate do |csv|
+ csv << headers
+ account.contacts.each do |contact|
+ csv << headers.map { |header| contact.send(header) }
+ end
+ end
+
+ attach_export_file(account, csv_data)
+ end
+
+ def valid_headers(column_names)
+ columns = (column_names.presence || default_columns)
+ headers = columns.map { |column| column if Contact.column_names.include?(column) }
+ headers.compact
+ end
+
+ def attach_export_file(account, csv_data)
+ return if csv_data.blank?
+
+ account.contacts_export.attach(
+ io: StringIO.new(csv_data),
+ filename: "#{account.name}_#{account.id}_contacts.csv",
+ content_type: 'text/csv'
+ )
+ end
+
+ def account_contact_export_url(account)
+ Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export)
+ end
+
+ def default_columns
+ %w[id name email phone_number]
+ end
+end
diff --git a/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb b/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb
index 1b11c32c4..ade3eb082 100644
--- a/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb
+++ b/app/jobs/channels/whatsapp/templates_sync_scheduler_job.rb
@@ -3,10 +3,8 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
def perform
Channel::Whatsapp.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL',
- 15.minutes.ago).find_in_batches do |channels_batch|
- channels_batch.each do |channel|
- Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
- end
+ 3.hours.ago).limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT).all.each do |channel|
+ Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
end
end
end
diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb
index 9d54462e2..fe615980a 100644
--- a/app/jobs/hook_job.rb
+++ b/app/jobs/hook_job.rb
@@ -20,8 +20,11 @@ class HookJob < ApplicationJob
return unless ['message.created'].include?(event_name)
message = event_data[:message]
-
- Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
+ if message.attachments.blank?
+ ::SendOnSlackJob.perform_later(message, hook)
+ else
+ ::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
+ end
end
def process_dialogflow_integration(hook, event_name, event_data)
diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb
index 9f9393df7..f2f1bd2e9 100644
--- a/app/jobs/inboxes/fetch_imap_emails_job.rb
+++ b/app/jobs/inboxes/fetch_imap_emails_job.rb
@@ -7,9 +7,10 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
return unless should_fetch_email?(channel)
process_email_for_channel(channel)
- rescue *ExceptionList::IMAP_EXCEPTIONS
+ rescue *ExceptionList::IMAP_EXCEPTIONS => e
+ Rails.logger.error e
channel.authorization_error!
- rescue EOFError, OpenSSL::SSL::SSLError => e
+ rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError => e
Rails.logger.error e
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
@@ -39,14 +40,16 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
received_mails(imap_inbox).each do |message_id|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
+ mail_info_logger(channel, inbound_mail, message_id)
+
next if email_already_present?(channel, inbound_mail, last_email_time)
process_mail(inbound_mail, channel)
end
end
- def email_already_present?(channel, inbound_mail, last_email_time)
- processed_email?(inbound_mail, last_email_time) || channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
+ def email_already_present?(channel, inbound_mail, _last_email_time)
+ channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
end
def received_mails(imap_inbox)
@@ -75,12 +78,21 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
received_mails(imap_inbox).each do |message_id|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
+ mail_info_logger(channel, inbound_mail, message_id)
+
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
process_mail(inbound_mail, channel)
end
end
+ def mail_info_logger(channel, inbound_mail, message_id)
+ return if Rails.env.test?
+
+ Rails.logger.info("
+ #{channel.provider} Email id: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}, message_id: #{message_id}")
+ end
+
def authenticated_imap_inbox(channel, access_token, auth_method)
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
imap.authenticate(auth_method, channel.imap_login, access_token)
@@ -112,6 +124,8 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
Imap::ImapMailbox.new.process(inbound_mail, channel)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
+ Rails.logger.error("
+ #{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
end
# Making sure the access token is valid for microsoft provider
diff --git a/app/jobs/send_on_slack_job.rb b/app/jobs/send_on_slack_job.rb
new file mode 100644
index 000000000..e9b84731b
--- /dev/null
+++ b/app/jobs/send_on_slack_job.rb
@@ -0,0 +1,7 @@
+class SendOnSlackJob < ApplicationJob
+ queue_as :medium
+
+ def perform(message, hook)
+ Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
+ end
+end
diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb
index ad02a3c18..ef4dd0247 100644
--- a/app/jobs/webhooks/instagram_events_job.rb
+++ b/app/jobs/webhooks/instagram_events_job.rb
@@ -31,6 +31,6 @@ class Webhooks::InstagramEventsJob < ApplicationJob
end
def messages(entry)
- (entry[:messaging].presence || entry[:standby])
+ (entry[:messaging].presence || entry[:standby] || [])
end
end
diff --git a/app/mailboxes/imap/imap_mailbox.rb b/app/mailboxes/imap/imap_mailbox.rb
index a9ed0a7c9..fffba4307 100644
--- a/app/mailboxes/imap/imap_mailbox.rb
+++ b/app/mailboxes/imap/imap_mailbox.rb
@@ -45,23 +45,49 @@ class Imap::ImapMailbox
end
end
+ def find_conversation_by_reference_ids
+ return if @inbound_mail.references.blank? && in_reply_to.present?
+
+ message = find_message_by_references
+
+ return if message.nil?
+
+ @inbox.conversations.find(message.conversation_id)
+ end
+
def in_reply_to
@inbound_mail.in_reply_to
end
+ def find_message_by_references
+ message_to_return = nil
+
+ references = Array.wrap(@inbound_mail.references)
+
+ references.each do |message_id|
+ message = @inbox.messages.find_by(source_id: message_id)
+ message_to_return = message if message.present?
+ end
+ message_to_return
+ end
+
def find_or_create_conversation
- @conversation = find_conversation_by_in_reply_to || ::Conversation.create!({ account_id: @account.id,
- inbox_id: @inbox.id,
- contact_id: @contact.id,
- contact_inbox_id: @contact_inbox.id,
- additional_attributes: {
- source: 'email',
- in_reply_to: in_reply_to,
- mail_subject: @processed_mail.subject,
- initiated_at: {
- timestamp: Time.now.utc
- }
- } })
+ @conversation = find_conversation_by_in_reply_to || find_conversation_by_reference_ids || ::Conversation.create!(
+ {
+ account_id: @account.id,
+ inbox_id: @inbox.id,
+ contact_id: @contact.id,
+ contact_inbox_id: @contact_inbox.id,
+ additional_attributes: {
+ source: 'email',
+ in_reply_to: in_reply_to,
+ mail_subject: @processed_mail.subject,
+ initiated_at: {
+ timestamp: Time.now.utc
+ }
+ }
+ }
+ )
end
def find_or_create_contact
diff --git a/app/mailboxes/mailbox_helper.rb b/app/mailboxes/mailbox_helper.rb
index fd812d7d4..ec3685cc3 100644
--- a/app/mailboxes/mailbox_helper.rb
+++ b/app/mailboxes/mailbox_helper.rb
@@ -24,15 +24,53 @@ module MailboxHelper
return if @message.blank?
processed_mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS).each do |mail_attachment|
- attachment = @message.attachments.new(
- account_id: @conversation.account_id,
- file_type: 'file'
- )
- attachment.file.attach(mail_attachment[:blob])
+ if inline_attachment?(mail_attachment)
+ embed_inline_image_source(mail_attachment)
+ else
+ attachment = @message.attachments.new(
+ account_id: @conversation.account_id,
+ file_type: 'file'
+ )
+ attachment.file.attach(mail_attachment[:blob])
+ end
end
@message.save!
end
+ def embed_inline_image_source(mail_attachment)
+ if processed_mail.serialized_data[:html_content].present?
+ upload_inline_image(mail_attachment)
+ elsif processed_mail.serialized_data[:text_content].present?
+ embed_plain_text_email_with_inline_image(mail_attachment)
+ end
+ end
+
+ def upload_inline_image(mail_attachment)
+ content_id = mail_attachment[:original].cid
+
+ @message.content_attributes[:email][:html_content][:full] = processed_mail.serialized_data[:html_content][:full].gsub(
+ "cid:#{content_id}", inline_image_url(mail_attachment[:blob]).to_s
+ )
+ @message.save!
+ end
+
+ def inline_attachment?(mail_attachment)
+ mail_attachment[:original].inline?
+ end
+
+ def embed_plain_text_email_with_inline_image(mail_attachment)
+ attachment_name = mail_attachment[:original].filename
+
+ @message.content_attributes[:email][:text_content][:full] = processed_mail.serialized_data[:text_content][:reply].gsub(
+ "[image: #{attachment_name}]", "
}\")
\""
+ )
+ @message.save!
+ end
+
+ def inline_image_url(blob)
+ Rails.application.routes.url_helpers.url_for(blob)
+ end
+
def create_contact
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: processed_mail.original_sender,
diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb
index c0b3e1697..c1f7bd81c 100644
--- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb
+++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb
@@ -43,6 +43,14 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
+ def contact_export_complete(file_url)
+ return unless smtp_config_set_or_development?
+
+ @action_url = file_url
+ subject = "Your contact's export file is available to download."
+ send_mail_with_liquid(to: admin_emails, subject: subject) and return
+ end
+
private
def admin_emails
diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb
index b118df107..79938a97c 100644
--- a/app/mailers/conversation_reply_mailer.rb
+++ b/app/mailers/conversation_reply_mailer.rb
@@ -79,8 +79,12 @@ class ConversationReplyMailer < ApplicationMailer
@conversation.messages.chat.where.not(message_type: :incoming)&.last
end
- def assignee_name
- @assignee_name ||= @agent&.available_name || 'Notifications'
+ def sender_name
+ @sender_name ||= current_message&.sender&.available_name || @agent&.available_name || 'Notifications'
+ end
+
+ def current_message
+ @message || @conversation.messages.outgoing.last
end
def mail_subject
@@ -97,7 +101,7 @@ class ConversationReplyMailer < ApplicationMailer
def reply_email
if should_use_conversation_email_address?
- I18n.t('conversations.reply.email.header.reply_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
+ I18n.t('conversations.reply.email.header.reply_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
reply_email: "#{@conversation.uuid}@#{@account.inbound_email_domain}")
else
@inbox.email_address || @agent&.email
@@ -106,21 +110,17 @@ class ConversationReplyMailer < ApplicationMailer
def from_email_with_name
if should_use_conversation_email_address?
- I18n.t('conversations.reply.email.header.from_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
+ I18n.t('conversations.reply.email.header.from_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
from_email: parse_email(@account.support_email))
else
- I18n.t('conversations.reply.email.header.from_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
+ I18n.t('conversations.reply.email.header.from_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
from_email: parse_email(inbox_from_email_address))
end
end
def channel_email_with_name
- if @conversation.assignee.present?
- I18n.t('conversations.reply.channel_email.header.reply_with_name', assignee_name: assignee_name, inbox_name: @inbox.name,
- from_email: @channel.email)
- else
- I18n.t('conversations.reply.channel_email.header.reply_with_inbox_name', inbox_name: @inbox.name, from_email: @channel.email)
- end
+ I18n.t('conversations.reply.channel_email.header.reply_with_name', assignee_name: sender_name, inbox_name: @inbox.name,
+ from_email: @channel.email)
end
def parse_email(email_string)
diff --git a/app/models/account.rb b/app/models/account.rb
index e5453bfa3..564d2f7e2 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -77,6 +77,8 @@ class Account < ApplicationRecord
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
has_many :working_hours, dependent: :destroy_async
+ has_one_attached :contacts_export
+
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
enum status: { active: 0, suspended: 1 }
diff --git a/app/models/agent_bot.rb b/app/models/agent_bot.rb
index 72f6b2618..e01f286ab 100644
--- a/app/models/agent_bot.rb
+++ b/app/models/agent_bot.rb
@@ -28,6 +28,7 @@ class AgentBot < ApplicationRecord
enum bot_type: { webhook: 0, csml: 1 }
validate :validate_agent_bot_config
+ validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
def available_name
name
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 9325d8168..3af08f21e 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -37,7 +37,7 @@ class Attachment < ApplicationRecord
belongs_to :message
has_one_attached :file
validate :acceptable_file
- validates :external_url, length: { maximum: 1000 }
+ validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: [:image, :audio, :video, :file, :location, :fallback, :share, :story_mention, :contact]
def push_event_data
diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb
index 0159ff846..7a9550bd3 100644
--- a/app/models/automation_rule.rb
+++ b/app/models/automation_rule.rb
@@ -77,4 +77,4 @@ class AutomationRule < ApplicationRecord
end
end
-AutomationRule.include_mod_with('Audit::Inbox')
+AutomationRule.include_mod_with('Audit::AutomationRule')
diff --git a/app/models/channel/api.rb b/app/models/channel/api.rb
index 013a8c17c..a2bf9da48 100644
--- a/app/models/channel/api.rb
+++ b/app/models/channel/api.rb
@@ -27,6 +27,7 @@ class Channel::Api < ApplicationRecord
has_secure_token :identifier
has_secure_token :hmac_token
validate :ensure_valid_agent_reply_time_window
+ validates :webhook_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
def name
'API'
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 140538a54..f95c2c30a 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -50,6 +50,12 @@ class Channel::Whatsapp < ApplicationRecord
true
end
+ def mark_message_templates_updated
+ # rubocop:disable Rails/SkipsModelValidations
+ update_column(:message_templates_last_updated, Time.zone.now)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
diff --git a/app/models/concerns/cache_keys.rb b/app/models/concerns/cache_keys.rb
index 4c27e6bbb..7a01f0925 100644
--- a/app/models/concerns/cache_keys.rb
+++ b/app/models/concerns/cache_keys.rb
@@ -4,29 +4,41 @@ module CacheKeys
include CacheKeysHelper
include Events::Types
+ included do
+ class_attribute :cacheable_models
+ self.cacheable_models = [Label, Inbox, Team]
+ end
+
def cache_keys
- {
- label: fetch_value_for_key(id, Label.name.underscore),
- inbox: fetch_value_for_key(id, Inbox.name.underscore),
- team: fetch_value_for_key(id, Team.name.underscore)
- }
+ keys = {}
+ self.class.cacheable_models.each do |model|
+ keys[model.name.underscore.to_sym] = fetch_value_for_key(id, model.name.underscore)
+ end
+
+ keys
end
def invalidate_cache_key_for(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
- Redis::Alfred.del(prefixed_cache_key)
- dispatch_cache_udpate_event
+ Redis::Alfred.delete(prefixed_cache_key)
+ dispatch_cache_update_event
end
def update_cache_key(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
Redis::Alfred.set(prefixed_cache_key, Time.now.utc.to_i)
- dispatch_cache_udpate_event
+ dispatch_cache_update_event
+ end
+
+ def reset_cache_keys
+ self.class.cacheable_models.each do |model|
+ invalidate_cache_key_for(model.name.underscore)
+ end
end
private
- def dispatch_cache_udpate_event
+ def dispatch_cache_update_event
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
end
end
diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb
index 5e6b8b8db..daa0b4bf6 100644
--- a/app/models/concerns/featurable.rb
+++ b/app/models/concerns/featurable.rb
@@ -6,7 +6,7 @@ module Featurable
check_for_column: false
}.freeze
- FEATURE_LIST = YAML.safe_load(File.read(Rails.root.join('config/features.yml'))).freeze
+ FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
@@ -46,7 +46,7 @@ module Featurable
end
def all_features
- FEATURE_LIST.map { |f| f['name'] }.index_with do |feature_name|
+ FEATURE_LIST.pluck('name').index_with do |feature_name|
feature_enabled?(feature_name)
end
end
diff --git a/app/models/concerns/liquidable.rb b/app/models/concerns/liquidable.rb
index 6734dfeae..8a30977a7 100644
--- a/app/models/concerns/liquidable.rb
+++ b/app/models/concerns/liquidable.rb
@@ -2,7 +2,6 @@ module Liquidable
extend ActiveSupport::Concern
included do
- acts_as_taggable_on :labels
before_create :process_liquid_in_content
end
diff --git a/app/models/concerns/message_filter_helpers.rb b/app/models/concerns/message_filter_helpers.rb
index 6f5a758f0..38124f347 100644
--- a/app/models/concerns/message_filter_helpers.rb
+++ b/app/models/concerns/message_filter_helpers.rb
@@ -6,7 +6,7 @@ module MessageFilterHelpers
end
def webhook_sendable?
- incoming? || outgoing?
+ incoming? || outgoing? || template?
end
def slack_hook_sendable?
diff --git a/app/models/concerns/pubsubable.rb b/app/models/concerns/pubsubable.rb
index 7da99ea14..bbdef5100 100644
--- a/app/models/concerns/pubsubable.rb
+++ b/app/models/concerns/pubsubable.rb
@@ -6,6 +6,16 @@ module Pubsubable
included do
# Used by the actionCable/PubSub Service we use for real time communications
has_secure_token :pubsub_token
+ before_save :rotate_pubsub_token
+ end
+
+ def rotate_pubsub_token
+ # ATM we are only rotating the token if the user is changing their password
+ return unless is_a?(User)
+
+ # Using the class method to avoid the extra Save
+ # TODO: Should we do this on signin ?
+ self.pubsub_token = self.class.generate_unique_secure_token if will_save_change_to_encrypted_password?
end
def pubsub_token
diff --git a/app/models/contact.rb b/app/models/contact.rb
index 96457996f..543100c68 100644
--- a/app/models/contact.rb
+++ b/app/models/contact.rb
@@ -19,6 +19,7 @@
# index_contacts_on_account_id (account_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
+# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text)) # rubocop:disable Layout/LineLength
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
@@ -136,7 +137,7 @@ class Contact < ApplicationRecord
end
def self.resolved_contacts
- where("COALESCE(NULLIF(contacts.email,''),NULLIF(contacts.phone_number,''),NULLIF(contacts.identifier,'')) IS NOT NULL")
+ where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
def discard_invalid_attrs
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index becb0a639..8bc23a174 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -69,6 +69,7 @@ class Conversation < ApplicationRecord
scope :unassigned, -> { where(assignee_id: nil) }
scope :assigned, -> { where.not(assignee_id: nil) }
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
+ scope :unattended, -> { where(first_reply_created_at: nil) }
scope :resolvable, lambda { |auto_resolve_duration|
return none if auto_resolve_duration.to_i.zero?
@@ -233,8 +234,8 @@ class Conversation < ApplicationRecord
def allowed_keys?
(
- (previous_changes.keys & %w[team_id assignee_id status snoozed_until custom_attributes label_list first_reply_created_at priority]).present? ||
- (previous_changes['additional_attributes'].present? && (previous_changes['additional_attributes'][1].keys & %w[conversation_language]).present?)
+ previous_changes.keys.intersect?(%w[team_id assignee_id status snoozed_until custom_attributes label_list first_reply_created_at priority]) ||
+ (previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
)
end
diff --git a/app/models/csat_survey_response.rb b/app/models/csat_survey_response.rb
index 92ea25ebe..68edf0ab1 100644
--- a/app/models/csat_survey_response.rb
+++ b/app/models/csat_survey_response.rb
@@ -35,4 +35,8 @@ class CsatSurveyResponse < ApplicationRecord
scope :filter_by_created_at, ->(range) { where(created_at: range) if range.present? }
scope :filter_by_assigned_agent_id, ->(user_ids) { where(assigned_agent_id: user_ids) if user_ids.present? }
+ scope :filter_by_inbox_id, ->(inbox_id) { joins(:conversation).where(conversations: { inbox_id: inbox_id }) if inbox_id.present? }
+ scope :filter_by_team_id, ->(team_id) { joins(:conversation).where(conversations: { team_id: team_id }) if team_id.present? }
+ # filter by rating value
+ scope :filter_by_rating, ->(rating) { where(rating: rating) if rating.present? }
end
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index e506edc5a..b3d58fd17 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -17,8 +17,16 @@
# index_custom_filters_on_user_id (user_id)
#
class CustomFilter < ApplicationRecord
+ MAX_FILTER_PER_USER = 50
belongs_to :user
belongs_to :account
enum filter_type: { conversation: 0, contact: 1, report: 2 }
+ validate :validate_number_of_filters
+
+ def validate_number_of_filters
+ return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
+
+ errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
+ end
end
diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb
index f0e2ca4f1..f1603e4ca 100644
--- a/app/models/installation_config.rb
+++ b/app/models/installation_config.rb
@@ -19,7 +19,7 @@ class InstallationConfig < ApplicationRecord
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
# FIX ME : fixes breakage of installation config. we need to migrate.
# Fix configuration in application.rb
- serialize :serialized_value, HashWithIndifferentAccess
+ serialize :serialized_value, ActiveSupport::HashWithIndifferentAccess
before_validation :set_lock
validates :name, presence: true
diff --git a/app/models/message.rb b/app/models/message.rb
index cea70e835..b4121523a 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -2,23 +2,24 @@
#
# Table name: messages
#
-# id :integer not null, primary key
-# additional_attributes :jsonb
-# content :text
-# content_attributes :json
-# content_type :integer default("text"), not null
-# external_source_ids :jsonb
-# message_type :integer not null
-# private :boolean default(FALSE)
-# sender_type :string
-# status :integer default("sent")
-# created_at :datetime not null
-# updated_at :datetime not null
-# account_id :integer not null
-# conversation_id :integer not null
-# inbox_id :integer not null
-# sender_id :bigint
-# source_id :string
+# id :integer not null, primary key
+# additional_attributes :jsonb
+# content :text
+# content_attributes :json
+# content_type :integer default("text"), not null
+# external_source_ids :jsonb
+# message_type :integer not null
+# private :boolean default(FALSE)
+# processed_message_content :text
+# sender_type :string
+# status :integer default("sent")
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :integer not null
+# conversation_id :integer not null
+# inbox_id :integer not null
+# sender_id :bigint
+# source_id :string
#
# Indexes
#
@@ -57,6 +58,7 @@ class Message < ApplicationRecord
}.to_json.freeze
before_validation :ensure_content_type
+ before_save :ensure_processed_message_content
validates :account_id, presence: true
validates :inbox_id, presence: true
@@ -68,6 +70,7 @@ class Message < ApplicationRecord
validates :content_type, presence: true
validates :content, length: { maximum: 150_000 }
+ validates :processed_message_content, length: { maximum: 150_000 }
# when you have a temperory id in your frontend and want it echoed back via action cable
attr_accessor :echo_id
@@ -214,6 +217,14 @@ class Message < ApplicationRecord
private
+ def ensure_processed_message_content
+ text_content_quoted = content_attributes.dig(:email, :text_content, :quoted)
+ html_content_quoted = content_attributes.dig(:email, :html_content, :quoted)
+
+ message_content = text_content_quoted || html_content_quoted || content
+ self.processed_message_content = message_content&.truncate(150_000)
+ end
+
def ensure_content_type
self.content_type ||= Message.content_types[:text]
end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index eadb48032..8fad75618 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -101,7 +101,7 @@ class Notification < ApplicationRecord
I18n.t(
'notifications.notification_title.assigned_conversation_new_message',
display_id: conversation.display_id,
- content: primary_actor&.content&.truncate_words(10)
+ content: transform_user_mention_content(primary_actor&.content&.truncate_words(10))
)
when 'conversation_mention'
"[##{conversation&.display_id}] #{transform_user_mention_content primary_actor&.content}"
diff --git a/app/models/reporting_event.rb b/app/models/reporting_event.rb
index 3568d2a06..2f141786b 100644
--- a/app/models/reporting_event.rb
+++ b/app/models/reporting_event.rb
@@ -17,12 +17,13 @@
#
# Indexes
#
-# index_reporting_events_on_account_id (account_id)
-# index_reporting_events_on_conversation_id (conversation_id)
-# index_reporting_events_on_created_at (created_at)
-# index_reporting_events_on_inbox_id (inbox_id)
-# index_reporting_events_on_name (name)
-# index_reporting_events_on_user_id (user_id)
+# index_reporting_events_on_account_id (account_id)
+# index_reporting_events_on_conversation_id (conversation_id)
+# index_reporting_events_on_created_at (created_at)
+# index_reporting_events_on_inbox_id (inbox_id)
+# index_reporting_events_on_name (name)
+# index_reporting_events_on_user_id (user_id)
+# reporting_events__account_id__name__created_at (account_id,name,created_at)
#
class ReportingEvent < ApplicationRecord
diff --git a/app/models/team.rb b/app/models/team.rb
index 57bd30451..c5935c285 100644
--- a/app/models/team.rb
+++ b/app/models/team.rb
@@ -54,3 +54,5 @@ class Team < ApplicationRecord
}
end
end
+
+Team.include_mod_with('Audit::Team')
diff --git a/app/models/user.rb b/app/models/user.rb
index c50ab208e..6ff4fb61d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -166,3 +166,5 @@ class User < ApplicationRecord
macros.personal.destroy_all
end
end
+
+User.include_mod_with('Audit::User')
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
index f43c80548..4f0e32a43 100644
--- a/app/models/webhook.rb
+++ b/app/models/webhook.rb
@@ -38,4 +38,4 @@ class Webhook < ApplicationRecord
end
end
-Webhook.include_mod_with('Audit::Inbox')
+Webhook.include_mod_with('Audit::Webhook')
diff --git a/app/policies/contact_policy.rb b/app/policies/contact_policy.rb
index 71a967c6f..cd199012f 100644
--- a/app/policies/contact_policy.rb
+++ b/app/policies/contact_policy.rb
@@ -11,6 +11,10 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator?
end
+ def export?
+ @account_user.administrator?
+ end
+
def search?
true
end
diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb
index 92676b0d6..98915c07f 100644
--- a/app/presenters/mail_presenter.rb
+++ b/app/presenters/mail_presenter.rb
@@ -70,6 +70,8 @@ class MailPresenter < SimpleDelegator
}
end
+ # check content disposition check
+ # if inline, upload to AWS and and take the URL
def attachments
# ref : https://github.com/gorails-screencasts/action-mailbox-action-text/blob/master/app/mailboxes/posts_mailbox.rb
mail.attachments.map do |attachment|
diff --git a/app/services/automation_rules/conditions_filter_service.rb b/app/services/automation_rules/conditions_filter_service.rb
index 432e9cfc3..f2f344e16 100644
--- a/app/services/automation_rules/conditions_filter_service.rb
+++ b/app/services/automation_rules/conditions_filter_service.rb
@@ -27,6 +27,7 @@ class AutomationRules::ConditionsFilterService < FilterService
end
records = base_relation.where(@query_string, @filter_values.with_indifferent_access)
+
records = perform_attribute_changed_filter(records) if @attribute_changed_query_filter.any?
records.any?
@@ -93,6 +94,8 @@ class AutomationRules::ConditionsFilterService < FilterService
attribute_key = query_hash['attribute_key']
query_operator = query_hash['query_operator']
+ attribute_key = 'processed_message_content' if attribute_key == 'content'
+
filter_operator_value = filter_operation(query_hash, current_index)
case current_filter['attribute_type']
diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb
index d91916b12..012dd662f 100644
--- a/app/services/conversations/filter_service.rb
+++ b/app/services/conversations/filter_service.rb
@@ -58,8 +58,9 @@ class Conversations::FilterService < FilterService
def conversations
@conversations = @conversations.includes(
- :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
+ :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
)
+
@conversations.latest.page(current_page)
end
end
diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb
index 62401337e..9923017dd 100644
--- a/app/services/whatsapp/providers/base_service.rb
+++ b/app/services/whatsapp/providers/base_service.rb
@@ -26,4 +26,54 @@ class Whatsapp::Providers::BaseService
def validate_provider_config
raise 'Overwrite this method in child class'
end
+
+ def create_buttons(items)
+ buttons = []
+ items.each do |item|
+ button = { :type => 'reply', 'reply' => { 'id' => item['value'], 'title' => item['title'] } }
+ buttons << button
+ end
+ buttons
+ end
+
+ def create_rows(items)
+ rows = []
+ items.each do |item|
+ row = { 'id' => item['value'], 'title' => item['title'] }
+ rows << row
+ end
+ rows
+ end
+
+ def create_payload(type, message_content, action)
+ {
+ 'type': type,
+ 'body': {
+ 'text': message_content
+ },
+ 'action': action
+ }
+ end
+
+ def create_payload_based_on_items(message)
+ if message.content_attributes['items'].length <= 3
+ create_button_payload(message)
+ else
+ create_list_payload(message)
+ end
+ end
+
+ def create_button_payload(message)
+ buttons = create_buttons(message.content_attributes['items'])
+ json_hash = { 'buttons' => buttons }
+ create_payload('button', message.content, JSON.generate(json_hash))
+ end
+
+ def create_list_payload(message)
+ rows = create_rows(message.content_attributes['items'])
+ section1 = { 'rows' => rows }
+ sections = [section1]
+ json_hash = { :button => 'Choose an item', 'sections' => sections }
+ create_payload('list', message.content, JSON.generate(json_hash))
+ end
end
diff --git a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
index c67e448bb..668162905 100644
--- a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
@@ -2,6 +2,8 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
def send_message(phone_number, message)
if message.attachments.present?
send_attachment_message(phone_number, message)
+ elsif message.content_type == 'input_select'
+ send_interactive_text_message(phone_number, message)
else
send_text_message(phone_number, message)
end
@@ -22,6 +24,8 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
end
def sync_templates
+ # ensuring that channels with wrong provider config wouldn't keep trying to sync templates
+ whatsapp_channel.mark_message_templates_updated
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
end
@@ -110,4 +114,20 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
}]
}
end
+
+ def send_interactive_text_message(phone_number, message)
+ payload = create_payload_based_on_items(message)
+
+ response = HTTParty.post(
+ "#{api_base_path}/messages",
+ headers: api_headers,
+ body: {
+ to: phone_number,
+ interactive: payload,
+ type: 'interactive'
+ }.to_json
+ )
+
+ process_response(response)
+ end
end
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 9b6c793aa..d1092e2ff 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -2,6 +2,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def send_message(phone_number, message)
if message.attachments.present?
send_attachment_message(phone_number, message)
+ elsif message.content_type == 'input_select'
+ send_interactive_text_message(phone_number, message)
else
send_text_message(phone_number, message)
end
@@ -23,8 +25,9 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def sync_templates
+ # ensuring that channels with wrong provider config wouldn't keep trying to sync templates
+ whatsapp_channel.mark_message_templates_updated
templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
-
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
end
@@ -128,4 +131,21 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
}]
}
end
+
+ def send_interactive_text_message(phone_number, message)
+ payload = create_payload_based_on_items(message)
+
+ response = HTTParty.post(
+ "#{phone_id_path}/messages",
+ headers: api_headers,
+ body: {
+ messaging_product: 'whatsapp',
+ to: phone_number,
+ interactive: payload,
+ type: 'interactive'
+ }.to_json
+ )
+
+ process_response(response)
+ end
end
diff --git a/app/views/api/v1/accounts/articles/_article.json.jbuilder b/app/views/api/v1/accounts/articles/_article.json.jbuilder
index 0de240a1a..8c03f8f7b 100644
--- a/app/views/api/v1/accounts/articles/_article.json.jbuilder
+++ b/app/views/api/v1/accounts/articles/_article.json.jbuilder
@@ -8,11 +8,12 @@ json.position article.position
json.account_id article.account_id
json.updated_at article.updated_at.to_i
json.meta article.meta
+
json.category do
json.id article.category_id
- json.name article.category.name
- json.slug article.category.slug
- json.locale article.category.locale
+ json.name article.category&.name
+ json.slug article.category&.slug
+ json.locale article.category&.locale
end
json.views article.views
diff --git a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
index e31980ea2..86c900fca 100644
--- a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
+++ b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
@@ -1 +1,10 @@
-json.payload @attachments.map(&:push_event_data)
+json.payload @attachments do |attachment|
+ json.message_id attachment.push_event_data[:message_id]
+ json.thumb_url attachment.push_event_data[:thumb_url]
+ json.data_url attachment.push_event_data[:data_url]
+ json.file_size attachment.push_event_data[:file_size]
+ json.file_type attachment.push_event_data[:file_type]
+ json.extension attachment.push_event_data[:extension]
+ json.created_at attachment.message.created_at.to_i
+ json.sender attachment.message.sender.push_event_data if attachment.message.sender
+end
diff --git a/app/views/api/v1/accounts/create.json.jbuilder b/app/views/api/v1/accounts/create.json.jbuilder
index 240e04ae1..b423b534d 100644
--- a/app/views/api/v1/accounts/create.json.jbuilder
+++ b/app/views/api/v1/accounts/create.json.jbuilder
@@ -6,6 +6,7 @@ json.data do
json.display_name resource.display_name
json.email resource.email
json.account_id @account.id
+ json.created_at resource.created_at
json.pubsub_token resource.pubsub_token
json.role resource.active_account_user&.role
json.inviter_id resource.active_account_user&.inviter_id
diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
index 9ea7df4e6..78774ee95 100644
--- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
+++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder
@@ -17,7 +17,7 @@ json.meta do
end
json.id conversation.display_id
-if conversation.messages.count.zero?
+if conversation.messages.first.blank?
json.messages []
elsif conversation.unread_incoming_messages.count.zero?
json.messages [conversation.messages.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)]
@@ -26,6 +26,7 @@ else
end
json.account_id conversation.account_id
+json.uuid conversation.uuid
json.additional_attributes conversation.additional_attributes
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
diff --git a/app/views/api/v1/models/_conversation.json.jbuilder b/app/views/api/v1/models/_conversation.json.jbuilder
index 0951892a6..3fa54bcd9 100644
--- a/app/views/api/v1/models/_conversation.json.jbuilder
+++ b/app/views/api/v1/models/_conversation.json.jbuilder
@@ -1,4 +1,5 @@
json.id conversation.display_id
+json.uuid conversation.uuid
json.created_at conversation.created_at.to_i
json.contact do
json.id conversation.contact.id
diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb
index ef94788a2..4a3b450a3 100644
--- a/app/views/devise/mailer/confirmation_instructions.html.erb
+++ b/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -9,9 +9,11 @@
<% if @resource.confirmed? %>
You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:
<% else %>
+ <% if account_user&.inviter.blank? %>
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
+ <% end %>
Please take a moment and click the link below and activate your account.
<% end %>
@@ -24,4 +26,4 @@
<%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %>
<% else %>
<%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
-<% end %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb
index eba020c8b..32ab67e80 100644
--- a/app/views/layouts/vueapp.html.erb
+++ b/app/views/layouts/vueapp.html.erb
@@ -50,8 +50,8 @@
window.browserConfig = {
browser_name: '<%= browser.name %>',
}
- window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>'
- window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '')%>'
+ window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '') %>'
+ window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '') %>'
<% if @global_config['ANALYTICS_TOKEN'].present? %>
<% end %>
+ <% if ENV.fetch('MS_CLARITY_TOKEN', nil).present? %>
+
+ <% end %>