Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b34fbf8e | ||
|
|
01997e5c5e | ||
|
|
88deecd02e |
@@ -266,6 +266,8 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
|
||||
+105
-5
@@ -9,9 +9,11 @@ import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import format from 'date-fns/format';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { downloadCsvFile } from 'dashboard/helper/downloadHelper';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
@@ -19,6 +21,7 @@ const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||
const accountConversationHeatmap = useMapGetter(
|
||||
'getAccountConversationHeatmapData'
|
||||
);
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const { t } = useI18n();
|
||||
|
||||
const menuItems = [
|
||||
@@ -33,19 +36,78 @@ const menuItems = [
|
||||
];
|
||||
|
||||
const selectedDays = ref(6);
|
||||
const selectedInbox = ref(null);
|
||||
|
||||
const selectedDayFilter = computed(() =>
|
||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||
);
|
||||
|
||||
const inboxMenuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_REPORTS.ALL_INBOXES'),
|
||||
value: null,
|
||||
},
|
||||
...inboxes.value.map(inbox => ({
|
||||
label: inbox.name,
|
||||
value: inbox.id,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
const selectedInboxFilter = computed(() => {
|
||||
if (!selectedInbox.value) {
|
||||
return { label: t('INBOX_REPORTS.ALL_INBOXES') };
|
||||
}
|
||||
return inboxMenuItems.value.find(
|
||||
item => item.value === selectedInbox.value.id
|
||||
);
|
||||
});
|
||||
|
||||
const downloadHeatmapData = () => {
|
||||
const to = endOfDay(new Date());
|
||||
store.dispatch('downloadAccountConversationHeatmap', {
|
||||
daysBefore: selectedDays.value,
|
||||
to: getUnixTime(to),
|
||||
|
||||
// If no inbox is selected, use the existing backend CSV endpoint
|
||||
if (!selectedInbox.value) {
|
||||
store.dispatch('downloadAccountConversationHeatmap', {
|
||||
daysBefore: selectedDays.value,
|
||||
to: getUnixTime(to),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If inbox is selected, generate CSV from store data
|
||||
if (
|
||||
!accountConversationHeatmap.value ||
|
||||
accountConversationHeatmap.value.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSV headers
|
||||
const headers = ['Date', 'Hour', 'Conversations Count'];
|
||||
const rows = [headers];
|
||||
|
||||
// Convert heatmap data to rows
|
||||
accountConversationHeatmap.value.forEach(item => {
|
||||
const date = new Date(item.timestamp * 1000);
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
const hour = date.getHours();
|
||||
rows.push([dateStr, `${hour}:00 - ${hour + 1}:00`, item.value]);
|
||||
});
|
||||
|
||||
// Convert to CSV string
|
||||
const csvContent = rows.map(row => row.join(',')).join('\n');
|
||||
|
||||
// Generate filename with inbox name
|
||||
const inboxName = selectedInbox.value.name.replace(/[^a-z0-9]/gi, '_');
|
||||
const fileName = `conversation_heatmap_${inboxName}_${format(new Date(), 'dd-MM-yyyy')}.csv`;
|
||||
|
||||
// Download the file
|
||||
downloadCsvFile(fileName, csvContent);
|
||||
};
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
|
||||
const fetchHeatmapData = () => {
|
||||
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
||||
return;
|
||||
@@ -54,13 +116,21 @@ const fetchHeatmapData = () => {
|
||||
let to = endOfDay(new Date());
|
||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||
|
||||
store.dispatch('fetchAccountConversationHeatmap', {
|
||||
const params = {
|
||||
metric: 'conversations_count',
|
||||
from: getUnixTime(from),
|
||||
to: getUnixTime(to),
|
||||
groupBy: 'hour',
|
||||
businessHours: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Add inbox filtering if an inbox is selected
|
||||
if (selectedInbox.value) {
|
||||
params.type = 'inbox';
|
||||
params.id = selectedInbox.value.id;
|
||||
}
|
||||
|
||||
store.dispatch('fetchAccountConversationHeatmap', params);
|
||||
};
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
@@ -69,9 +139,18 @@ const handleAction = ({ value }) => {
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const handleInboxAction = ({ value }) => {
|
||||
toggleInboxDropdown(false);
|
||||
selectedInbox.value = value
|
||||
? inboxes.value.find(inbox => inbox.id === value)
|
||||
: null;
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
fetchHeatmapData();
|
||||
startRefetching();
|
||||
});
|
||||
@@ -100,6 +179,27 @@ onMounted(() => {
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleInboxDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="selectedInboxFilter.label"
|
||||
class="rounded-md group-hover:bg-n-alpha-2 max-w-[200px]"
|
||||
@click="toggleInboxDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showInboxDropdown"
|
||||
:menu-items="inboxMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]"
|
||||
@action="handleInboxAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Provides session management functionality for Devise Token Auth tokens.
|
||||
# Handles logout operations, session limits, and token cleanup.
|
||||
module SessionManageable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Clears all active sessions for the user.
|
||||
# @return [void]
|
||||
def logout_all_sessions!
|
||||
self.tokens = {}
|
||||
save!
|
||||
end
|
||||
|
||||
# Logs out a specific session by client ID.
|
||||
# @param client_id [String] the client identifier to logout
|
||||
# @return [Boolean] true if session was found and removed
|
||||
def logout_session!(client_id)
|
||||
return false unless client_id.present? && tokens.present?
|
||||
|
||||
removed = tokens.delete(client_id)
|
||||
save! if removed
|
||||
|
||||
removed.present?
|
||||
end
|
||||
|
||||
# Removes tokens that expired before the given timestamp.
|
||||
# @param timestamp [Time, Integer] cutoff time for token cleanup
|
||||
# @return [void]
|
||||
def reset_tokens_before!(timestamp)
|
||||
return unless tokens.present?
|
||||
|
||||
self.tokens = tokens.select do |_client_id, token_data|
|
||||
(token_data['expiry'] || 0) >= timestamp.to_i
|
||||
end
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
# Returns count of non-expired active sessions.
|
||||
# @return [Integer] number of active sessions
|
||||
def active_session_count
|
||||
return 0 unless tokens.present?
|
||||
|
||||
current_time = Time.current.to_i
|
||||
tokens.count { |_client_id, token_data| (token_data['expiry'] || 0) > current_time }
|
||||
end
|
||||
|
||||
# Checks if user has exceeded configured session limit.
|
||||
# @return [Boolean] true if session limit is exceeded
|
||||
def session_limit_exceeded?
|
||||
active_session_count >= session_limit
|
||||
end
|
||||
|
||||
# Returns session information for all active tokens.
|
||||
# @return [Array<Hash>] array of session info, sorted by expiry (newest first)
|
||||
def session_info
|
||||
return [] unless tokens.present?
|
||||
|
||||
tokens.map do |client_id, token_data|
|
||||
{
|
||||
client_id: client_id,
|
||||
expiry: Time.zone.at(token_data['expiry'] || 0)
|
||||
}
|
||||
end.sort_by { |session| session[:expiry] }.reverse
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns configured session limit from GlobalConfig.
|
||||
# Defaults to infinity if not configured.
|
||||
# @return [Integer, Float] session limit or Float::INFINITY
|
||||
def session_limit
|
||||
@session_limit ||= GlobalConfig.get(
|
||||
'USER_SESSION_LIMIT',
|
||||
'USER_SESSION_LIMIT_PER_USER',
|
||||
account: Current.account
|
||||
)&.to_i || Float::INFINITY
|
||||
end
|
||||
end
|
||||
@@ -47,6 +47,7 @@ class User < ApplicationRecord
|
||||
include Pubsubable
|
||||
include Rails.application.routes.url_helpers
|
||||
include Reportable
|
||||
include SessionManageable
|
||||
include SsoAuthenticatable
|
||||
include UserAttributeHelpers
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SessionManageable do
|
||||
let(:user) { create(:user) }
|
||||
let(:current_time) { Time.current.to_i }
|
||||
let(:expired_time) { 1.hour.ago.to_i }
|
||||
let(:future_time) { 1.hour.from_now.to_i }
|
||||
|
||||
before do
|
||||
# Mock GlobalConfig for session limit
|
||||
allow(GlobalConfig).to receive(:get).with(
|
||||
'USER_SESSION_LIMIT',
|
||||
'USER_SESSION_LIMIT_PER_USER',
|
||||
account: nil
|
||||
).and_return('5')
|
||||
end
|
||||
|
||||
describe '#logout_all_sessions!' do
|
||||
it 'clears all tokens and saves the user' do
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
|
||||
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user).to receive(:save!)
|
||||
user.logout_all_sessions!
|
||||
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
|
||||
it 'works with empty tokens' do
|
||||
user.tokens = {}
|
||||
expect(user).to receive(:save!)
|
||||
user.logout_all_sessions!
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
|
||||
it 'works with nil tokens' do
|
||||
user.tokens = nil
|
||||
expect(user).to receive(:save!)
|
||||
user.logout_all_sessions!
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#logout_session!' do
|
||||
before do
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
|
||||
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
|
||||
}
|
||||
end
|
||||
|
||||
it 'removes specific client token and saves' do
|
||||
expect(user).to receive(:save!)
|
||||
result = user.logout_session!('client1')
|
||||
|
||||
expect(result).to be true
|
||||
expect(user.tokens).not_to have_key('client1')
|
||||
expect(user.tokens).to have_key('client2')
|
||||
end
|
||||
|
||||
it 'returns false for non-existent client' do
|
||||
result = user.logout_session!('non_existent_client')
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'returns false for empty client_id' do
|
||||
result = user.logout_session!('')
|
||||
expect(result).to be false
|
||||
|
||||
result = user.logout_session!(nil)
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'returns false when no tokens present' do
|
||||
user.tokens = nil
|
||||
result = user.logout_session!('client1')
|
||||
expect(result).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reset_tokens_before!' do
|
||||
before do
|
||||
user.tokens = {
|
||||
'expired_client' => { 'token' => 'token1', 'expiry' => expired_time },
|
||||
'current_client' => { 'token' => 'token2', 'expiry' => future_time },
|
||||
'edge_case_client' => { 'token' => 'token3', 'expiry' => current_time }
|
||||
}
|
||||
end
|
||||
|
||||
it 'removes tokens that expired before the given timestamp' do
|
||||
expect(user).to receive(:save!)
|
||||
user.reset_tokens_before!(current_time)
|
||||
|
||||
expect(user.tokens).not_to have_key('expired_client')
|
||||
expect(user.tokens).to have_key('current_client')
|
||||
expect(user.tokens).to have_key('edge_case_client')
|
||||
end
|
||||
|
||||
it 'handles timestamp as Time object' do
|
||||
timestamp = Time.zone.at(current_time)
|
||||
expect(user).to receive(:save!)
|
||||
user.reset_tokens_before!(timestamp)
|
||||
|
||||
expect(user.tokens).not_to have_key('expired_client')
|
||||
expect(user.tokens).to have_key('current_client')
|
||||
end
|
||||
|
||||
it 'does nothing when no tokens present' do
|
||||
user.tokens = nil
|
||||
user.reset_tokens_before!(current_time)
|
||||
# tokens gets initialized to empty hash during the process
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
|
||||
it 'handles tokens with missing expiry' do
|
||||
user.tokens = {
|
||||
'no_expiry_client' => { 'token' => 'token1' },
|
||||
'zero_expiry_client' => { 'token' => 'token2', 'expiry' => 0 }
|
||||
}
|
||||
|
||||
expect(user).to receive(:save!)
|
||||
user.reset_tokens_before!(current_time)
|
||||
|
||||
expect(user.tokens).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active_session_count' do
|
||||
it 'counts only non-expired tokens' do
|
||||
user.tokens = {
|
||||
'expired1' => { 'token' => 'token1', 'expiry' => expired_time },
|
||||
'active1' => { 'token' => 'token2', 'expiry' => future_time },
|
||||
'active2' => { 'token' => 'token3', 'expiry' => future_time },
|
||||
'expired2' => { 'token' => 'token4', 'expiry' => expired_time }
|
||||
}
|
||||
|
||||
expect(user.active_session_count).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns 0 when no tokens present' do
|
||||
user.tokens = nil
|
||||
expect(user.active_session_count).to eq(0)
|
||||
|
||||
user.tokens = {}
|
||||
expect(user.active_session_count).to eq(0)
|
||||
end
|
||||
|
||||
it 'handles tokens with missing expiry' do
|
||||
user.tokens = {
|
||||
'no_expiry' => { 'token' => 'token1' },
|
||||
'active' => { 'token' => 'token2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user.active_session_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#session_limit_exceeded?' do
|
||||
it 'returns true when active sessions exceed limit' do
|
||||
# Mock 3 session limit
|
||||
allow(GlobalConfig).to receive(:get).and_return('3')
|
||||
|
||||
user.tokens = {
|
||||
'active1' => { 'token' => 'token1', 'expiry' => future_time },
|
||||
'active2' => { 'token' => 'token2', 'expiry' => future_time },
|
||||
'active3' => { 'token' => 'token3', 'expiry' => future_time },
|
||||
'active4' => { 'token' => 'token4', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user.session_limit_exceeded?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when within limit' do
|
||||
allow(GlobalConfig).to receive(:get).and_return('5')
|
||||
|
||||
user.tokens = {
|
||||
'active1' => { 'token' => 'token1', 'expiry' => future_time },
|
||||
'active2' => { 'token' => 'token2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user.session_limit_exceeded?).to be false
|
||||
end
|
||||
|
||||
it 'handles infinite limit' do
|
||||
allow(GlobalConfig).to receive(:get).and_return(nil)
|
||||
|
||||
user.tokens = {}
|
||||
(1..100).each do |i|
|
||||
user.tokens["client#{i}"] = { 'token' => "token#{i}", 'expiry' => future_time }
|
||||
end
|
||||
|
||||
expect(user.session_limit_exceeded?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#session_info' do
|
||||
it 'returns session information sorted by expiry (newest first)' do
|
||||
earlier_time = 2.hours.from_now.to_i
|
||||
later_time = 3.hours.from_now.to_i
|
||||
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'token1', 'expiry' => earlier_time },
|
||||
'client2' => { 'token' => 'token2', 'expiry' => later_time }
|
||||
}
|
||||
|
||||
sessions = user.session_info
|
||||
|
||||
expect(sessions.size).to eq(2)
|
||||
expect(sessions[0][:client_id]).to eq('client2')
|
||||
expect(sessions[0][:expiry]).to eq(Time.zone.at(later_time))
|
||||
expect(sessions[1][:client_id]).to eq('client1')
|
||||
expect(sessions[1][:expiry]).to eq(Time.zone.at(earlier_time))
|
||||
end
|
||||
|
||||
it 'returns empty array when no tokens' do
|
||||
user.tokens = nil
|
||||
expect(user.session_info).to eq([])
|
||||
|
||||
user.tokens = {}
|
||||
expect(user.session_info).to eq([])
|
||||
end
|
||||
|
||||
it 'handles tokens with missing expiry' do
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'token1' },
|
||||
'client2' => { 'token' => 'token2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
sessions = user.session_info
|
||||
|
||||
expect(sessions.size).to eq(2)
|
||||
expect(sessions[0][:expiry]).to eq(Time.zone.at(future_time))
|
||||
expect(sessions[1][:expiry]).to eq(Time.zone.at(0))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#session_limit' do
|
||||
it 'returns configured limit from GlobalConfig' do
|
||||
allow(GlobalConfig).to receive(:get).with(
|
||||
'USER_SESSION_LIMIT',
|
||||
'USER_SESSION_LIMIT_PER_USER',
|
||||
account: nil
|
||||
).and_return('10')
|
||||
|
||||
limit = user.send(:session_limit)
|
||||
expect(limit).to eq(10)
|
||||
end
|
||||
|
||||
it 'returns infinity when no limit configured' do
|
||||
allow(GlobalConfig).to receive(:get).and_return(nil)
|
||||
|
||||
limit = user.send(:session_limit)
|
||||
expect(limit).to eq(Float::INFINITY)
|
||||
end
|
||||
|
||||
it 'memoizes the result' do
|
||||
allow(GlobalConfig).to receive(:get).and_return('5')
|
||||
|
||||
# First call
|
||||
limit1 = user.send(:session_limit)
|
||||
# Second call should use memoized value
|
||||
limit2 = user.send(:session_limit)
|
||||
|
||||
expect(limit1).to eq(limit2)
|
||||
expect(GlobalConfig).to have_received(:get).once
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user