mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 12:08:01 +00:00
feat: label reports overview (#11194)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
101
app/builders/v2/reports/label_summary_builder.rb
Normal file
101
app/builders/v2/reports/label_summary_builder.rb
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||||
|
attr_reader :account, :params
|
||||||
|
|
||||||
|
# rubocop:disable Lint/MissingSuper
|
||||||
|
# the parent class has no initialize
|
||||||
|
def initialize(account:, params:)
|
||||||
|
@account = account
|
||||||
|
@params = params
|
||||||
|
|
||||||
|
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||||
|
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||||
|
end
|
||||||
|
# rubocop:enable Lint/MissingSuper
|
||||||
|
|
||||||
|
def build
|
||||||
|
labels = account.labels.to_a
|
||||||
|
return [] if labels.empty?
|
||||||
|
|
||||||
|
report_data = collect_report_data
|
||||||
|
labels.map { |label| build_label_report(label, report_data) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def collect_report_data
|
||||||
|
conversation_filter = build_conversation_filter
|
||||||
|
use_business_hours = use_business_hours?
|
||||||
|
|
||||||
|
{
|
||||||
|
conversation_counts: fetch_conversation_counts(conversation_filter),
|
||||||
|
resolved_counts: fetch_resolved_counts(conversation_filter),
|
||||||
|
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
|
||||||
|
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
|
||||||
|
reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_label_report(label, report_data)
|
||||||
|
{
|
||||||
|
id: label.id,
|
||||||
|
name: label.title,
|
||||||
|
conversations_count: report_data[:conversation_counts][label.title] || 0,
|
||||||
|
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
|
||||||
|
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
|
||||||
|
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
|
||||||
|
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def use_business_hours?
|
||||||
|
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_conversation_filter
|
||||||
|
conversation_filter = { account_id: account.id }
|
||||||
|
conversation_filter[:created_at] = range if range.present?
|
||||||
|
|
||||||
|
conversation_filter
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_conversation_counts(conversation_filter)
|
||||||
|
fetch_counts(conversation_filter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_resolved_counts(conversation_filter)
|
||||||
|
fetch_counts(conversation_filter.merge(status: :resolved))
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_counts(conversation_filter)
|
||||||
|
ActsAsTaggableOn::Tagging
|
||||||
|
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||||
|
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||||
|
.where(
|
||||||
|
taggable_type: 'Conversation',
|
||||||
|
context: 'labels',
|
||||||
|
conversations: conversation_filter
|
||||||
|
)
|
||||||
|
.select('tags.name, COUNT(taggings.*) AS count')
|
||||||
|
.group('tags.name')
|
||||||
|
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_metrics(conversation_filter, event_name, use_business_hours)
|
||||||
|
ReportingEvent
|
||||||
|
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
|
||||||
|
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
|
||||||
|
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||||
|
.where(
|
||||||
|
conversations: conversation_filter,
|
||||||
|
name: event_name,
|
||||||
|
taggings: { taggable_type: 'Conversation', context: 'labels' }
|
||||||
|
)
|
||||||
|
.group('tags.name')
|
||||||
|
.order('tags.name')
|
||||||
|
.select(
|
||||||
|
'tags.name',
|
||||||
|
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
|
||||||
|
)
|
||||||
|
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
|
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
||||||
|
|
||||||
def agent
|
def agent
|
||||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||||
@@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
|||||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def label
|
||||||
|
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_authorization
|
def check_authorization
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def generate_labels_report
|
def generate_labels_report
|
||||||
Current.account.labels.map do |label|
|
reports = V2::Reports::LabelSummaryBuilder.new(
|
||||||
label_report = report_builder({ type: :label, id: label.id }).short_summary
|
account: Current.account,
|
||||||
[label.title] + generate_readable_report_metrics(label_report)
|
params: build_params({})
|
||||||
|
).build
|
||||||
|
|
||||||
|
reports.map do |report|
|
||||||
|
[report[:name]] + generate_readable_report_metrics(report)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLabelReports({ since, until, businessHours } = {}) {
|
||||||
|
return axios.get(`${this.url}/label`, {
|
||||||
|
params: {
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
business_hours: businessHours,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SummaryReportsAPI();
|
export default new SummaryReportsAPI();
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const newReportRoutes = () => [
|
|||||||
{
|
{
|
||||||
name: 'Reports Label',
|
name: 'Reports Label',
|
||||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||||
to: accountScopedRoute('label_reports'),
|
to: accountScopedRoute('label_reports_index'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Reports Inbox',
|
name: 'Reports Inbox',
|
||||||
|
|||||||
@@ -193,6 +193,7 @@
|
|||||||
},
|
},
|
||||||
"LABEL_REPORTS": {
|
"LABEL_REPORTS": {
|
||||||
"HEADER": "Labels Overview",
|
"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...",
|
"LOADING_CHART": "Loading chart data...",
|
||||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||||
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
||||||
@@ -559,6 +560,7 @@
|
|||||||
"INBOX": "Inbox",
|
"INBOX": "Inbox",
|
||||||
"AGENT": "Agent",
|
"AGENT": "Agent",
|
||||||
"TEAM": "Team",
|
"TEAM": "Team",
|
||||||
|
"LABEL": "Label",
|
||||||
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
|
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
|
||||||
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
|
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
|
||||||
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
|
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import ReportHeader from './components/ReportHeader.vue';
|
||||||
|
import SummaryReports from './components/SummaryReports.vue';
|
||||||
|
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const summarReportsRef = ref(null);
|
||||||
|
|
||||||
|
const onDownloadClick = () => {
|
||||||
|
summarReportsRef.value.downloadReports();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ReportHeader
|
||||||
|
:header-title="$t('LABEL_REPORTS.HEADER')"
|
||||||
|
:header-description="$t('LABEL_REPORTS.DESCRIPTION')"
|
||||||
|
>
|
||||||
|
<V4Button
|
||||||
|
:label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
|
||||||
|
icon="i-ph-download-simple"
|
||||||
|
size="sm"
|
||||||
|
@click="onDownloadClick"
|
||||||
|
/>
|
||||||
|
</ReportHeader>
|
||||||
|
|
||||||
|
<SummaryReports
|
||||||
|
ref="summarReportsRef"
|
||||||
|
action-key="summaryReports/fetchLabelSummaryReports"
|
||||||
|
getter-key="labels/getLabels"
|
||||||
|
fetch-items-key="labels/get"
|
||||||
|
summary-key="summaryReports/getLabelSummaryReports"
|
||||||
|
type="label"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import WootReports from './components/WootReports.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const store = useStore();
|
||||||
|
const label = useFunctionGetter('labels/getLabelById', route.params.id);
|
||||||
|
|
||||||
|
onMounted(() => store.dispatch('labels/get'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WootReports
|
||||||
|
v-if="label.id"
|
||||||
|
:key="label.id"
|
||||||
|
type="label"
|
||||||
|
getter-key="labels/getLabels"
|
||||||
|
action-key="labels/get"
|
||||||
|
:selected-item="label"
|
||||||
|
:download-button-label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
|
||||||
|
:report-title="$t('LABEL_REPORTS.HEADER')"
|
||||||
|
has-back-button
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full py-20">
|
||||||
|
<Spinner class="mx-auto" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -108,7 +108,8 @@ const tableData = computed(() =>
|
|||||||
} = rowMetrics;
|
} = rowMetrics;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
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,
|
type: props.type,
|
||||||
conversationsCount: renderCount(conversationsCount),
|
conversationsCount: renderCount(conversationsCount),
|
||||||
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
|
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
|
||||||
@@ -177,7 +178,7 @@ defineExpose({ downloadReports });
|
|||||||
<template>
|
<template>
|
||||||
<ReportFilterSelector @filter-change="onFilterChange" />
|
<ReportFilterSelector @filter-change="onFilterChange" />
|
||||||
<div
|
<div
|
||||||
class="flex-1 overflow-auto px-5 py-6 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||||
>
|
>
|
||||||
<Table :table="table" />
|
<Table :table="table" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import Index from './Index.vue';
|
|||||||
import AgentReportsIndex from './AgentReportsIndex.vue';
|
import AgentReportsIndex from './AgentReportsIndex.vue';
|
||||||
import InboxReportsIndex from './InboxReportsIndex.vue';
|
import InboxReportsIndex from './InboxReportsIndex.vue';
|
||||||
import TeamReportsIndex from './TeamReportsIndex.vue';
|
import TeamReportsIndex from './TeamReportsIndex.vue';
|
||||||
|
import LabelReportsIndex from './LabelReportsIndex.vue';
|
||||||
|
|
||||||
import AgentReportsShow from './AgentReportsShow.vue';
|
import AgentReportsShow from './AgentReportsShow.vue';
|
||||||
import InboxReportsShow from './InboxReportsShow.vue';
|
import InboxReportsShow from './InboxReportsShow.vue';
|
||||||
import TeamReportsShow from './TeamReportsShow.vue';
|
import TeamReportsShow from './TeamReportsShow.vue';
|
||||||
|
import LabelReportsShow from './LabelReportsShow.vue';
|
||||||
|
|
||||||
import AgentReports from './AgentReports.vue';
|
import AgentReports from './AgentReports.vue';
|
||||||
import InboxReports from './InboxReports.vue';
|
import InboxReports from './InboxReports.vue';
|
||||||
@@ -104,6 +106,22 @@ const revisedReportRoutes = [
|
|||||||
},
|
},
|
||||||
component: TeamReportsShow,
|
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 {
|
export default {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export const getters = {
|
|||||||
.filter(record => record.show_on_sidebar)
|
.filter(record => record.show_on_sidebar)
|
||||||
.sort((a, b) => a.title.localeCompare(b.title));
|
.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
},
|
},
|
||||||
|
getLabelById: _state => id => {
|
||||||
|
return _state.records.find(record => record.id === Number(id));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ vi.mock('dashboard/api/summaryReports', () => ({
|
|||||||
getInboxReports: vi.fn(),
|
getInboxReports: vi.fn(),
|
||||||
getAgentReports: vi.fn(),
|
getAgentReports: vi.fn(),
|
||||||
getTeamReports: vi.fn(),
|
getTeamReports: vi.fn(),
|
||||||
|
getLabelReports: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -25,10 +26,12 @@ describe('Summary Reports Store', () => {
|
|||||||
inboxSummaryReports: [],
|
inboxSummaryReports: [],
|
||||||
agentSummaryReports: [],
|
agentSummaryReports: [],
|
||||||
teamSummaryReports: [],
|
teamSummaryReports: [],
|
||||||
|
labelSummaryReports: [],
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetchingInboxSummaryReports: false,
|
isFetchingInboxSummaryReports: false,
|
||||||
isFetchingAgentSummaryReports: false,
|
isFetchingAgentSummaryReports: false,
|
||||||
isFetchingTeamSummaryReports: false,
|
isFetchingTeamSummaryReports: false,
|
||||||
|
isFetchingLabelSummaryReports: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -39,6 +42,7 @@ describe('Summary Reports Store', () => {
|
|||||||
inboxSummaryReports: [{ id: 1 }],
|
inboxSummaryReports: [{ id: 1 }],
|
||||||
agentSummaryReports: [{ id: 2 }],
|
agentSummaryReports: [{ id: 2 }],
|
||||||
teamSummaryReports: [{ id: 3 }],
|
teamSummaryReports: [{ id: 3 }],
|
||||||
|
labelSummaryReports: [{ id: 4 }],
|
||||||
uiFlags: { isFetchingInboxSummaryReports: true },
|
uiFlags: { isFetchingInboxSummaryReports: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +58,10 @@ describe('Summary Reports Store', () => {
|
|||||||
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
|
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', () => {
|
it('should return UI flags', () => {
|
||||||
expect(store.getters.getUIFlags(state)).toEqual({
|
expect(store.getters.getUIFlags(state)).toEqual({
|
||||||
isFetchingInboxSummaryReports: true,
|
isFetchingInboxSummaryReports: true,
|
||||||
@@ -86,6 +94,14 @@ describe('Summary Reports Store', () => {
|
|||||||
expect(state.teamSummaryReports).toEqual(data);
|
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', () => {
|
it('should merge UI flags with existing flags', () => {
|
||||||
const state = {
|
const state = {
|
||||||
uiFlags: { flag1: true, flag2: false },
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ const typeMap = {
|
|||||||
apiMethod: 'getTeamReports',
|
apiMethod: 'getTeamReports',
|
||||||
mutationKey: 'setTeamSummaryReport',
|
mutationKey: 'setTeamSummaryReport',
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
flagKey: 'isFetchingLabelSummaryReports',
|
||||||
|
apiMethod: 'getLabelReports',
|
||||||
|
mutationKey: 'setLabelSummaryReport',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchSummaryReports(type, params, { commit }) {
|
async function fetchSummaryReports(type, params, { commit }) {
|
||||||
@@ -38,10 +43,12 @@ export const initialState = {
|
|||||||
inboxSummaryReports: [],
|
inboxSummaryReports: [],
|
||||||
agentSummaryReports: [],
|
agentSummaryReports: [],
|
||||||
teamSummaryReports: [],
|
teamSummaryReports: [],
|
||||||
|
labelSummaryReports: [],
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetchingInboxSummaryReports: false,
|
isFetchingInboxSummaryReports: false,
|
||||||
isFetchingAgentSummaryReports: false,
|
isFetchingAgentSummaryReports: false,
|
||||||
isFetchingTeamSummaryReports: false,
|
isFetchingTeamSummaryReports: false,
|
||||||
|
isFetchingLabelSummaryReports: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +62,9 @@ export const getters = {
|
|||||||
getTeamSummaryReports(state) {
|
getTeamSummaryReports(state) {
|
||||||
return state.teamSummaryReports;
|
return state.teamSummaryReports;
|
||||||
},
|
},
|
||||||
|
getLabelSummaryReports(state) {
|
||||||
|
return state.labelSummaryReports;
|
||||||
|
},
|
||||||
getUIFlags(state) {
|
getUIFlags(state) {
|
||||||
return state.uiFlags;
|
return state.uiFlags;
|
||||||
},
|
},
|
||||||
@@ -72,6 +82,10 @@ export const actions = {
|
|||||||
fetchTeamSummaryReports({ commit }, params) {
|
fetchTeamSummaryReports({ commit }, params) {
|
||||||
return fetchSummaryReports('team', params, { commit });
|
return fetchSummaryReports('team', params, { commit });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchLabelSummaryReports({ commit }, params) {
|
||||||
|
return fetchSummaryReports('label', params, { commit });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
@@ -84,6 +98,9 @@ export const mutations = {
|
|||||||
setTeamSummaryReport(state, data) {
|
setTeamSummaryReport(state, data) {
|
||||||
state.teamSummaryReports = data;
|
state.teamSummaryReports = data;
|
||||||
},
|
},
|
||||||
|
setLabelSummaryReport(state, data) {
|
||||||
|
state.labelSummaryReports = data;
|
||||||
|
},
|
||||||
setUIFlags(state, uiFlag) {
|
setUIFlags(state, uiFlag) {
|
||||||
state.uiFlags = { ...state.uiFlags, ...uiFlag };
|
state.uiFlags = { ...state.uiFlags, ...uiFlag };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ Rails.application.routes.draw do
|
|||||||
get :agent
|
get :agent
|
||||||
get :team
|
get :team
|
||||||
get :inbox
|
get :inbox
|
||||||
|
get :label
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :reports, only: [:index] do
|
resources :reports, only: [:index] do
|
||||||
|
|||||||
105
lib/seeders/reports/conversation_creator.rb
Normal file
105
lib/seeders/reports/conversation_creator.rb
Normal file
@@ -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
|
||||||
141
lib/seeders/reports/message_creator.rb
Normal file
141
lib/seeders/reports/message_creator.rb
Normal file
@@ -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
|
||||||
234
lib/seeders/reports/report_data_seeder.rb
Normal file
234
lib/seeders/reports/report_data_seeder.rb
Normal file
@@ -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
|
||||||
24
lib/tasks/seed_reports_data.rake
Normal file
24
lib/tasks/seed_reports_data.rake
Normal file
@@ -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
|
||||||
317
spec/builders/v2/reports/label_summary_builder_spec.rb
Normal file
317
spec/builders/v2/reports/label_summary_builder_spec.rb
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user