Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470a73632d | ||
|
|
887897ea98 | ||
|
|
9fc2f25d43 | ||
|
|
8aee518149 | ||
|
|
5733b822e3 | ||
|
|
1e52d23d7a | ||
|
|
fbb3479263 | ||
|
|
166a41c31c | ||
|
|
e65e18e9c5 | ||
|
|
565b747658 | ||
|
|
3a1beab3de | ||
|
|
64260b1f7f | ||
|
|
92d5d5ecff |
@@ -2,6 +2,14 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -14,15 +22,23 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
bulk_create_agents(emails)
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
clear_onboarding_step
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
||||
phone_number_id = metadata[:phone_number_id]
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
|
||||
def normalized_phone_number(phone_number)
|
||||
return if phone_number.blank?
|
||||
|
||||
phone_number = phone_number.to_s
|
||||
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def inactive_whatsapp_number?
|
||||
|
||||
+36
-7
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
evaluateSLAStatus,
|
||||
shouldRefreshSLAStatus,
|
||||
} from 'dashboard/helper/slaHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -12,6 +16,7 @@ const props = defineProps({
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const { t } = useI18n();
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
@@ -24,12 +29,15 @@ const slaEvents = computed(() => props.conversation?.slaEvents);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return slaStatus.value?.threshold && appliedSLA.value?.id;
|
||||
return slaStatus.value?.type && appliedSLA.value?.id;
|
||||
});
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
return slaStatus.value?.type?.toUpperCase();
|
||||
});
|
||||
const slaValueText = computed(
|
||||
() => slaStatus.value?.threshold || t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
|
||||
);
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
@@ -39,7 +47,24 @@ const updateSlaStatus = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
clearTimer();
|
||||
if (
|
||||
!shouldRefreshSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.conversation,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
@@ -52,12 +77,16 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
clearTimer();
|
||||
});
|
||||
|
||||
watch(() => props.conversation, updateSlaStatus);
|
||||
watch(
|
||||
() => props.conversation,
|
||||
() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}
|
||||
);
|
||||
|
||||
// This expose is to provide context to the parent component, so that it can decided weather
|
||||
// a new row has to be added to the conversation card or not
|
||||
@@ -96,7 +125,7 @@ defineExpose({
|
||||
class="text-sm truncate"
|
||||
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
|
||||
{{ `${slaStatusText}: ${slaValueText}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
evaluateSLAStatus,
|
||||
shouldRefreshSLAStatus,
|
||||
} from 'dashboard/helper/slaHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
@@ -15,6 +19,7 @@ const props = defineProps({
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const { t } = useI18n();
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
@@ -28,8 +33,18 @@ defineOptions({
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.type);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
const slaLabel = computed(() => {
|
||||
if (slaStatus.value?.threshold) return slaStatus.value.threshold;
|
||||
|
||||
const status = t('CONVERSATION.HEADER.SLA_STATUS.MISSED');
|
||||
return {
|
||||
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
|
||||
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
|
||||
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
|
||||
}[slaStatus.value.type];
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
@@ -39,7 +54,24 @@ const updateSlaStatus = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
clearTimer();
|
||||
if (
|
||||
!shouldRefreshSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
@@ -52,12 +84,16 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
clearTimer();
|
||||
});
|
||||
|
||||
watch(() => props.chat, updateSlaStatus);
|
||||
watch(
|
||||
() => props.chat,
|
||||
() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
@@ -70,11 +106,7 @@ defineExpose({
|
||||
v-bind="$attrs"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
>
|
||||
<Label
|
||||
:label="slaStatus.threshold"
|
||||
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||
compact
|
||||
>
|
||||
<Label :label="slaLabel" :color="isSlaMissed ? 'ruby' : 'amber'" compact>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||
</template>
|
||||
|
||||
+43
-11
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
import {
|
||||
evaluateSLAStatus,
|
||||
shouldRefreshSLAStatus,
|
||||
} from 'dashboard/helper/slaHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -32,7 +35,7 @@ const slaStatus = ref({
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.type);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
const slaTextStyles = computed(() =>
|
||||
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
|
||||
@@ -40,12 +43,24 @@ const slaTextStyles = computed(() =>
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
|
||||
const status = isSlaMissed.value
|
||||
? t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
|
||||
: t('CONVERSATION.HEADER.SLA_STATUS.DUE');
|
||||
|
||||
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||
});
|
||||
return {
|
||||
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
|
||||
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
|
||||
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
|
||||
}[upperCaseType];
|
||||
});
|
||||
const showFullStatusText = computed(
|
||||
() => props.showExtendedInfo && props.parentWidth > 650
|
||||
);
|
||||
const slaValueText = computed(
|
||||
() =>
|
||||
slaStatus.value?.threshold ||
|
||||
(showFullStatusText.value ? '' : slaStatusText.value)
|
||||
);
|
||||
|
||||
const showSlaPopoverCard = computed(
|
||||
() => props.showExtendedInfo && slaEvents.value?.length > 0
|
||||
@@ -65,7 +80,24 @@ const updateSlaStatus = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
clearTimer();
|
||||
if (
|
||||
!shouldRefreshSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
@@ -76,6 +108,7 @@ watch(
|
||||
() => props.chat,
|
||||
() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -91,9 +124,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
clearTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -118,7 +149,7 @@ onUnmounted(() => {
|
||||
:class="slaTextStyles"
|
||||
/>
|
||||
<span
|
||||
v-if="showExtendedInfo && parentWidth > 650"
|
||||
v-if="showFullStatusText"
|
||||
class="text-xs font-medium"
|
||||
:class="slaTextStyles"
|
||||
>
|
||||
@@ -126,10 +157,11 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="slaValueText"
|
||||
class="text-xs font-medium"
|
||||
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
|
||||
>
|
||||
{{ slaStatus.threshold }}
|
||||
{{ slaValueText }}
|
||||
</span>
|
||||
</div>
|
||||
<SLAPopoverCard
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('keeps nameless agent bots and applies a fallback label', () => {
|
||||
const namelessBot = {
|
||||
id: 91,
|
||||
name: null,
|
||||
assignee_type: 'AgentBot',
|
||||
availability_status: 'offline',
|
||||
};
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => [
|
||||
...allAgentsData,
|
||||
namelessBot,
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList();
|
||||
// access the computed to trigger evaluation
|
||||
expect(agentsList.value).toBeDefined();
|
||||
|
||||
const passedAgents =
|
||||
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
|
||||
expect(passedAgents).toContainEqual({
|
||||
...namelessBot,
|
||||
name: '-',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
@@ -53,7 +53,11 @@ export function useAgentsList(
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agents = (assignableAgents.value || []).map(agent =>
|
||||
!agent.name && agent.assignee_type === 'AgentBot'
|
||||
? { ...agent, name: '-' }
|
||||
: agent
|
||||
);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export const getAgentsByAvailability = (agents, availability) => {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,22 @@ const toUnixTimestamp = value => {
|
||||
: Math.floor(parsedTimestamp / 1000);
|
||||
};
|
||||
|
||||
const isSLACompleted = (sla, conversation) => {
|
||||
const terminalStatuses = ['hit', 'missed'];
|
||||
|
||||
return Boolean(
|
||||
sla.slaCompletedAt ||
|
||||
terminalStatuses.includes(sla.slaStatus) ||
|
||||
conversation.status === 'resolved'
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldRefreshSLAStatus = ({ appliedSla, chat }) => {
|
||||
if (!appliedSla || !chat) return false;
|
||||
|
||||
return !isSLACompleted(useCamelCase(appliedSla), useCamelCase(chat));
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates SLA status using backend-computed due times
|
||||
* @param {Object} params - Parameters object
|
||||
@@ -66,6 +82,9 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
const conversation = useCamelCase(chat);
|
||||
const events = useCamelCase(slaEvents || []);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const completionTime = toUnixTimestamp(sla.slaCompletedAt);
|
||||
const isCompleted = isSLACompleted(sla, conversation);
|
||||
const evaluationTime = completionTime || (isCompleted ? null : currentTime);
|
||||
const slaStatuses = [];
|
||||
|
||||
const dueAtByType = {
|
||||
@@ -84,47 +103,51 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
|
||||
slaStatuses.push({
|
||||
type,
|
||||
threshold: missedAt - currentTime,
|
||||
threshold: evaluationTime ? missedAt - evaluationTime : null,
|
||||
icon: 'flame',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
|
||||
const shouldCheckFirstResponse =
|
||||
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
|
||||
if (!isCompleted) {
|
||||
const firstReplyCreatedAt = toUnixTimestamp(
|
||||
conversation.firstReplyCreatedAt
|
||||
);
|
||||
const shouldCheckFirstResponse =
|
||||
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
|
||||
|
||||
// Check FRT - until first reply is made on time
|
||||
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
|
||||
const threshold = sla.slaFrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'FRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
// Check FRT - until first reply is made on time
|
||||
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
|
||||
const threshold = sla.slaFrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'FRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check NRT - only if first reply made and waiting for response
|
||||
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
|
||||
const threshold = sla.slaNrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'NRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
// Check NRT - only if first reply made and waiting for response
|
||||
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
|
||||
const threshold = sla.slaNrtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'NRT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Check RT - only if conversation is unresolved
|
||||
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
|
||||
const threshold = sla.slaRtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'RT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
// Check RT - only if conversation is unresolved
|
||||
if (sla.slaRtDueAt) {
|
||||
const threshold = sla.slaRtDueAt - currentTime;
|
||||
slaStatuses.push({
|
||||
type: 'RT',
|
||||
threshold,
|
||||
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||
isSlaMissed: threshold <= 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (slaStatuses.length === 0) {
|
||||
@@ -137,13 +160,19 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||
return a.isSlaMissed ? -1 : 1;
|
||||
}
|
||||
|
||||
if (a.threshold === null || b.threshold === null) {
|
||||
if (a.threshold === b.threshold) return 0;
|
||||
return a.threshold === null ? -1 : 1;
|
||||
}
|
||||
|
||||
return Math.abs(a.threshold) - Math.abs(b.threshold);
|
||||
});
|
||||
const mostUrgent = slaStatuses[0];
|
||||
|
||||
return {
|
||||
type: mostUrgent.type,
|
||||
threshold: formatSLATime(mostUrgent.threshold),
|
||||
threshold:
|
||||
mostUrgent.threshold === null ? '' : formatSLATime(mostUrgent.threshold),
|
||||
icon: mostUrgent.icon,
|
||||
isSlaMissed: mostUrgent.isSlaMissed,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
|
||||
offlineAgentsData
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when an agent has a null name', () => {
|
||||
const agents = [
|
||||
{ id: 1, name: null, availability_status: 'offline' },
|
||||
{ id: 2, name: 'Zoe', availability_status: 'offline' },
|
||||
];
|
||||
|
||||
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
|
||||
expect(
|
||||
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedAgentsByAvailability', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateSLAStatus } from '../slaHelper';
|
||||
import { evaluateSLAStatus, shouldRefreshSLAStatus } from '../slaHelper';
|
||||
|
||||
describe('#SLA Helpers', () => {
|
||||
const currentTimestamp = 1700000000; // Fixed timestamp for testing
|
||||
@@ -378,6 +378,109 @@ describe('#SLA Helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed SLA misses', () => {
|
||||
it('freezes a recorded FRT miss at the SLA completion time', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_completed_at: currentTimestamp - 3600,
|
||||
sla_frt_due_at: currentTimestamp - 7200,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'frt', created_at: currentTimestamp - 7000 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'FRT',
|
||||
threshold: '1h',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes a recorded NRT miss at the SLA completion time', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_completed_at: currentTimestamp - 3600,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'nrt', created_at: currentTimestamp - 5400 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'NRT',
|
||||
threshold: '30m',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes a recorded RT miss at the SLA completion time', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_completed_at: currentTimestamp - 3600,
|
||||
sla_rt_due_at: currentTimestamp - 7200,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'RT',
|
||||
threshold: '1h',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a static miss for a legacy completed SLA without a timestamp', () => {
|
||||
const appliedSla = {
|
||||
sla_status: 'missed',
|
||||
sla_rt_due_at: currentTimestamp - 7200,
|
||||
};
|
||||
const chat = { status: 'resolved' };
|
||||
const slaEvents = [
|
||||
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
|
||||
];
|
||||
|
||||
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'RT',
|
||||
threshold: '',
|
||||
isSlaMissed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh scheduling', () => {
|
||||
it('refreshes only active unresolved SLAs', () => {
|
||||
expect(
|
||||
shouldRefreshSLAStatus({
|
||||
appliedSla: { sla_status: 'active' },
|
||||
chat: { status: 'open' },
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRefreshSLAStatus({
|
||||
appliedSla: { sla_status: 'active' },
|
||||
chat: { status: 'resolved' },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRefreshSLAStatus({
|
||||
appliedSla: { sla_status: 'missed' },
|
||||
chat: { status: 'open' },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time formatting', () => {
|
||||
it('formats time in days and hours', () => {
|
||||
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(() => {
|
||||
<BaseTableCell class="max-w-0">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:name="bot.name || ''"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
class="flex-shrink-0"
|
||||
|
||||
@@ -68,6 +68,10 @@ const isAgentBot = computed(
|
||||
() => props.selectedItem?.assignee_type === 'AgentBot'
|
||||
);
|
||||
|
||||
const selectedItemName = computed(() =>
|
||||
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
|
||||
);
|
||||
|
||||
const selectedThumbnail = computed(
|
||||
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
||||
);
|
||||
@@ -95,16 +99,16 @@ const selectedThumbnail = computed(
|
||||
<h4
|
||||
v-else
|
||||
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
|
||||
:title="selectedItem.name"
|
||||
:title="selectedItemName"
|
||||
>
|
||||
{{ selectedItem.name }}
|
||||
{{ selectedItemName }}
|
||||
</h4>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
||||
:src="selectedThumbnail"
|
||||
:status="selectedItem.availability_status"
|
||||
:name="selectedItem.name"
|
||||
:name="selectedItemName"
|
||||
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
||||
:size="24"
|
||||
hide-offline-status
|
||||
|
||||
@@ -53,7 +53,9 @@ export default {
|
||||
computed: {
|
||||
filteredOptions() {
|
||||
return this.options.filter(option => {
|
||||
return option.name.toLowerCase().includes(this.search.toLowerCase());
|
||||
return (option.name || '')
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase());
|
||||
});
|
||||
},
|
||||
noResult() {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -24,6 +26,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
isSendingTranscript: false,
|
||||
transcriptCooldown: false,
|
||||
transcriptCooldownTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -57,6 +62,9 @@ export default {
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
@@ -90,19 +98,35 @@ export default {
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
startTranscriptCooldown() {
|
||||
this.transcriptCooldown = true;
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
this.transcriptCooldownTimer = setTimeout(() => {
|
||||
this.transcriptCooldown = false;
|
||||
}, TRANSCRIPT_COOLDOWN_MS);
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!this.hasEmail ||
|
||||
this.isSendingTranscript ||
|
||||
this.transcriptCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSendingTranscript = true;
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
this.startTranscriptCooldown();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
} finally {
|
||||
this.isSendingTranscript = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -144,6 +168,7 @@ export default {
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
:disabled="isSendingTranscript || transcriptCooldown"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
|
||||
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
end
|
||||
|
||||
def get_channel_from_wb_payload(wb_params)
|
||||
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
# validate to ensure the phone number id matches the whatsapp channel
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
should_send_template_message = template_params.present? || !message.conversation.can_reply?
|
||||
if should_send_template_message
|
||||
send_template_message
|
||||
else
|
||||
send_session_message
|
||||
end
|
||||
return send_template_message if template_params.present?
|
||||
return send_session_message if message.conversation.can_reply?
|
||||
|
||||
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
|
||||
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
|
||||
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
|
||||
# raw digits first and then a normalized fallback, accepting only a candidate whose
|
||||
# phone_number_id matches.
|
||||
class Whatsapp::WebhookChannelFinderService
|
||||
def initialize(display_phone_number:, phone_number_id:)
|
||||
@display_phone_number = display_phone_number
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
return if digits.blank?
|
||||
|
||||
candidates = [
|
||||
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
|
||||
channel_by_normalized_number
|
||||
]
|
||||
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def digits
|
||||
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
|
||||
end
|
||||
|
||||
def channel_by_normalized_number
|
||||
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
|
||||
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
|
||||
return unless normalizer
|
||||
|
||||
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
|
||||
end
|
||||
end
|
||||
@@ -154,6 +154,7 @@ en:
|
||||
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
||||
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
||||
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
|
||||
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddCompletedAtToAppliedSlas < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :applied_slas, :completed_at, :datetime
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_15_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -178,6 +178,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "sla_status", default: 0
|
||||
t.datetime "completed_at"
|
||||
t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true
|
||||
t.index ["account_id"], name: "index_applied_slas_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module Enterprise::Api::V1::Accounts::AgentsController
|
||||
def create
|
||||
super
|
||||
return if @agent.blank?
|
||||
|
||||
associate_agent_with_custom_role
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# sla_status :integer default("active")
|
||||
# completed_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
@@ -53,6 +54,7 @@ class AppliedSla < ApplicationRecord
|
||||
sla_status: sla_status,
|
||||
created_at: created_at.to_i,
|
||||
updated_at: updated_at.to_i,
|
||||
sla_completed_at: completed_at&.to_i,
|
||||
sla_description: sla_policy.description,
|
||||
sla_name: sla_policy.name,
|
||||
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
|
||||
|
||||
@@ -33,6 +33,18 @@ module Enterprise::Conversation
|
||||
|
||||
private
|
||||
|
||||
def handle_resolved_status_change
|
||||
super
|
||||
update_applied_sla_completion
|
||||
end
|
||||
|
||||
def update_applied_sla_completion
|
||||
return unless saved_change_to_status?
|
||||
return if applied_sla.blank? || applied_sla.hit? || applied_sla.missed?
|
||||
|
||||
applied_sla.update!(completed_at: resolved? ? Time.current : nil)
|
||||
end
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
class Sla::BackfillAppliedSlaCompletedAtService
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
|
||||
def initialize(**options)
|
||||
options.assert_valid_keys(:account_id, :all_accounts, :apply, :batch_size, :after_id, :output)
|
||||
|
||||
@account_id = options[:account_id]
|
||||
@all_accounts = options.fetch(:all_accounts, false)
|
||||
@apply = options.fetch(:apply, false)
|
||||
@batch_size = options.fetch(:batch_size, DEFAULT_BATCH_SIZE)
|
||||
@after_id = options.fetch(:after_id, 0)
|
||||
@output = options.fetch(:output, $stdout)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_options!
|
||||
|
||||
scope = candidate_scope
|
||||
eligible_count = scope.count
|
||||
counters = { processed: 0, matched: 0, updated: 0, skipped: 0, last_id: @after_id }
|
||||
|
||||
print_preflight(eligible_count)
|
||||
|
||||
scope.find_in_batches(batch_size: @batch_size, start: @after_id + 1) { |batch| process_batch(batch, counters) }
|
||||
|
||||
result = counters.merge(eligible: eligible_count, dry_run: !@apply)
|
||||
@output.puts "Completed: #{result.inspect}"
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_batch(batch, counters)
|
||||
resolution_times = resolution_times_for(batch)
|
||||
updated_count = @apply ? bulk_update(resolution_times) : 0
|
||||
|
||||
counters[:processed] += batch.size
|
||||
counters[:matched] += resolution_times.size
|
||||
counters[:updated] += updated_count
|
||||
counters[:skipped] += batch.size - resolution_times.size
|
||||
counters[:last_id] = batch.last.id
|
||||
|
||||
@output.puts "Processed through applied_sla_id=#{counters[:last_id]} " \
|
||||
"(matched=#{counters[:matched]}, updated=#{counters[:updated]}, skipped=#{counters[:skipped]})"
|
||||
end
|
||||
|
||||
def validate_options!
|
||||
account_scope = @account_id.present?
|
||||
raise ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true' if account_scope == @all_accounts
|
||||
raise ArgumentError, 'BATCH_SIZE must be greater than zero' unless @batch_size.positive?
|
||||
raise ArgumentError, 'AFTER_ID must be zero or greater' if @after_id.negative?
|
||||
|
||||
Account.find(@account_id) if account_scope
|
||||
end
|
||||
|
||||
def candidate_scope
|
||||
scope = AppliedSla.where(sla_status: :missed, completed_at: nil).where('applied_slas.id > ?', @after_id)
|
||||
scope = scope.where(account_id: @account_id) if @account_id.present?
|
||||
scope
|
||||
end
|
||||
|
||||
def resolution_times_for(batch)
|
||||
events_by_conversation = ReportingEvent
|
||||
.where(
|
||||
account_id: batch.map(&:account_id).uniq,
|
||||
conversation_id: batch.map(&:conversation_id),
|
||||
name: 'conversation_resolved'
|
||||
)
|
||||
.where.not(event_end_time: nil)
|
||||
.order(:conversation_id, event_end_time: :desc)
|
||||
.group_by(&:conversation_id)
|
||||
|
||||
batch.each_with_object({}) do |applied_sla, resolution_times|
|
||||
event = events_by_conversation.fetch(applied_sla.conversation_id, []).find do |reporting_event|
|
||||
reporting_event.event_end_time.between?(applied_sla.created_at, applied_sla.updated_at)
|
||||
end
|
||||
resolution_times[applied_sla.id] = event.event_end_time if event
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_update(resolution_times)
|
||||
return 0 if resolution_times.empty?
|
||||
|
||||
connection = AppliedSla.connection
|
||||
values = resolution_times.map do |id, completed_at|
|
||||
"(#{connection.quote(id)}, #{connection.quote(completed_at)}::timestamp)"
|
||||
end.join(', ')
|
||||
|
||||
statement = <<~SQL.squish
|
||||
UPDATE #{connection.quote_table_name(AppliedSla.table_name)} AS applied_slas
|
||||
SET completed_at = backfill.completed_at
|
||||
FROM (VALUES #{values}) AS backfill(id, completed_at)
|
||||
WHERE applied_slas.id = backfill.id
|
||||
AND applied_slas.completed_at IS NULL
|
||||
SQL
|
||||
|
||||
connection.exec_update(statement, 'Backfill applied SLA completed_at')
|
||||
end
|
||||
|
||||
def print_preflight(eligible_count)
|
||||
scope = @account_id.present? ? "account_id=#{@account_id}" : 'all accounts'
|
||||
mode = @apply ? 'APPLY' : 'DRY RUN'
|
||||
@output.puts "Applied SLA completed_at backfill: mode=#{mode}, scope=#{scope}, batch_size=#{@batch_size}, after_id=#{@after_id}"
|
||||
@output.puts "Eligible missed applied SLAs: #{eligible_count}"
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ json.sla_id resource.sla_policy_id
|
||||
json.sla_status resource.sla_status
|
||||
json.created_at resource.created_at.to_i
|
||||
json.updated_at resource.updated_at.to_i
|
||||
json.sla_completed_at resource.completed_at&.to_i
|
||||
json.sla_description resource.sla_policy.description
|
||||
json.sla_name resource.sla_policy.name
|
||||
json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Backfill applied_slas.completed_at from conversation resolution reporting events.
|
||||
#
|
||||
# Account-scoped dry run:
|
||||
# ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb
|
||||
#
|
||||
# Account-scoped apply:
|
||||
# ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb
|
||||
#
|
||||
# Explicit global apply with resume controls:
|
||||
# ALL_ACCOUNTS=true APPLY=true BATCH_SIZE=500 AFTER_ID=0 \
|
||||
# bundle exec rails runner script/backfill_applied_sla_completed_at.rb
|
||||
|
||||
begin
|
||||
account_id = Integer(ENV.fetch('ACCOUNT_ID'), 10) if ENV['ACCOUNT_ID'].present?
|
||||
all_accounts = ENV['ALL_ACCOUNTS'] == 'true'
|
||||
apply = ENV['APPLY'] == 'true'
|
||||
batch_size = Integer(ENV.fetch('BATCH_SIZE', Sla::BackfillAppliedSlaCompletedAtService::DEFAULT_BATCH_SIZE.to_s), 10)
|
||||
after_id = Integer(ENV.fetch('AFTER_ID', '0'), 10)
|
||||
|
||||
Sla::BackfillAppliedSlaCompletedAtService.new(
|
||||
account_id: account_id,
|
||||
all_accounts: all_accounts,
|
||||
apply: apply,
|
||||
batch_size: batch_size,
|
||||
after_id: after_id
|
||||
).perform
|
||||
rescue ArgumentError, ActiveRecord::RecordNotFound => e
|
||||
warn "Backfill aborted: #{e.message}"
|
||||
exit 1
|
||||
end
|
||||
@@ -23,6 +23,12 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'locks the account while checking and creating the agent' do
|
||||
expect(account).to receive(:with_lock).and_call_original
|
||||
|
||||
agent_builder.perform
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { agent_builder.perform }.to change(User, :count).by(1)
|
||||
@@ -67,5 +73,17 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
expect(user.encrypted_password).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account has reached its agent limit' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
|
||||
end
|
||||
|
||||
it 'raises a limit exceeded error without creating a user' do
|
||||
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
|
||||
|
||||
expect(User.from_email(email)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,9 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||
it 'deletes the linear integration' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the linear integration when the user is an administrator' do
|
||||
# Stub the HTTP call to Linear's revoke endpoint
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
'https://api.linear.app/oauth/revoke',
|
||||
@@ -21,11 +23,19 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.hooks.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent and keeps the integration' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.hooks.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
||||
|
||||
@@ -159,15 +159,17 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an administrator' do
|
||||
it 'deletes the shopify integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { account.hooks.count }.by(-1)
|
||||
|
||||
@@ -175,6 +177,18 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized and keeps the integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to(change { account.hooks.count })
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
|
||||
@@ -21,6 +21,27 @@ RSpec.describe 'Agents API', type: :request do
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
end
|
||||
|
||||
it 'prevents adding an agent if the last seat is consumed before creation' do
|
||||
account.update!(limits: { agents: account.account_users.count + 1 })
|
||||
competing_agent_created = false
|
||||
|
||||
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
|
||||
unless competing_agent_created
|
||||
create(:user, account: account, role: :agent)
|
||||
competing_agent_created = true
|
||||
end
|
||||
|
||||
method.call(*args)
|
||||
end
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
expect(User.from_email(params[:email])).to be_nil
|
||||
expect(account.account_users.count).to eq(account.usage_limits[:agents])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
it 'returns SLA data for the conversation if the feature is enabled' do
|
||||
account.enable_features!('sla')
|
||||
conversation = create(:conversation, account: account)
|
||||
applied_sla = create(:applied_sla, conversation: conversation)
|
||||
applied_sla = create(:applied_sla, conversation: conversation, completed_at: 1.hour.ago)
|
||||
sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id)
|
||||
expect(response.parsed_body['applied_sla']['sla_completed_at']).to eq(applied_sla.completed_at.to_i)
|
||||
expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id)
|
||||
end
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ RSpec.describe AppliedSla, type: :model do
|
||||
sla_status: applied_sla.sla_status,
|
||||
created_at: applied_sla.created_at.to_i,
|
||||
updated_at: applied_sla.updated_at.to_i,
|
||||
sla_completed_at: nil,
|
||||
sla_description: applied_sla.sla_policy.description,
|
||||
sla_name: applied_sla.sla_policy.name,
|
||||
sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold,
|
||||
|
||||
@@ -41,6 +41,37 @@ RSpec.describe Conversation, type: :model do
|
||||
# end
|
||||
end
|
||||
|
||||
describe 'SLA completion' do
|
||||
let(:applied_sla) { create(:applied_sla) }
|
||||
let(:conversation) { applied_sla.conversation }
|
||||
|
||||
it 'records the completion time when the conversation is resolved' do
|
||||
completion_time = Time.zone.parse('2026-07-15 10:00:00')
|
||||
|
||||
travel_to(completion_time) { conversation.update!(status: :resolved) }
|
||||
|
||||
expect(applied_sla.reload.completed_at).to eq(completion_time)
|
||||
end
|
||||
|
||||
it 'clears the completion time when a nonterminal SLA is reopened' do
|
||||
conversation.update!(status: :resolved)
|
||||
|
||||
conversation.update!(status: :open)
|
||||
|
||||
expect(applied_sla.reload.completed_at).to be_nil
|
||||
end
|
||||
|
||||
it 'preserves the completion time when a terminal SLA is reopened' do
|
||||
conversation.update!(status: :resolved)
|
||||
completed_at = applied_sla.reload.completed_at
|
||||
applied_sla.update!(sla_status: :missed)
|
||||
|
||||
conversation.update!(status: :open)
|
||||
|
||||
expect(applied_sla.reload.completed_at).to eq(completed_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_policy' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Sla::BackfillAppliedSlaCompletedAtService do
|
||||
let(:output) { StringIO.new }
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:applied_sla) do
|
||||
create(
|
||||
:applied_sla,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
sla_status: :missed,
|
||||
created_at: 3.days.ago,
|
||||
updated_at: 1.day.ago
|
||||
)
|
||||
end
|
||||
let!(:resolution_event) do
|
||||
create(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
name: 'conversation_resolved',
|
||||
event_start_time: applied_sla.created_at,
|
||||
event_end_time: 2.days.ago
|
||||
)
|
||||
end
|
||||
|
||||
it 'defaults to a dry run' do
|
||||
result = described_class.new(account_id: account.id, output: output).perform
|
||||
|
||||
expect(result).to include(dry_run: true, eligible: 1, matched: 1, updated: 0, skipped: 0)
|
||||
expect(applied_sla.reload.completed_at).to be_nil
|
||||
end
|
||||
|
||||
it 'backfills the latest reliable resolution without changing updated_at' do
|
||||
latest_resolution = create(
|
||||
:reporting_event,
|
||||
account: account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
name: 'conversation_resolved',
|
||||
event_start_time: applied_sla.created_at,
|
||||
event_end_time: 36.hours.ago
|
||||
)
|
||||
original_updated_at = applied_sla.updated_at
|
||||
|
||||
result = described_class.new(account_id: account.id, apply: true, output: output).perform
|
||||
|
||||
expect(result).to include(dry_run: false, eligible: 1, matched: 1, updated: 1, skipped: 0)
|
||||
expect(applied_sla.reload.completed_at).to eq(latest_resolution.reload.event_end_time)
|
||||
expect(applied_sla.updated_at).to eq(original_updated_at)
|
||||
end
|
||||
|
||||
it 'skips records without a reliable resolution event' do
|
||||
resolution_event.destroy!
|
||||
|
||||
result = described_class.new(account_id: account.id, apply: true, output: output).perform
|
||||
|
||||
expect(result).to include(eligible: 1, matched: 0, updated: 0, skipped: 1)
|
||||
expect(applied_sla.reload.completed_at).to be_nil
|
||||
end
|
||||
|
||||
it 'is idempotent' do
|
||||
service = described_class.new(account_id: account.id, apply: true, output: output)
|
||||
|
||||
service.perform
|
||||
result = service.perform
|
||||
|
||||
expect(result).to include(eligible: 0, matched: 0, updated: 0, skipped: 0)
|
||||
expect(applied_sla.reload.completed_at).to eq(resolution_event.reload.event_end_time)
|
||||
end
|
||||
|
||||
it 'requires exactly one account scope' do
|
||||
expect { described_class.new(output: output).perform }
|
||||
.to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true')
|
||||
expect { described_class.new(account_id: account.id, all_accounts: true, output: output).perform }
|
||||
.to raise_error(ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true')
|
||||
end
|
||||
|
||||
it 'limits account runs and requires explicit global scope for other accounts' do
|
||||
other_account = create(:account)
|
||||
other_conversation = create(:conversation, account: other_account)
|
||||
other_applied_sla = create(
|
||||
:applied_sla,
|
||||
account: other_account,
|
||||
conversation: other_conversation,
|
||||
sla_status: :missed,
|
||||
created_at: 3.days.ago,
|
||||
updated_at: 1.day.ago
|
||||
)
|
||||
other_resolution_event = create(
|
||||
:reporting_event,
|
||||
account: other_account,
|
||||
inbox: other_conversation.inbox,
|
||||
conversation: other_conversation,
|
||||
name: 'conversation_resolved',
|
||||
event_start_time: other_applied_sla.created_at,
|
||||
event_end_time: 2.days.ago
|
||||
)
|
||||
|
||||
described_class.new(account_id: account.id, apply: true, output: output).perform
|
||||
|
||||
expect(applied_sla.reload.completed_at).to eq(resolution_event.reload.event_end_time)
|
||||
expect(other_applied_sla.reload.completed_at).to be_nil
|
||||
|
||||
described_class.new(all_accounts: true, apply: true, output: output).perform
|
||||
|
||||
expect(other_applied_sla.reload.completed_at).to eq(other_resolution_event.reload.event_end_time)
|
||||
end
|
||||
|
||||
it 'resumes after the supplied applied SLA id' do
|
||||
result = described_class.new(account_id: account.id, after_id: applied_sla.id, output: output).perform
|
||||
|
||||
expect(result).to include(eligible: 0, processed: 0, last_id: applied_sla.id)
|
||||
end
|
||||
end
|
||||
@@ -345,6 +345,94 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
||||
end.not_to change(Conversation, :count)
|
||||
end
|
||||
|
||||
it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do
|
||||
brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: brazil_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '554199887766'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do
|
||||
argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: argentina_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '5491112345678'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'finds channel when display_phone_number contains formatting characters' do
|
||||
formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: formatted_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '+1 415-555-2671'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do
|
||||
normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false).tap do |raw_channel|
|
||||
raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id'))
|
||||
end
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: normalized_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '554199887766'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
|
||||
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
|
||||
@@ -72,6 +72,21 @@ describe Whatsapp::SendOnWhatsappService do
|
||||
expect(message.reload.source_id).to eq('123456789')
|
||||
end
|
||||
|
||||
it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do
|
||||
create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago,
|
||||
conversation: conversation, account: conversation.account)
|
||||
message = create(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(Whatsapp::TemplateProcessorService).not_to receive(:new)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made
|
||||
end
|
||||
|
||||
it 'marks message as failed when template name is blank' do
|
||||
processor = instance_double(Whatsapp::TemplateProcessorService)
|
||||
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)
|
||||
|
||||
Reference in New Issue
Block a user