+
-
- {{ identifier }}
-
-
+
+
+
+ {{ identifier }}
+
+
+
+
-
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue b/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue
new file mode 100644
index 000000000..160394142
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue b/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue
new file mode 100644
index 000000000..e9d1ca500
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+ {{ issue.title }}
+
+
+
+ {{ issue.description }}
+
+
+
+
+
+
+
+
+ {{ assignee.name }}
+
+
+
+
+
+
+
+
+ {{ issue.state?.name }}
+
+
+
+
+
+
+
+
+ {{ priorityLabel }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue b/app/javascript/dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue
new file mode 100644
index 000000000..f7394a092
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
![]()
+
![]()
+
+
+
+
+ {{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.TITLE') }}
+
+
+ {{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.DESCRIPTION') }}
+
+
+ {{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.AGENT_DESCRIPTION') }}
+
+
+
+
+ {{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.BUTTON_TEXT') }}
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/index.vue b/app/javascript/dashboard/components/widgets/conversation/linear/index.vue
deleted file mode 100644
index f7c12fcdf..000000000
--- a/app/javascript/dashboard/components/widgets/conversation/linear/index.vue
+++ /dev/null
@@ -1,161 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/composables/useUISettings.js b/app/javascript/dashboard/composables/useUISettings.js
index 29fe878c7..964c615ad 100644
--- a/app/javascript/dashboard/composables/useUISettings.js
+++ b/app/javascript/dashboard/composables/useUISettings.js
@@ -9,6 +9,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'contact_notes' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
+ { name: 'linear_issues' },
{ name: 'shopify_orders' },
]);
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index fdb7fc07a..c047f17ad 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -308,6 +308,7 @@
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
+ "LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index cfa46867c..071c95604 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -315,11 +315,18 @@
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
},
+ "NO_LINKED_ISSUES": "No linked issues found",
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
+ },
+ "CTA": {
+ "TITLE": "Connect to Linear",
+ "AGENT_DESCRIPTION": "Linear workspace is not connected. Request your administrator to connect a workspace to use this integration.",
+ "DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
+ "BUTTON_TEXT": "Connect Linear workspace"
}
}
},
diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json
index 294ca2e7b..7c42fdfba 100644
--- a/app/javascript/dashboard/i18n/locale/en/report.json
+++ b/app/javascript/dashboard/i18n/locale/en/report.json
@@ -193,6 +193,7 @@
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
+ "DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
@@ -559,6 +560,7 @@
"INBOX": "Inbox",
"AGENT": "Agent",
"TEAM": "Team",
+ "LABEL": "Label",
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index 212d9219b..d7f99802c 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
@@ -19,6 +19,9 @@ import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
+import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
+import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const props = defineProps({
conversationId: {
@@ -40,6 +43,13 @@ const {
const dragging = ref(false);
const conversationSidebarItems = ref([]);
+
+const currentAccountId = useMapGetter('getCurrentAccountId');
+
+const isFeatureEnabledonAccount = useMapGetter(
+ 'accounts/isFeatureEnabledonAccount'
+);
+
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
'shopify'
@@ -49,6 +59,20 @@ const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
+const linearIntegration = useFunctionGetter(
+ 'integrations/getIntegration',
+ 'linear'
+);
+
+const isLinearIntegrationEnabled = computed(
+ () => linearIntegration.value?.enabled || false
+);
+
+const isLinearFeatureEnabled = isFeatureEnabledonAccount.value(
+ currentAccountId.value,
+ FEATURE_FLAGS.LINEAR
+);
+
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => props.conversationId);
@@ -103,6 +127,8 @@ onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
store.dispatch('attributes/get', 0);
+ // Load integrations to ensure linear integration state is available
+ store.dispatch('integrations/get', 'linear');
});
@@ -113,7 +139,7 @@ onMounted(() => {
@close="closeContactPanel"
/>
-
+
{
@end="onDragEnd"
>
-
-
-
toggleSidebarUIState('is_conv_actions_open', value)
- "
- >
-
-
-
-
-
- toggleSidebarUIState('is_conv_participants_open', value)
- "
- >
-
-
-
-
-
toggleSidebarUIState('is_conv_details_open', value)
- "
- >
-
-
-
-
-
- toggleSidebarUIState('is_contact_attributes_open', value)
- "
- >
-
-
-
-
-
toggleSidebarUIState('is_previous_conv_open', value)
- "
- >
-
-
-
-
- toggleSidebarUIState('is_macro_open', value)"
- >
-
-
-
-
+
toggleSidebarUIState('is_conv_actions_open', value)
"
>
- toggleSidebarUIState('is_shopify_orders_open', value)
+
+
+
+
+
+ toggleSidebarUIState('is_conv_participants_open', value)
+ "
+ >
+
+
+
+
+
toggleSidebarUIState('is_conv_details_open', value)
+ "
+ >
+
+
+
+
+
+ toggleSidebarUIState('is_contact_attributes_open', value)
+ "
+ >
+
-
-
-
-
-
toggleSidebarUIState('is_contact_notes_open', value)
- "
- >
-
-
-
+ />
+
+
+
+
toggleSidebarUIState('is_previous_conv_open', value)
+ "
+ >
+
+
+
+
+ toggleSidebarUIState('is_macro_open', value)"
+ >
+
+
+
+
+
toggleSidebarUIState('is_linear_issues_open', value)
+ "
+ >
+
+
+
+
+
+
toggleSidebarUIState('is_shopify_orders_open', value)
+ "
+ >
+
+
+
+
+
toggleSidebarUIState('is_contact_notes_open', value)
+ "
+ >
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue
new file mode 100644
index 000000000..956b30974
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue
new file mode 100644
index 000000000..678028410
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
index 4c0c4fe64..7e0b9be8f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
@@ -108,7 +108,8 @@ const tableData = computed(() =>
} = rowMetrics;
return {
id: row.id,
- name: row.name,
+ // we fallback on title, label for instance does not have a name property
+ name: row.name ?? row.title,
type: props.type,
conversationsCount: renderCount(conversationsCount),
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
@@ -177,7 +178,7 @@ defineExpose({ downloadReports });
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js
index 43855240c..af01f6d74 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js
@@ -7,10 +7,12 @@ import Index from './Index.vue';
import AgentReportsIndex from './AgentReportsIndex.vue';
import InboxReportsIndex from './InboxReportsIndex.vue';
import TeamReportsIndex from './TeamReportsIndex.vue';
+import LabelReportsIndex from './LabelReportsIndex.vue';
import AgentReportsShow from './AgentReportsShow.vue';
import InboxReportsShow from './InboxReportsShow.vue';
import TeamReportsShow from './TeamReportsShow.vue';
+import LabelReportsShow from './LabelReportsShow.vue';
import AgentReports from './AgentReports.vue';
import InboxReports from './InboxReports.vue';
@@ -104,6 +106,22 @@ const revisedReportRoutes = [
},
component: TeamReportsShow,
},
+ {
+ path: 'labels_overview',
+ name: 'label_reports_index',
+ meta: {
+ permissions: ['administrator', 'report_manage'],
+ },
+ component: LabelReportsIndex,
+ },
+ {
+ path: 'labels/:id',
+ name: 'label_reports_show',
+ meta: {
+ permissions: ['administrator', 'report_manage'],
+ },
+ component: LabelReportsShow,
+ },
];
export default {
diff --git a/app/javascript/dashboard/store/modules/labels.js b/app/javascript/dashboard/store/modules/labels.js
index 34f63f2a1..662c2973e 100644
--- a/app/javascript/dashboard/store/modules/labels.js
+++ b/app/javascript/dashboard/store/modules/labels.js
@@ -26,6 +26,9 @@ export const getters = {
.filter(record => record.show_on_sidebar)
.sort((a, b) => a.title.localeCompare(b.title));
},
+ getLabelById: _state => id => {
+ return _state.records.find(record => record.id === Number(id));
+ },
};
export const actions = {
diff --git a/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js b/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js
index 043987136..c1a199856 100644
--- a/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js
@@ -7,6 +7,7 @@ vi.mock('dashboard/api/summaryReports', () => ({
getInboxReports: vi.fn(),
getAgentReports: vi.fn(),
getTeamReports: vi.fn(),
+ getLabelReports: vi.fn(),
},
}));
@@ -25,10 +26,12 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
+ labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
+ isFetchingLabelSummaryReports: false,
},
});
});
@@ -39,6 +42,7 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [{ id: 1 }],
agentSummaryReports: [{ id: 2 }],
teamSummaryReports: [{ id: 3 }],
+ labelSummaryReports: [{ id: 4 }],
uiFlags: { isFetchingInboxSummaryReports: true },
};
@@ -54,6 +58,10 @@ describe('Summary Reports Store', () => {
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
});
+ it('should return label summary reports', () => {
+ expect(store.getters.getLabelSummaryReports(state)).toEqual([{ id: 4 }]);
+ });
+
it('should return UI flags', () => {
expect(store.getters.getUIFlags(state)).toEqual({
isFetchingInboxSummaryReports: true,
@@ -86,6 +94,14 @@ describe('Summary Reports Store', () => {
expect(state.teamSummaryReports).toEqual(data);
});
+ it('should set label summary report', () => {
+ const state = { ...initialState };
+ const data = [{ id: 4 }];
+
+ store.mutations.setLabelSummaryReport(state, data);
+ expect(state.labelSummaryReports).toEqual(data);
+ });
+
it('should merge UI flags with existing flags', () => {
const state = {
uiFlags: { flag1: true, flag2: false },
@@ -185,5 +201,29 @@ describe('Summary Reports Store', () => {
});
});
});
+
+ describe('fetchLabelSummaryReports', () => {
+ it('should fetch label reports successfully', async () => {
+ const params = { labelId: 789 };
+ const mockResponse = {
+ data: [{ label_id: 789, label_name: 'Test Label' }],
+ };
+
+ SummaryReportsAPI.getLabelReports.mockResolvedValue(mockResponse);
+
+ await store.actions.fetchLabelSummaryReports({ commit }, params);
+
+ expect(commit).toHaveBeenCalledWith('setUIFlags', {
+ isFetchingLabelSummaryReports: true,
+ });
+ expect(SummaryReportsAPI.getLabelReports).toHaveBeenCalledWith(params);
+ expect(commit).toHaveBeenCalledWith('setLabelSummaryReport', [
+ { labelId: 789, labelName: 'Test Label' },
+ ]);
+ expect(commit).toHaveBeenCalledWith('setUIFlags', {
+ isFetchingLabelSummaryReports: false,
+ });
+ });
+ });
});
});
diff --git a/app/javascript/dashboard/store/modules/summaryReports.js b/app/javascript/dashboard/store/modules/summaryReports.js
index 15df7589b..d95c0df30 100644
--- a/app/javascript/dashboard/store/modules/summaryReports.js
+++ b/app/javascript/dashboard/store/modules/summaryReports.js
@@ -17,6 +17,11 @@ const typeMap = {
apiMethod: 'getTeamReports',
mutationKey: 'setTeamSummaryReport',
},
+ label: {
+ flagKey: 'isFetchingLabelSummaryReports',
+ apiMethod: 'getLabelReports',
+ mutationKey: 'setLabelSummaryReport',
+ },
};
async function fetchSummaryReports(type, params, { commit }) {
@@ -38,10 +43,12 @@ export const initialState = {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
+ labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
+ isFetchingLabelSummaryReports: false,
},
};
@@ -55,6 +62,9 @@ export const getters = {
getTeamSummaryReports(state) {
return state.teamSummaryReports;
},
+ getLabelSummaryReports(state) {
+ return state.labelSummaryReports;
+ },
getUIFlags(state) {
return state.uiFlags;
},
@@ -72,6 +82,10 @@ export const actions = {
fetchTeamSummaryReports({ commit }, params) {
return fetchSummaryReports('team', params, { commit });
},
+
+ fetchLabelSummaryReports({ commit }, params) {
+ return fetchSummaryReports('label', params, { commit });
+ },
};
export const mutations = {
@@ -84,6 +98,9 @@ export const mutations = {
setTeamSummaryReport(state, data) {
state.teamSummaryReports = data;
},
+ setLabelSummaryReport(state, data) {
+ state.labelSummaryReports = data;
+ },
setUIFlags(state, uiFlag) {
state.uiFlags = { ...state.uiFlags, ...uiFlag };
},
diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb
index e337a03f6..3a186676e 100644
--- a/app/services/telegram/incoming_message_service.rb
+++ b/app/services/telegram/incoming_message_service.rb
@@ -94,13 +94,25 @@ class Telegram::IncomingMessageService
end
def file_content_type
- return :image if params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
- return :audio if params[:message][:voice].present? || params[:message][:audio].present?
- return :video if params[:message][:video].present?
+ return :image if image_message?
+ return :audio if audio_message?
+ return :video if video_message?
file_type(params[:message][:document][:mime_type])
end
+ def image_message?
+ params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
+ end
+
+ def audio_message?
+ params[:message][:voice].present? || params[:message][:audio].present?
+ end
+
+ def video_message?
+ params[:message][:video].present? || params[:message][:video_note].present?
+ end
+
def attach_files
return unless file
@@ -174,6 +186,9 @@ class Telegram::IncomingMessageService
end
def visual_media_params
- params[:message][:photo].presence&.last || params.dig(:message, :sticker, :thumb).presence || params[:message][:video].presence
+ params[:message][:photo].presence&.last ||
+ params.dig(:message, :sticker, :thumb).presence ||
+ params[:message][:video].presence ||
+ params[:message][:video_note].presence
end
end
diff --git a/config/routes.rb b/config/routes.rb
index 4fe652407..867f6bdc2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -344,6 +344,7 @@ Rails.application.routes.draw do
get :agent
get :team
get :inbox
+ get :label
end
end
resources :reports, only: [:index] do
diff --git a/lib/seeders/reports/conversation_creator.rb b/lib/seeders/reports/conversation_creator.rb
new file mode 100644
index 000000000..b6259de7d
--- /dev/null
+++ b/lib/seeders/reports/conversation_creator.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'faker'
+require 'active_support/testing/time_helpers'
+
+class Seeders::Reports::ConversationCreator
+ include ActiveSupport::Testing::TimeHelpers
+
+ def initialize(account:, resources:)
+ @account = account
+ @contacts = resources[:contacts]
+ @inboxes = resources[:inboxes]
+ @teams = resources[:teams]
+ @labels = resources[:labels]
+ @agents = resources[:agents]
+ @priorities = [nil, 'urgent', 'high', 'medium', 'low']
+ end
+
+ def create_conversation(created_at:)
+ conversation = nil
+
+ ActiveRecord::Base.transaction do
+ travel_to(created_at) do
+ conversation = build_conversation
+ conversation.save!
+
+ add_labels_to_conversation(conversation)
+ create_messages_for_conversation(conversation)
+ resolve_conversation_if_needed(conversation)
+ end
+
+ travel_back
+ end
+
+ conversation
+ end
+
+ private
+
+ def build_conversation
+ contact = @contacts.sample
+ inbox = @inboxes.sample
+
+ contact_inbox = find_or_create_contact_inbox(contact, inbox)
+ assignee = select_assignee(inbox)
+ team = select_team
+ priority = @priorities.sample
+
+ contact_inbox.conversations.new(
+ account: @account,
+ inbox: inbox,
+ contact: contact,
+ assignee: assignee,
+ team: team,
+ priority: priority
+ )
+ end
+
+ def find_or_create_contact_inbox(contact, inbox)
+ inbox.contact_inboxes.find_or_create_by!(
+ contact: contact,
+ source_id: SecureRandom.hex
+ )
+ end
+
+ def select_assignee(inbox)
+ rand(10) < 8 ? inbox.members.sample : nil
+ end
+
+ def select_team
+ rand(10) < 7 ? @teams.sample : nil
+ end
+
+ def add_labels_to_conversation(conversation)
+ labels_to_add = @labels.sample(rand(5..20))
+ conversation.update_labels(labels_to_add.map(&:title))
+ end
+
+ def create_messages_for_conversation(conversation)
+ message_creator = Seeders::Reports::MessageCreator.new(
+ account: @account,
+ agents: @agents,
+ conversation: conversation
+ )
+ message_creator.create_messages
+ end
+
+ def resolve_conversation_if_needed(conversation)
+ return unless rand < 0.7
+
+ resolution_delay = rand((30.minutes)..(24.hours))
+ travel(resolution_delay)
+ conversation.update!(status: :resolved)
+
+ trigger_conversation_resolved_event(conversation)
+ end
+
+ def trigger_conversation_resolved_event(conversation)
+ event_data = { conversation: conversation }
+
+ ReportingEventListener.instance.conversation_resolved(
+ Events::Base.new('conversation_resolved', Time.current, event_data)
+ )
+ end
+end
diff --git a/lib/seeders/reports/message_creator.rb b/lib/seeders/reports/message_creator.rb
new file mode 100644
index 000000000..fc10716d9
--- /dev/null
+++ b/lib/seeders/reports/message_creator.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require 'faker'
+require 'active_support/testing/time_helpers'
+
+class Seeders::Reports::MessageCreator
+ include ActiveSupport::Testing::TimeHelpers
+
+ MESSAGES_PER_CONVERSATION = 5
+
+ def initialize(account:, agents:, conversation:)
+ @account = account
+ @agents = agents
+ @conversation = conversation
+ end
+
+ def create_messages
+ message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5)
+ first_agent_reply = true
+
+ message_count.times do |i|
+ message = create_single_message(i)
+ first_agent_reply = handle_reply_tracking(message, i, first_agent_reply)
+ end
+ end
+
+ def create_single_message(index)
+ is_incoming = index.even?
+ add_realistic_delay(index, is_incoming) if index.positive?
+ create_message(is_incoming)
+ end
+
+ def handle_reply_tracking(message, index, first_agent_reply)
+ return first_agent_reply if index.even? # Skip incoming messages
+
+ handle_agent_reply_events(message, first_agent_reply)
+ false # No longer first reply after any agent message
+ end
+
+ private
+
+ def add_realistic_delay(_message_index, is_incoming)
+ delay = calculate_message_delay(is_incoming)
+ travel(delay)
+ end
+
+ def calculate_message_delay(is_incoming)
+ if is_incoming
+ # Customer response time: 1 minute to 4 hours
+ rand((1.minute)..(4.hours))
+ elsif business_hours_active?(Time.current)
+ # Agent response time varies by business hours
+ rand((30.seconds)..(30.minutes))
+ else
+ rand((1.hour)..(8.hours))
+ end
+ end
+
+ def create_message(is_incoming)
+ if is_incoming
+ create_incoming_message
+ else
+ create_outgoing_message
+ end
+ end
+
+ def create_incoming_message
+ @conversation.messages.create!(
+ account: @account,
+ inbox: @conversation.inbox,
+ message_type: :incoming,
+ content: generate_message_content,
+ sender: @conversation.contact
+ )
+ end
+
+ def create_outgoing_message
+ sender = @conversation.assignee || @agents.sample
+
+ @conversation.messages.create!(
+ account: @account,
+ inbox: @conversation.inbox,
+ message_type: :outgoing,
+ content: generate_message_content,
+ sender: sender
+ )
+ end
+
+ def generate_message_content
+ Faker::Lorem.paragraph(sentence_count: rand(1..5))
+ end
+
+ def handle_agent_reply_events(message, is_first_reply)
+ if is_first_reply
+ trigger_first_reply_event(message)
+ else
+ trigger_reply_event(message)
+ end
+ end
+
+ def business_hours_active?(time)
+ weekday = time.wday
+ hour = time.hour
+ weekday.between?(1, 5) && hour.between?(9, 17)
+ end
+
+ def trigger_first_reply_event(message)
+ event_data = {
+ message: message,
+ conversation: message.conversation
+ }
+
+ ReportingEventListener.instance.first_reply_created(
+ Events::Base.new('first_reply_created', Time.current, event_data)
+ )
+ end
+
+ def trigger_reply_event(message)
+ waiting_since = calculate_waiting_since(message)
+
+ event_data = {
+ message: message,
+ conversation: message.conversation,
+ waiting_since: waiting_since
+ }
+
+ ReportingEventListener.instance.reply_created(
+ Events::Base.new('reply_created', Time.current, event_data)
+ )
+ end
+
+ def calculate_waiting_since(message)
+ last_customer_message = message.conversation.messages
+ .where(message_type: :incoming)
+ .where('created_at < ?', message.created_at)
+ .order(:created_at)
+ .last
+
+ last_customer_message&.created_at || message.conversation.created_at
+ end
+end
diff --git a/lib/seeders/reports/report_data_seeder.rb b/lib/seeders/reports/report_data_seeder.rb
new file mode 100644
index 000000000..909818b72
--- /dev/null
+++ b/lib/seeders/reports/report_data_seeder.rb
@@ -0,0 +1,234 @@
+# frozen_string_literal: true
+
+# Reports Data Seeder
+#
+# Generates realistic test data for performance testing of reports and analytics.
+# Creates conversations, messages, contacts, agents, teams, and labels with proper
+# reporting events (first response times, resolution times, etc.) using time travel
+# to generate historical data with realistic timestamps.
+#
+# Usage:
+# ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
+#
+# This will create:
+# - 1000 conversations with realistic message exchanges
+# - 100 contacts with realistic profiles
+# - 20 agents assigned to teams and inboxes
+# - 5 teams with realistic distribution
+# - 30 labels with random assignments
+# - 3 inboxes with agent assignments
+# - Realistic reporting events with historical timestamps
+#
+# Note: This seeder clears existing data for the account before seeding.
+
+require 'faker'
+require_relative 'conversation_creator'
+require_relative 'message_creator'
+
+# rubocop:disable Rails/Output
+class Seeders::Reports::ReportDataSeeder
+ include ActiveSupport::Testing::TimeHelpers
+
+ TOTAL_CONVERSATIONS = 1000
+ TOTAL_CONTACTS = 100
+ TOTAL_AGENTS = 20
+ TOTAL_TEAMS = 5
+ TOTAL_LABELS = 30
+ TOTAL_INBOXES = 3
+ MESSAGES_PER_CONVERSATION = 5
+ START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
+ END_DATE = Time.current
+
+ def initialize(account:)
+ raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
+
+ @account = account
+ @teams = []
+ @agents = []
+ @labels = []
+ @inboxes = []
+ @contacts = []
+ end
+
+ def perform!
+ puts "Starting reports data seeding for account: #{@account.name}"
+
+ # Clear existing data
+ clear_existing_data
+
+ create_teams
+ create_agents
+ create_labels
+ create_inboxes
+ create_contacts
+
+ create_conversations
+
+ puts "Completed reports data seeding for account: #{@account.name}"
+ end
+
+ private
+
+ def clear_existing_data
+ puts "Clearing existing data for account: #{@account.id}"
+ @account.teams.destroy_all
+ @account.conversations.destroy_all
+ @account.labels.destroy_all
+ @account.inboxes.destroy_all
+ @account.contacts.destroy_all
+ @account.agents.destroy_all
+ @account.reporting_events.destroy_all
+ end
+
+ def create_teams
+ TOTAL_TEAMS.times do |i|
+ team = @account.teams.create!(
+ name: "#{Faker::Company.industry} Team #{i + 1}"
+ )
+ @teams << team
+ print "\rCreating teams: #{i + 1}/#{TOTAL_TEAMS}"
+ end
+
+ print "\n"
+ end
+
+ def create_agents
+ TOTAL_AGENTS.times do |i|
+ user = create_single_agent(i)
+ assign_agent_to_teams(user)
+ @agents << user
+ print "\rCreating agents: #{i + 1}/#{TOTAL_AGENTS}"
+ end
+
+ print "\n"
+ end
+
+ def create_single_agent(index)
+ random_suffix = SecureRandom.hex(4)
+ user = User.create!(
+ name: Faker::Name.name,
+ email: "agent_#{index + 1}_#{random_suffix}@#{@account.domain || 'example.com'}",
+ password: 'Password1!.',
+ confirmed_at: Time.current
+ )
+ user.skip_confirmation!
+ user.save!
+
+ AccountUser.create!(
+ account_id: @account.id,
+ user_id: user.id,
+ role: :agent
+ )
+
+ user
+ end
+
+ def assign_agent_to_teams(user)
+ teams_to_assign = @teams.sample(rand(1..3))
+ teams_to_assign.each do |team|
+ TeamMember.create!(
+ team_id: team.id,
+ user_id: user.id
+ )
+ end
+ end
+
+ def create_labels
+ TOTAL_LABELS.times do |i|
+ label = @account.labels.create!(
+ title: "Label-#{i + 1}-#{Faker::Lorem.word}",
+ description: Faker::Company.catch_phrase,
+ color: Faker::Color.hex_color
+ )
+ @labels << label
+ print "\rCreating labels: #{i + 1}/#{TOTAL_LABELS}"
+ end
+
+ print "\n"
+ end
+
+ def create_inboxes
+ TOTAL_INBOXES.times do |_i|
+ inbox = create_single_inbox
+ assign_agents_to_inbox(inbox)
+ @inboxes << inbox
+ print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
+ end
+
+ print "\n"
+ end
+
+ def create_single_inbox
+ channel = Channel::WebWidget.create!(
+ website_url: "https://#{Faker::Internet.domain_name}",
+ account_id: @account.id
+ )
+
+ @account.inboxes.create!(
+ name: "#{Faker::Company.name} Website",
+ channel: channel
+ )
+ end
+
+ def assign_agents_to_inbox(inbox)
+ agents_to_assign = if @inboxes.empty?
+ # First inbox gets all agents to ensure coverage
+ @agents
+ else
+ # Subsequent inboxes get random selection with some overlap
+ min_agents = [@agents.size / TOTAL_INBOXES, 10].max
+ max_agents = [(@agents.size * 0.8).to_i, 50].min
+ @agents.sample(rand(min_agents..max_agents))
+ end
+
+ agents_to_assign.each do |agent|
+ InboxMember.create!(inbox: inbox, user: agent)
+ end
+ end
+
+ def create_contacts
+ TOTAL_CONTACTS.times do |i|
+ contact = @account.contacts.create!(
+ name: Faker::Name.name,
+ email: Faker::Internet.email,
+ phone_number: Faker::PhoneNumber.cell_phone_in_e164,
+ identifier: SecureRandom.uuid,
+ additional_attributes: {
+ company: Faker::Company.name,
+ city: Faker::Address.city,
+ country: Faker::Address.country,
+ customer_since: Faker::Date.between(from: 2.years.ago, to: Time.zone.today)
+ }
+ )
+ @contacts << contact
+
+ print "\rCreating contacts: #{i + 1}/#{TOTAL_CONTACTS}"
+ end
+
+ print "\n"
+ end
+
+ def create_conversations
+ conversation_creator = Seeders::Reports::ConversationCreator.new(
+ account: @account,
+ resources: {
+ contacts: @contacts,
+ inboxes: @inboxes,
+ teams: @teams,
+ labels: @labels,
+ agents: @agents
+ }
+ )
+
+ TOTAL_CONVERSATIONS.times do |i|
+ created_at = Faker::Time.between(from: START_DATE, to: END_DATE)
+ conversation_creator.create_conversation(created_at: created_at)
+
+ completion_percentage = ((i + 1).to_f / TOTAL_CONVERSATIONS * 100).round
+ print "\rCreating conversations: #{i + 1}/#{TOTAL_CONVERSATIONS} (#{completion_percentage}%)"
+ end
+
+ print "\n"
+ end
+end
+# rubocop:enable Rails/Output
diff --git a/lib/tasks/seed_reports_data.rake b/lib/tasks/seed_reports_data.rake
new file mode 100644
index 000000000..5c80f1e51
--- /dev/null
+++ b/lib/tasks/seed_reports_data.rake
@@ -0,0 +1,24 @@
+namespace :db do
+ namespace :seed do
+ desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
+ task reports_data: :environment do
+ if ENV['ACCOUNT_ID'].blank?
+ puts 'Please provide an ACCOUNT_ID environment variable'
+ puts 'Usage: ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data'
+ exit 1
+ end
+
+ ENV['ENABLE_ACCOUNT_SEEDING'] = 'true' if ENV['ENABLE_ACCOUNT_SEEDING'].blank?
+
+ account_id = ENV.fetch('ACCOUNT_ID', nil)
+ account = Account.find(account_id)
+
+ puts "Starting reports data seeding for account: #{account.name} (ID: #{account.id})"
+
+ seeder = Seeders::Reports::ReportDataSeeder.new(account: account)
+ seeder.perform!
+
+ puts "Finished seeding reports data for account: #{account.name}"
+ end
+ end
+end
diff --git a/package.json b/package.json
index eb5098fa0..3ec557f2e 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
- "@chatwoot/utils": "^0.0.46",
+ "@chatwoot/utils": "^0.0.47",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index db7589569..b8b23d1d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,8 +23,8 @@ importers:
specifier: 1.1.1-next
version: 1.1.1-next
'@chatwoot/utils':
- specifier: ^0.0.46
- version: 0.0.46
+ specifier: ^0.0.47
+ version: 0.0.47
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -406,8 +406,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
- '@chatwoot/utils@0.0.46':
- resolution: {integrity: sha512-a68CQ+aPFfyMr7dnXUUSt/kwHEazBd7Y8aidDZeDp5eL7sych7EpmT5XMTmhttlqMiRsmwETblXJJ2fBH6I44A==}
+ '@chatwoot/utils@0.0.47':
+ resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5255,7 +5255,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.46':
+ '@chatwoot/utils@0.0.47':
dependencies:
date-fns: 2.30.0
diff --git a/spec/builders/v2/reports/label_summary_builder_spec.rb b/spec/builders/v2/reports/label_summary_builder_spec.rb
new file mode 100644
index 000000000..7a5a589dd
--- /dev/null
+++ b/spec/builders/v2/reports/label_summary_builder_spec.rb
@@ -0,0 +1,317 @@
+require 'rails_helper'
+
+RSpec.describe V2::Reports::LabelSummaryBuilder do
+ include ActiveJob::TestHelper
+
+ let_it_be(:account) { create(:account) }
+ let_it_be(:label_1) { create(:label, title: 'label_1', account: account) }
+ let_it_be(:label_2) { create(:label, title: 'label_2', account: account) }
+ let_it_be(:label_3) { create(:label, title: 'label_3', account: account) }
+
+ let(:params) do
+ {
+ business_hours: business_hours,
+ since: (Time.zone.today - 3.days).to_time.to_i.to_s,
+ until: Time.zone.today.end_of_day.to_time.to_i.to_s,
+ timezone_offset: 0
+ }
+ end
+ let(:builder) { described_class.new(account: account, params: params) }
+
+ describe '#initialize' do
+ let(:business_hours) { false }
+
+ it 'sets account and params' do
+ expect(builder.account).to eq(account)
+ expect(builder.params).to eq(params)
+ end
+
+ it 'sets timezone from timezone_offset' do
+ builder_with_offset = described_class.new(account: account, params: { timezone_offset: -8 })
+ expect(builder_with_offset.instance_variable_get(:@timezone)).to eq('Pacific Time (US & Canada)')
+ end
+
+ it 'defaults timezone when timezone_offset is not provided' do
+ builder_without_offset = described_class.new(account: account, params: {})
+ expect(builder_without_offset.instance_variable_get(:@timezone)).not_to be_nil
+ end
+ end
+
+ describe '#build' do
+ context 'when there are no labels' do
+ let(:business_hours) { false }
+ let(:empty_account) { create(:account) }
+ let(:empty_builder) { described_class.new(account: empty_account, params: params) }
+
+ it 'returns empty array' do
+ expect(empty_builder.build).to eq([])
+ end
+ end
+
+ context 'when there are labels but no conversations' do
+ let(:business_hours) { false }
+
+ it 'returns zero values for all labels' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ bug_report = report.find { |r| r[:name] == 'label_1' }
+ feature_request = report.find { |r| r[:name] == 'label_2' }
+ customer_support = report.find { |r| r[:name] == 'label_3' }
+
+ [
+ [bug_report, label_1, 'label_1'],
+ [feature_request, label_2, 'label_2'],
+ [customer_support, label_3, 'label_3']
+ ].each do |report_data, label, label_name|
+ expect(report_data).to include(
+ id: label.id,
+ name: label_name,
+ conversations_count: 0,
+ avg_resolution_time: 0,
+ avg_first_response_time: 0,
+ avg_reply_time: 0,
+ resolved_conversations_count: 0
+ )
+ end
+ end
+ end
+
+ context 'when there are labeled conversations with metrics' do
+ before do
+ travel_to(Time.zone.today) do
+ user = create(:user, account: account)
+ inbox = create(:inbox, account: account)
+ create(:inbox_member, user: user, inbox: inbox)
+
+ gravatar_url = 'https://www.gravatar.com'
+ stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
+
+ perform_enqueued_jobs do
+ # Create conversations with label_1
+ 3.times do
+ conversation = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: Time.zone.today)
+ create_list(:message, 2, message_type: 'outgoing',
+ account: account, inbox: inbox,
+ conversation: conversation,
+ created_at: Time.zone.today + 1.hour)
+ create_list(:message, 1, message_type: 'incoming',
+ account: account, inbox: inbox,
+ conversation: conversation,
+ created_at: Time.zone.today + 2.hours)
+ conversation.update_labels('label_1')
+ conversation.label_list
+ conversation.save!
+ end
+
+ # Create conversations with label_2
+ 2.times do
+ conversation = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: Time.zone.today)
+ create_list(:message, 1, message_type: 'outgoing',
+ account: account, inbox: inbox,
+ conversation: conversation,
+ created_at: Time.zone.today + 1.hour)
+ conversation.update_labels('label_2')
+ conversation.label_list
+ conversation.save!
+ end
+
+ # Resolve some conversations
+ conversations_to_resolve = account.conversations.first(2)
+ conversations_to_resolve.each(&:toggle_status)
+
+ # Create some reporting events
+ account.conversations.reload.each_with_index do |conv, idx|
+ # First response times
+ create(:reporting_event,
+ account: account,
+ conversation: conv,
+ name: 'first_response',
+ value: (30 + (idx * 10)) * 60,
+ value_in_business_hours: (20 + (idx * 5)) * 60,
+ created_at: Time.zone.today)
+
+ # Reply times
+ create(:reporting_event,
+ account: account,
+ conversation: conv,
+ name: 'reply',
+ value: (15 + (idx * 5)) * 60,
+ value_in_business_hours: (10 + (idx * 3)) * 60,
+ created_at: Time.zone.today)
+
+ # Resolution times for resolved conversations
+ next unless conv.resolved?
+
+ create(:reporting_event,
+ account: account,
+ conversation: conv,
+ name: 'conversation_resolved',
+ value: (60 + (idx * 30)) * 60,
+ value_in_business_hours: (45 + (idx * 20)) * 60,
+ created_at: Time.zone.today)
+ end
+ end
+ end
+ end
+
+ context 'when business hours is disabled' do
+ let(:business_hours) { false }
+
+ it 'returns correct label stats using regular values' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ label_2_report = report.find { |r| r[:name] == 'label_2' }
+ label_3_report = report.find { |r| r[:name] == 'label_3' }
+
+ expect(label_1_report).to include(
+ conversations_count: 3,
+ avg_first_response_time: be > 0,
+ avg_reply_time: be > 0
+ )
+
+ expect(label_2_report).to include(
+ conversations_count: 2,
+ avg_first_response_time: be > 0,
+ avg_reply_time: be > 0
+ )
+
+ expect(label_3_report).to include(
+ conversations_count: 0,
+ avg_first_response_time: 0,
+ avg_reply_time: 0
+ )
+ end
+ end
+
+ context 'when business hours is enabled' do
+ let(:business_hours) { true }
+
+ it 'returns correct label stats using business hours values' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ label_2_report = report.find { |r| r[:name] == 'label_2' }
+
+ expect(label_1_report[:conversations_count]).to eq(3)
+ expect(label_1_report[:avg_first_response_time]).to be > 0
+ expect(label_1_report[:avg_reply_time]).to be > 0
+
+ expect(label_2_report[:conversations_count]).to eq(2)
+ expect(label_2_report[:avg_first_response_time]).to be > 0
+ expect(label_2_report[:avg_reply_time]).to be > 0
+ end
+ end
+ end
+
+ context 'when filtering by date range' do
+ let(:business_hours) { false }
+
+ before do
+ travel_to(Time.zone.today) do
+ user = create(:user, account: account)
+ inbox = create(:inbox, account: account)
+ create(:inbox_member, user: user, inbox: inbox)
+
+ gravatar_url = 'https://www.gravatar.com'
+ stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
+
+ perform_enqueued_jobs do
+ # Conversation within range
+ conversation_in_range = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: 2.days.ago)
+ conversation_in_range.update_labels('label_1')
+ conversation_in_range.label_list
+ conversation_in_range.save!
+
+ create(:reporting_event,
+ account: account,
+ conversation: conversation_in_range,
+ name: 'first_response',
+ value: 1800,
+ created_at: 2.days.ago)
+
+ # Conversation outside range (too old)
+ conversation_out_of_range = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: 1.week.ago)
+ conversation_out_of_range.update_labels('label_1')
+ conversation_out_of_range.label_list
+ conversation_out_of_range.save!
+
+ create(:reporting_event,
+ account: account,
+ conversation: conversation_out_of_range,
+ name: 'first_response',
+ value: 3600,
+ created_at: 1.week.ago)
+ end
+ end
+ end
+
+ it 'only includes conversations within the date range' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ expect(label_1_report).not_to be_nil
+ expect(label_1_report[:conversations_count]).to eq(1)
+ expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
+ end
+ end
+
+ context 'with business hours parameter' do
+ let(:business_hours) { 'true' }
+
+ before do
+ travel_to(Time.zone.today) do
+ user = create(:user, account: account)
+ inbox = create(:inbox, account: account)
+ create(:inbox_member, user: user, inbox: inbox)
+
+ gravatar_url = 'https://www.gravatar.com'
+ stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
+
+ perform_enqueued_jobs do
+ conversation = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: Time.zone.today)
+ conversation.update_labels('label_1')
+ conversation.label_list
+ conversation.save!
+
+ create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ name: 'first_response',
+ value: 3600,
+ value_in_business_hours: 1800,
+ created_at: Time.zone.today)
+ end
+ end
+ end
+
+ it 'properly casts string "true" to boolean and uses business hours values' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ expect(label_1_report).not_to be_nil
+ expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/dashboard_controller_spec.rb b/spec/controllers/dashboard_controller_spec.rb
index 517443858..e022e22c7 100644
--- a/spec/controllers/dashboard_controller_spec.rb
+++ b/spec/controllers/dashboard_controller_spec.rb
@@ -19,9 +19,10 @@ describe '/app/login', type: :request do
end
context 'with non-HTML format' do
- it 'returns not acceptable for JSON' do
- get '/app/login', params: { format: 'json' }
+ it 'returns not acceptable for JSON with error message' do
+ get '/app/login', headers: { 'Accept' => 'application/json' }
expect(response).to have_http_status(:not_acceptable)
+ expect(response.parsed_body).to eq({ 'error' => 'Please use API routes instead of dashboard routes for JSON requests' })
end
end
diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb
index 0702baa32..78a8e9d7c 100644
--- a/spec/services/telegram/incoming_message_service_spec.rb
+++ b/spec/services/telegram/incoming_message_service_spec.rb
@@ -179,6 +179,35 @@ describe Telegram::IncomingMessageService do
end
end
+ context 'when valid video_note messages params' do
+ it 'creates appropriate conversations, message and contacts' do
+ allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mov')
+ params = {
+ 'update_id' => 2_342_342_343_242,
+ 'message' => {
+ 'video_note' => {
+ 'duration' => 3,
+ 'length' => 240,
+ 'thumb' => {
+ 'file_id' => 'AAMCBQADGQEAA4ZhXd78Xz6_c6gCzbdIkgGiXJcwwwACqwMAAp3x8Fbhf3EWamgCWAEAB20AAyEE',
+ 'file_unique_id' => 'AQADqwMAAp3x8FZy',
+ 'file_size' => 11_462,
+ 'width' => 240,
+ 'height' => 240
+ },
+ 'file_id' => 'DQACAgUAAxkBAAIBY2FdJlhf8PC2E3IalXSvXWO5m8GBAALJAwACwqHgVhb0truM0uhwIQQ',
+ 'file_unique_id' => 'AgADyQMAAsKh4FY',
+ 'file_size' => 132_446
+ }
+ }.merge(message_params)
+ }.with_indifferent_access
+ described_class.new(inbox: telegram_channel.inbox, params: params).perform
+ expect(telegram_channel.inbox.conversations.count).not_to eq(0)
+ expect(Contact.all.first.name).to eq('Sojan Jose')
+ expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
+ end
+ end
+
context 'when valid voice attachment params' do
it 'creates appropriate conversations, message and contacts' do
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.ogg')