Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
526b7479ea | ||
|
|
8dcabee652 | ||
|
|
3b65450a0b | ||
|
|
0134cbeed0 | ||
|
|
46f95e849d | ||
|
|
cdd3b73fc9 | ||
|
|
610495123e | ||
|
|
cb65d615ea | ||
|
|
d8da1f5bf3 | ||
|
|
6829328182 | ||
|
|
6cc69f444b | ||
|
|
f89ed56258 | ||
|
|
7c5bb343c6 | ||
|
|
170ea7691f |
+1
-1
@@ -644,7 +644,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (3.2.3)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
|
||||
@@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def fetch_average_time(event_name)
|
||||
get_grouped_average(reporting_events.where(name: event_name))
|
||||
end
|
||||
|
||||
def fetch_resolved_count
|
||||
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
@@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class V2::Reports::Conversations::BaseAggregator < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def metrics
|
||||
return {} if range.blank?
|
||||
|
||||
compute_metrics
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_metrics
|
||||
raise NotImplementedError, 'Subclasses must implement #compute_metrics'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
class V2::Reports::Conversations::BetterMetricBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
COUNT_METRICS = %i[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
AVERAGE_METRICS = %i[
|
||||
avg_first_response_time
|
||||
avg_resolution_time
|
||||
reply_time
|
||||
].freeze
|
||||
|
||||
SUMMARY_METRICS = %i[
|
||||
conversations_count
|
||||
outgoing_messages_count
|
||||
avg_first_response_time
|
||||
avg_resolution_time
|
||||
resolutions_count
|
||||
reply_time
|
||||
].freeze
|
||||
|
||||
BOT_SUMMARY_METRICS = %i[
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
AGGREGATOR_CLASSES = [
|
||||
V2::Reports::Conversations::ConversationsAggregator,
|
||||
V2::Reports::Conversations::MessagesAggregator,
|
||||
V2::Reports::Conversations::ReportingEventsAggregator
|
||||
].freeze
|
||||
|
||||
def summary
|
||||
result = SUMMARY_METRICS.index_with { |metric| fetch_metric(metric) }
|
||||
result[:incoming_messages_count] = fetch_metric(:incoming_messages_count) unless params[:type] == :agent
|
||||
result
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
BOT_SUMMARY_METRICS.index_with { |metric| fetch_metric(metric) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_metric(key)
|
||||
aggregated_metrics.fetch(key, default_for(key))
|
||||
end
|
||||
|
||||
def aggregated_metrics
|
||||
@aggregated_metrics ||= AGGREGATOR_CLASSES.each_with_object({}) do |aggregator_class, memo|
|
||||
memo.merge!(aggregator_class.new(account, params).metrics) do |_metric, existing_value, new_value|
|
||||
new_value.nil? ? existing_value : new_value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def default_for(metric)
|
||||
return 0 if COUNT_METRICS.include?(metric)
|
||||
return nil if AVERAGE_METRICS.include?(metric)
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class V2::Reports::Conversations::ConversationsAggregator < V2::Reports::Conversations::BaseAggregator
|
||||
private
|
||||
|
||||
def compute_metrics
|
||||
{
|
||||
conversations_count: base_relation.count
|
||||
}
|
||||
end
|
||||
|
||||
def base_relation
|
||||
scope.conversations
|
||||
.where(account_id: account.id, created_at: range)
|
||||
.unscope(:order)
|
||||
.reorder(nil)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class V2::Reports::Conversations::MessagesAggregator < V2::Reports::Conversations::BaseAggregator
|
||||
private
|
||||
|
||||
def compute_metrics
|
||||
incoming_count, outgoing_count = base_relation.pluck(
|
||||
Arel.sql(count_sql(Message.message_types[:incoming])),
|
||||
Arel.sql(count_sql(Message.message_types[:outgoing]))
|
||||
).first || [0, 0]
|
||||
|
||||
{
|
||||
incoming_messages_count: incoming_count || 0,
|
||||
outgoing_messages_count: outgoing_count || 0
|
||||
}
|
||||
end
|
||||
|
||||
def base_relation
|
||||
scope.messages
|
||||
.where(account_id: account.id, created_at: range)
|
||||
.unscope(:order)
|
||||
.reorder(nil)
|
||||
end
|
||||
|
||||
def count_sql(message_type)
|
||||
"COUNT(*) FILTER (WHERE message_type = #{message_type})"
|
||||
end
|
||||
end
|
||||
@@ -1,14 +1,19 @@
|
||||
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
|
||||
def summary
|
||||
{
|
||||
result = {
|
||||
conversations_count: count('conversations_count'),
|
||||
incoming_messages_count: count('incoming_messages_count'),
|
||||
outgoing_messages_count: count('outgoing_messages_count'),
|
||||
avg_first_response_time: count('avg_first_response_time'),
|
||||
avg_resolution_time: count('avg_resolution_time'),
|
||||
incoming_messages_count: 0,
|
||||
resolutions_count: count('resolutions_count'),
|
||||
reply_time: count('reply_time')
|
||||
}
|
||||
|
||||
# Only include incoming_messages_count for non-agent summaries
|
||||
result[:incoming_messages_count] = count('incoming_messages_count') unless params[:type] == :agent
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
class V2::Reports::Conversations::ReportingEventsAggregator < V2::Reports::Conversations::BaseAggregator
|
||||
COUNT_MAPPINGS = {
|
||||
resolutions_count: :conversation_resolved,
|
||||
bot_resolutions_count: :conversation_bot_resolved,
|
||||
bot_handoffs_count: :conversation_bot_handoff
|
||||
}.freeze
|
||||
|
||||
AVERAGE_MAPPINGS = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}.freeze
|
||||
|
||||
private
|
||||
|
||||
def compute_metrics
|
||||
result = base_relation.select(*select_fragments).first
|
||||
return default_metrics unless result
|
||||
|
||||
COUNT_MAPPINGS.keys.index_with { |key| result.public_send(key) || 0 }
|
||||
.merge(AVERAGE_MAPPINGS.keys.index_with { |key| result.public_send(key) })
|
||||
end
|
||||
|
||||
def select_fragments
|
||||
COUNT_MAPPINGS.map { |metric, event_name| "#{count_fragment(event_name)} AS #{metric}" } +
|
||||
AVERAGE_MAPPINGS.map { |metric, event_name| "#{average_fragment(event_name)} AS #{metric}" }
|
||||
end
|
||||
|
||||
def count_fragment(event_name)
|
||||
if event_name == :conversation_bot_handoff
|
||||
Arel.sql("COUNT(DISTINCT CASE WHEN name = '#{event_name}' THEN conversation_id END)")
|
||||
else
|
||||
Arel.sql("COUNT(*) FILTER (WHERE name = '#{event_name}')")
|
||||
end
|
||||
end
|
||||
|
||||
def average_fragment(event_name)
|
||||
Arel.sql("AVG(CASE WHEN name = '#{event_name}' THEN #{value_column} END)")
|
||||
end
|
||||
|
||||
def base_relation
|
||||
scope.reporting_events
|
||||
.where(account_id: account.id, created_at: range)
|
||||
.unscope(:order)
|
||||
.reorder(nil)
|
||||
end
|
||||
|
||||
def value_column
|
||||
params[:business_hours].present? ? 'value_in_business_hours' : 'value'
|
||||
end
|
||||
|
||||
def default_metrics
|
||||
COUNT_MAPPINGS.keys.index_with { 0 }
|
||||
.merge(AVERAGE_MAPPINGS.keys.index_with { nil })
|
||||
end
|
||||
end
|
||||
@@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
|
||||
@@ -125,7 +125,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def build_summary(method)
|
||||
builder = V2::Reports::Conversations::MetricBuilder
|
||||
builder = V2::Reports::Conversations::BetterMetricBuilder
|
||||
current_summary = builder.new(Current.account, current_summary_params).send(method)
|
||||
previous_summary = builder.new(Current.account, previous_summary_params).send(method)
|
||||
current_summary.merge(previous: previous_summary)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { format } from 'date-fns';
|
||||
import sessionStorage from 'shared/helpers/sessionStorage';
|
||||
|
||||
import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
@@ -13,7 +15,8 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const { currentAccount } = useAccount();
|
||||
const router = useRouter();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
captainLimits,
|
||||
@@ -24,6 +27,12 @@ const {
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const store = useStore();
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
// State for handling refresh attempts and loading
|
||||
const isWaitingForBilling = ref(false);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
});
|
||||
@@ -61,11 +70,45 @@ const hasABillingPlan = computed(() => {
|
||||
|
||||
const fetchAccountDetails = async () => {
|
||||
if (!hasABillingPlan.value) {
|
||||
store.dispatch('accounts/subscription');
|
||||
await store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingPageLogic = async () => {
|
||||
// If self-hosted, redirect to dashboard
|
||||
if (!isOnChatwootCloud.value) {
|
||||
router.push({ name: 'home' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already attempted a refresh for billing setup
|
||||
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
|
||||
|
||||
// If cloud user, fetch account details first
|
||||
await fetchAccountDetails();
|
||||
|
||||
// If still no billing plan after fetch
|
||||
if (!hasABillingPlan.value) {
|
||||
// If we haven't attempted refresh yet, do it once
|
||||
if (!billingRefreshAttempted) {
|
||||
isWaitingForBilling.value = true;
|
||||
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} else {
|
||||
// We've already tried refreshing, so just show the no billing message
|
||||
// Clear the flag for future visits
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
} else {
|
||||
// Billing plan found, clear any existing refresh flag
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
@@ -76,14 +119,18 @@ const onToggleChatWindow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchAccountDetails);
|
||||
onMounted(handleBillingPageLogic);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetchingItem"
|
||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
:no-records-found="!hasABillingPlan"
|
||||
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
|
||||
:loading-message="
|
||||
isWaitingForBilling
|
||||
? $t('BILLING_SETTINGS.NO_BILLING_USER')
|
||||
: $t('ATTRIBUTES_MGMT.LOADING')
|
||||
"
|
||||
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
|
||||
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -96,11 +96,12 @@ export default {
|
||||
return parse(this.toTime, 'hh:mm a', new Date());
|
||||
},
|
||||
totalHours() {
|
||||
if (this.timeSlot.openAllDay) {
|
||||
return 24;
|
||||
}
|
||||
const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
|
||||
return totalHours;
|
||||
if (this.timeSlot.openAllDay) return '24h';
|
||||
|
||||
const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
|
||||
const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
|
||||
|
||||
return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
|
||||
},
|
||||
hasError() {
|
||||
return !this.timeSlot.valid;
|
||||
@@ -211,7 +212,7 @@ export default {
|
||||
v-if="isDayEnabled && !hasError"
|
||||
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
|
||||
>
|
||||
{{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
|
||||
{{ totalHours }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
|
||||
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
|
||||
will be generated by adding `step` minutes to the previous one.
|
||||
The list is generated by starting with a random day and adding step minutes till end of the same day.
|
||||
Always includes 11:59 PM as the final slot to complete the day.
|
||||
*/
|
||||
const date = new Date(1970, 1, 1);
|
||||
const slots = [];
|
||||
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
|
||||
);
|
||||
date.setMinutes(date.getMinutes() + step);
|
||||
}
|
||||
|
||||
// Always add 11:59 PM as the final slot if it's not already included
|
||||
const lastSlot = '11:59 PM';
|
||||
if (!slots.includes(lastSlot)) {
|
||||
slots.push(lastSlot);
|
||||
}
|
||||
|
||||
return slots;
|
||||
};
|
||||
|
||||
|
||||
+55
-3
@@ -7,10 +7,19 @@ import {
|
||||
} from '../businessHour';
|
||||
|
||||
describe('#generateTimeSlots', () => {
|
||||
it('returns correct number of time slots', () => {
|
||||
expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
|
||||
it('returns correct number of time slots for 15-minute intervals', () => {
|
||||
const slots = generateTimeSlots(15);
|
||||
// 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
|
||||
expect(slots.length).toStrictEqual(97);
|
||||
});
|
||||
it('returns correct time slots', () => {
|
||||
|
||||
it('returns correct number of time slots for 30-minute intervals', () => {
|
||||
const slots = generateTimeSlots(30);
|
||||
// 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
|
||||
expect(slots.length).toStrictEqual(49);
|
||||
});
|
||||
|
||||
it('returns correct time slots for 4-hour intervals', () => {
|
||||
expect(generateTimeSlots(240)).toStrictEqual([
|
||||
'12:00 AM',
|
||||
'04:00 AM',
|
||||
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
|
||||
'12:00 PM',
|
||||
'04:00 PM',
|
||||
'08:00 PM',
|
||||
'11:59 PM',
|
||||
]);
|
||||
});
|
||||
|
||||
it('always starts with 12:00 AM', () => {
|
||||
expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
|
||||
expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
|
||||
expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
|
||||
});
|
||||
|
||||
it('always ends with 11:59 PM', () => {
|
||||
const slots15 = generateTimeSlots(15);
|
||||
const slots30 = generateTimeSlots(30);
|
||||
const slots60 = generateTimeSlots(60);
|
||||
|
||||
expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
|
||||
expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
|
||||
expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
|
||||
});
|
||||
|
||||
it('includes 11:59 PM even when it would not be in regular intervals', () => {
|
||||
const slots = generateTimeSlots(30);
|
||||
expect(slots).toContain('11:59 PM');
|
||||
expect(slots).toContain('11:30 PM'); // Regular interval
|
||||
});
|
||||
|
||||
it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
|
||||
// Test with a step that would naturally include 11:59 PM
|
||||
const slots = generateTimeSlots(1); // 1-minute intervals
|
||||
const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
|
||||
expect(count11_59).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it('generates correct time format', () => {
|
||||
const slots = generateTimeSlots(60);
|
||||
expect(slots).toContain('01:00 AM');
|
||||
expect(slots).toContain('12:00 PM');
|
||||
expect(slots).toContain('01:00 PM');
|
||||
expect(slots).toContain('11:00 PM');
|
||||
});
|
||||
|
||||
it('handles edge case with very large step', () => {
|
||||
const slots = generateTimeSlots(1440); // 24 hours
|
||||
expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getTime', () => {
|
||||
|
||||
@@ -26,12 +26,12 @@ const fetchMetaData = async (commit, params) => {
|
||||
};
|
||||
|
||||
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000);
|
||||
const superLongDebouncedFetchMetaData = debounce(
|
||||
fetchMetaData,
|
||||
10000,
|
||||
1500,
|
||||
false,
|
||||
20000
|
||||
10000
|
||||
);
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -154,7 +154,10 @@ const equalTo = (filterValue, conversationValue) => {
|
||||
* It only works with string values and returns false for non-string types.
|
||||
*/
|
||||
const contains = (filterValue, conversationValue) => {
|
||||
if (typeof conversationValue === 'string') {
|
||||
if (
|
||||
typeof conversationValue === 'string' &&
|
||||
typeof filterValue === 'string'
|
||||
) {
|
||||
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
@@ -190,10 +193,8 @@ const compareDates = (conversationValue, filterValue, compareFn) => {
|
||||
const matchesCondition = (conversationValue, filter) => {
|
||||
const { filter_operator: filterOperator, values } = filter;
|
||||
|
||||
// Handle null/undefined values
|
||||
if (conversationValue === null || conversationValue === undefined) {
|
||||
return filterOperator === 'is_not_present';
|
||||
}
|
||||
const isNullish =
|
||||
conversationValue === null || conversationValue === undefined;
|
||||
|
||||
const filterValue = Array.isArray(values)
|
||||
? values.map(resolveValue)
|
||||
@@ -213,10 +214,10 @@ const matchesCondition = (conversationValue, filter) => {
|
||||
return !contains(filterValue, conversationValue);
|
||||
|
||||
case 'is_present':
|
||||
return true; // We already handled null/undefined above
|
||||
return !isNullish;
|
||||
|
||||
case 'is_not_present':
|
||||
return false; // We already handled null/undefined above
|
||||
return isNullish;
|
||||
|
||||
case 'is_greater_than':
|
||||
return compareDates(conversationValue, filterValue, (a, b) => a > b);
|
||||
@@ -225,6 +226,10 @@ const matchesCondition = (conversationValue, filter) => {
|
||||
return compareDates(conversationValue, filterValue, (a, b) => a < b);
|
||||
|
||||
case 'days_before': {
|
||||
if (isNullish) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
|
||||
const targetDate = new Date(today.getTime() - daysInMilliseconds);
|
||||
|
||||
+78
@@ -192,6 +192,32 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match conversation with equal_to operator when assignee is null', () => {
|
||||
const conversation = { meta: { assignee: null } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: 1, name: 'John Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match conversation with not_equal_to operator when assignee is null', () => {
|
||||
const conversation = { meta: { assignee: null } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: { id: 1, name: 'John Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match conversation with is_not_present operator for assignee_id', () => {
|
||||
const conversation = { meta: { assignee: null } };
|
||||
const filters = [
|
||||
@@ -285,6 +311,58 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match contains operator when display_id is null', () => {
|
||||
const conversation = { id: null };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'contains',
|
||||
values: '234',
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match contains operator when filter value is null', () => {
|
||||
const conversation = { id: '12345' };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'contains',
|
||||
values: null,
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match does_not_contain operator when display_id is null', () => {
|
||||
const conversation = { id: null };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'does_not_contain',
|
||||
values: '234',
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match does_not_contain operator when filter value is null', () => {
|
||||
const conversation = { id: '12345' };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'does_not_contain',
|
||||
values: null,
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match conversation with does_not_contain operator when value is not present', () => {
|
||||
const conversation = { id: '12345' };
|
||||
const filters = [
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# company_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -28,6 +29,7 @@
|
||||
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
|
||||
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
|
||||
# index_contacts_on_blocked (blocked)
|
||||
# index_contacts_on_company_id (company_id)
|
||||
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
|
||||
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
|
||||
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
|
||||
@@ -244,3 +246,4 @@ class Contact < ApplicationRecord
|
||||
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
|
||||
end
|
||||
end
|
||||
Contact.include_mod_with('Concerns::Contact')
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
# message_signature :text
|
||||
# name :string not null
|
||||
# otp_backup_codes :text
|
||||
# otp_required_for_login :boolean default(FALSE), not null
|
||||
# otp_required_for_login :boolean default(FALSE)
|
||||
# otp_secret :string
|
||||
# provider :string default("email"), not null
|
||||
# pubsub_token :string
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
# message_signature :text
|
||||
# name :string not null
|
||||
# otp_backup_codes :text
|
||||
# otp_required_for_login :boolean default(FALSE), not null
|
||||
# otp_required_for_login :boolean default(FALSE)
|
||||
# otp_secret :string
|
||||
# provider :string default("email"), not null
|
||||
# pubsub_token :string
|
||||
|
||||
@@ -47,6 +47,15 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
||||
end
|
||||
|
||||
def argentina_phone_number?(phone_number)
|
||||
phone_number.match(/^54/)
|
||||
end
|
||||
|
||||
def normalised_argentina_mobil_number(phone_number)
|
||||
# Remove 9 before country code
|
||||
phone_number.sub(/^549/, '54')
|
||||
end
|
||||
|
||||
def processed_waid(waid)
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Handles Argentina phone number normalization
|
||||
#
|
||||
# Argentina phone numbers can appear with or without "9" after country code
|
||||
# This normalizer removes the "9" when present to create consistent format: 54 + area + number
|
||||
class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
|
||||
def normalize(waid)
|
||||
return waid unless handles_country?(waid)
|
||||
|
||||
# Remove "9" after country code if present (549 → 54)
|
||||
waid.sub(/^549/, '54')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def country_code_pattern
|
||||
/^54/
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
# Service to handle phone number normalization for WhatsApp messages
|
||||
# Currently supports Brazil phone number format variations
|
||||
# Currently supports Brazil and Argentina phone number format variations
|
||||
# Designed to be extensible for additional countries in future PRs
|
||||
#
|
||||
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
||||
@@ -34,6 +34,7 @@ class Whatsapp::PhoneNumberNormalizationService
|
||||
end
|
||||
|
||||
NORMALIZERS = [
|
||||
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer
|
||||
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
|
||||
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
|
||||
].freeze
|
||||
end
|
||||
|
||||
@@ -76,6 +76,9 @@ en:
|
||||
invalid: Invalid email
|
||||
phone_number:
|
||||
invalid: should be in e164 format
|
||||
companies:
|
||||
domain:
|
||||
invalid: must be a valid domain name
|
||||
categories:
|
||||
locale:
|
||||
unique: should be unique in the category and portal
|
||||
|
||||
@@ -153,6 +153,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :companies, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
|
||||
collection do
|
||||
get :active
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class CreateCompanies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :companies do |t|
|
||||
t.string :name, null: false
|
||||
t.string :domain
|
||||
t.text :description
|
||||
t.references :account, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :companies, [:name, :account_id]
|
||||
add_index :companies, [:domain, :account_id]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddCompanyToContacts < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :contacts, :company, null: true
|
||||
end
|
||||
end
|
||||
@@ -570,6 +570,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
|
||||
end
|
||||
|
||||
create_table "companies", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "domain"
|
||||
t.text "description"
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_companies_on_account_id"
|
||||
t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
|
||||
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
|
||||
end
|
||||
|
||||
create_table "contact_inboxes", force: :cascade do |t|
|
||||
t.bigint "contact_id"
|
||||
t.bigint "inbox_id"
|
||||
@@ -602,6 +614,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
t.string "location", default: ""
|
||||
t.string "country_code", default: ""
|
||||
t.boolean "blocked", default: false, null: false
|
||||
t.bigint "company_id"
|
||||
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
|
||||
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
@@ -609,6 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
t.index ["account_id"], name: "index_contacts_on_account_id"
|
||||
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["blocked"], name: "index_contacts_on_blocked"
|
||||
t.index ["company_id"], name: "index_contacts_on_company_id"
|
||||
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
|
||||
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
|
||||
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_company, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@companies = Current.account.companies.ordered_by_name
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@company = Current.account.companies.build(company_params)
|
||||
@company.save!
|
||||
end
|
||||
|
||||
def update
|
||||
@company.update!(company_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@company.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
|
||||
|
||||
authorize(Company)
|
||||
end
|
||||
|
||||
def fetch_company
|
||||
@company = Current.account.companies.find(params[:id])
|
||||
end
|
||||
|
||||
def company_params
|
||||
params.require(:company).permit(:name, :domain, :description, :avatar)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: companies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# domain :string
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_companies_on_account_id (account_id)
|
||||
# index_companies_on_domain_and_account_id (domain,account_id)
|
||||
# index_companies_on_name_and_account_id (name,account_id)
|
||||
#
|
||||
class Company < ApplicationRecord
|
||||
include Avatarable
|
||||
validates :account_id, presence: true
|
||||
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
|
||||
validates :domain, allow_blank: true, format: {
|
||||
with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
|
||||
message: I18n.t('errors.companies.domain.invalid')
|
||||
}
|
||||
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
|
||||
|
||||
belongs_to :account
|
||||
has_many :contacts, dependent: :nullify
|
||||
|
||||
scope :ordered_by_name, -> { order(:name) }
|
||||
end
|
||||
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
|
||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module Enterprise::Concerns::Contact
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
belongs_to :company, optional: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class CompanyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
true
|
||||
end
|
||||
|
||||
def create?
|
||||
true
|
||||
end
|
||||
|
||||
def update?
|
||||
true
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
json.id company.id
|
||||
json.name company.name
|
||||
json.domain company.domain
|
||||
json.description company.description
|
||||
json.avatar_url company.avatar_url
|
||||
json.created_at company.created_at
|
||||
json.updated_at company.updated_at
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
json.payload do
|
||||
json.array! @companies do |company|
|
||||
json.partial! 'company', company: company
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'company', company: @company
|
||||
end
|
||||
@@ -70,7 +70,9 @@ module Integrations::Slack::SlackMessageHelper
|
||||
case attachment[:filetype]
|
||||
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
|
||||
:image
|
||||
when 'pdf'
|
||||
when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
|
||||
:video
|
||||
else
|
||||
:file
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@ module Limits
|
||||
GREETING_MESSAGE_MAX_LENGTH = 10_000
|
||||
CATEGORIES_PER_PAGE = 1000
|
||||
AUTO_ASSIGNMENT_BULK_LIMIT = 100
|
||||
COMPANY_NAME_LENGTH_LIMIT = 100
|
||||
COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
|
||||
MAX_CUSTOM_FILTERS_PER_USER = 1000
|
||||
|
||||
def self.conversation_message_per_minute_limit
|
||||
|
||||
@@ -14,25 +14,55 @@ RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
|
||||
end
|
||||
|
||||
describe '#summary' do
|
||||
it 'returns the correct summary values' do
|
||||
summary = subject.summary
|
||||
expect(summary).to eq(
|
||||
{
|
||||
conversations_count: 42,
|
||||
incoming_messages_count: 42,
|
||||
outgoing_messages_count: 42,
|
||||
avg_first_response_time: 42,
|
||||
avg_resolution_time: 42,
|
||||
resolutions_count: 42,
|
||||
reply_time: 42
|
||||
}
|
||||
)
|
||||
context 'when type is not agent' do
|
||||
let(:params) { { since: '2023-01-01', until: '2024-01-01', type: :inbox } }
|
||||
|
||||
it 'returns the correct summary values including incoming_messages_count' do
|
||||
summary = subject.summary
|
||||
expect(summary).to eq(
|
||||
{
|
||||
conversations_count: 42,
|
||||
outgoing_messages_count: 42,
|
||||
avg_first_response_time: 42,
|
||||
avg_resolution_time: 42,
|
||||
resolutions_count: 42,
|
||||
reply_time: 42,
|
||||
incoming_messages_count: 42
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates builders with proper params' do
|
||||
subject.summary
|
||||
expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
|
||||
expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'incoming_messages_count'))
|
||||
expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates builders with proper params' do
|
||||
subject.summary
|
||||
expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
|
||||
expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
|
||||
context 'when type is agent' do
|
||||
let(:params) { { since: '2023-01-01', until: '2024-01-01', type: :agent } }
|
||||
|
||||
it 'returns the correct summary values without incoming_messages_count' do
|
||||
summary = subject.summary
|
||||
expect(summary).to eq(
|
||||
{
|
||||
conversations_count: 42,
|
||||
outgoing_messages_count: 42,
|
||||
avg_first_response_time: 42,
|
||||
avg_resolution_time: 42,
|
||||
resolutions_count: 42,
|
||||
reply_time: 42
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not create builder for incoming_messages_count' do
|
||||
subject.summary
|
||||
expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
|
||||
expect(V2::Reports::Timeseries::CountReportBuilder).not_to have_received(:new).with(account, params.merge(metric: 'incoming_messages_count'))
|
||||
expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Companies API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/companies"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:company1) { create(:company, name: 'Company 1', account: account) }
|
||||
let!(:company2) { create(:company, account: account) }
|
||||
|
||||
it 'returns all companies' do
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(2)
|
||||
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
it 'returns the company' do
|
||||
get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload']['name']).to eq(company.name)
|
||||
expect(response_body['payload']['id']).to eq(company.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies' do
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:valid_params) do
|
||||
{
|
||||
company: {
|
||||
name: 'New Company',
|
||||
domain: 'newcompany.com',
|
||||
description: 'A new company'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new company' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/companies",
|
||||
params: valid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Company, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload']['name']).to eq('New Company')
|
||||
expect(response_body['payload']['domain']).to eq('newcompany.com')
|
||||
end
|
||||
|
||||
it 'returns error for invalid params' do
|
||||
invalid_params = { company: { name: '' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/companies",
|
||||
params: invalid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
let(:update_params) do
|
||||
{
|
||||
company: {
|
||||
name: 'Updated Company Name',
|
||||
domain: 'updated.com'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the company' do
|
||||
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
params: update_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload']['name']).to eq('Updated Company Name')
|
||||
expect(response_body['payload']['domain']).to eq('updated.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
it 'deletes the company' do
|
||||
company
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Company, :count).by(-1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a regular agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Company, type: :model do
|
||||
context 'with validations' do
|
||||
it { is_expected.to validate_presence_of(:account_id) }
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(100) }
|
||||
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
|
||||
|
||||
describe 'domain validation' do
|
||||
it { is_expected.to allow_value('example.com').for(:domain) }
|
||||
it { is_expected.to allow_value('sub.example.com').for(:domain) }
|
||||
it { is_expected.to allow_value('').for(:domain) }
|
||||
it { is_expected.to allow_value(nil).for(:domain) }
|
||||
it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
|
||||
it { is_expected.not_to allow_value('.example.com').for(:domain) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to have_many(:contacts).dependent(:nullify) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:company_b) { create(:company, name: 'B Company', account: account) }
|
||||
let!(:company_a) { create(:company, name: 'A Company', account: account) }
|
||||
let!(:company_c) { create(:company, name: 'C Company', account: account) }
|
||||
|
||||
describe '.ordered_by_name' do
|
||||
it 'orders companies by name alphabetically' do
|
||||
companies = described_class.where(account: account).ordered_by_name
|
||||
expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CompanyPolicy, type: :policy do
|
||||
subject(:company_policy) { described_class }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:administrator) { create(:user, :administrator, account: account) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
||||
|
||||
permissions :index?, :show?, :create?, :update? do
|
||||
context 'when administrator' do
|
||||
it { expect(company_policy).to permit(administrator_context, company) }
|
||||
end
|
||||
|
||||
context 'when agent' do
|
||||
it { expect(company_policy).to permit(agent_context, company) }
|
||||
end
|
||||
end
|
||||
|
||||
permissions :destroy? do
|
||||
context 'when administrator' do
|
||||
it { expect(company_policy).to permit(administrator_context, company) }
|
||||
end
|
||||
|
||||
context 'when agent' do
|
||||
it { expect(company_policy).not_to permit(agent_context, company) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
FactoryBot.define do
|
||||
factory :company do
|
||||
sequence(:name) { |n| "Company #{n}" }
|
||||
sequence(:domain) { |n| "company#{n}.com" }
|
||||
description { 'A sample company description' }
|
||||
account
|
||||
|
||||
trait :without_domain do
|
||||
domain { nil }
|
||||
end
|
||||
|
||||
trait :with_avatar do
|
||||
avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
|
||||
end
|
||||
|
||||
trait :with_long_description do
|
||||
description { 'A' * 500 }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -157,6 +157,19 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'handles different file types correctly' do
|
||||
expect(hook).not_to be_nil
|
||||
video_attachment_params = message_with_attachments.deep_dup
|
||||
video_attachment_params[:event][:files][0][:filetype] = 'mp4'
|
||||
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
|
||||
|
||||
builder = described_class.new(video_attachment_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
|
||||
expect { builder.perform }.not_to raise_error
|
||||
expect(conversation.messages.last.attachments).to be_any
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link shared' do
|
||||
|
||||
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When the incoming waid is an Argentine number with 9 after country code' do
|
||||
let(:wa_id) { '5491123456789' }
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
||||
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists with normalized format' do
|
||||
# Normalized format removes the 9 after country code
|
||||
normalized_wa_id = '541123456789'
|
||||
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
# no new conversation should be created
|
||||
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||
# message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
|
||||
# should use the normalized wa_id from existing contact
|
||||
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming waid is an Argentine number without 9 after country code' do
|
||||
let(:wa_id) { '541123456789' }
|
||||
|
||||
context 'when a contact inbox exists with the same format' do
|
||||
it 'appends to existing contact' do
|
||||
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
# no new conversation should be created
|
||||
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||
# message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact inbox does not exist' do
|
||||
it 'creates contact inbox with the incoming waid' do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
||||
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when message processing is in progress' do
|
||||
it 'ignores the current message creation request' do
|
||||
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
|
||||
|
||||
Reference in New Issue
Block a user