Merge branch 'develop' into chore/update-rails
This commit is contained in:
@@ -2,10 +2,9 @@ class RoomChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
# TODO: should we only do ensure stream if current account is present?
|
||||
# for now going ahead with guard clauses in update_subscription and broadcast_presence
|
||||
|
||||
ensure_stream
|
||||
current_user
|
||||
current_account
|
||||
ensure_stream
|
||||
update_subscription
|
||||
broadcast_presence
|
||||
end
|
||||
@@ -22,12 +21,12 @@ class RoomChannel < ApplicationCable::Channel
|
||||
|
||||
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
|
||||
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
|
||||
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
|
||||
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
|
||||
end
|
||||
|
||||
def ensure_stream
|
||||
@pubsub_token = params[:pubsub_token]
|
||||
stream_from @pubsub_token
|
||||
stream_from pubsub_token
|
||||
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
|
||||
end
|
||||
|
||||
def update_subscription
|
||||
@@ -36,11 +35,15 @@ class RoomChannel < ApplicationCable::Channel
|
||||
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
|
||||
end
|
||||
|
||||
def pubsub_token
|
||||
@pubsub_token ||= params[:pubsub_token]
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= if params[:user_id].blank?
|
||||
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
|
||||
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
|
||||
else
|
||||
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
|
||||
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
|
||||
skip_before_action :set_contact
|
||||
|
||||
def index
|
||||
@campaigns = @web_widget.inbox.campaigns.where(enabled: true)
|
||||
@campaigns = @web_widget
|
||||
.inbox
|
||||
.campaigns
|
||||
.where(enabled: true, account_id: @web_widget.inbox.account_id)
|
||||
.includes(:sender)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,6 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
|
||||
skip_before_action :set_contact
|
||||
|
||||
def index
|
||||
@inbox_members = @web_widget.inbox.inbox_members.includes(:user)
|
||||
@inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,17 +6,25 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@articles = @portal.articles.published
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
@articles_count = @articles.count
|
||||
search_articles
|
||||
order_by_sort_param
|
||||
@articles = @articles.page(list_params[:page]) if list_params[:page].present?
|
||||
limit_results
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
private
|
||||
|
||||
def limit_results
|
||||
return if list_params[:per_page].blank?
|
||||
|
||||
per_page = [list_params[:per_page].to_i, 100].min
|
||||
per_page = 25 if per_page < 1
|
||||
@articles = @articles.page(list_params[:page]).per(per_page)
|
||||
end
|
||||
|
||||
def search_articles
|
||||
@articles = @articles.search(list_params) if list_params.present?
|
||||
end
|
||||
@@ -45,7 +53,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.permit(:query, :locale, :sort, :status, :page)
|
||||
params.permit(:query, :locale, :sort, :status, :page, :per_page)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Twilio::CallbackController < ApplicationController
|
||||
def create
|
||||
::Twilio::IncomingMessageService.new(params: permitted_params).perform
|
||||
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Twilio::DeliveryStatusController < ApplicationController
|
||||
def create
|
||||
::Twilio::DeliveryStatusService.new(params: permitted_params).perform
|
||||
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
@@ -61,6 +60,19 @@ export default function useAutomationValues() {
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a translated "None" option to the beginning of a list
|
||||
* @param {Array} list - The list to add "None" to
|
||||
* @returns {Array} A new array with "None" option at the beginning
|
||||
*/
|
||||
const addNoneToList = list => [
|
||||
{
|
||||
id: 'nil',
|
||||
name: t('AUTOMATION.NONE_OPTION') || 'None',
|
||||
},
|
||||
...(list || []),
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the condition dropdown values for a given type.
|
||||
* @param {string} type - The type of condition.
|
||||
@@ -95,6 +107,7 @@ export default function useAutomationValues() {
|
||||
slaPolicies: slaPolicies.value,
|
||||
languages,
|
||||
type,
|
||||
addNoneToListFn: addNoneToList,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export const generateCustomAttributeTypes = (customAttributes, type) => {
|
||||
};
|
||||
|
||||
export const generateConditionOptions = (options, key = 'id') => {
|
||||
if (!options || !Array.isArray(options)) return [];
|
||||
return options.map(i => {
|
||||
return {
|
||||
id: i[key],
|
||||
@@ -95,25 +96,17 @@ export const generateConditionOptions = (options, key = 'id') => {
|
||||
});
|
||||
};
|
||||
|
||||
// Add the "None" option to the agent list
|
||||
export const addNoneToList = agents => [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
},
|
||||
...(agents || []),
|
||||
];
|
||||
|
||||
export const getActionOptions = ({
|
||||
agents,
|
||||
teams,
|
||||
labels,
|
||||
slaPolicies,
|
||||
type,
|
||||
addNoneToListFn,
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: addNoneToList(agents),
|
||||
assign_team: addNoneToList(teams),
|
||||
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
|
||||
assign_team: addNoneToListFn ? addNoneToListFn(teams) : teams,
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
|
||||
@@ -118,6 +118,46 @@ describe('getActionOptions', () => {
|
||||
expectedOptions
|
||||
);
|
||||
});
|
||||
|
||||
it('adds None option when addNoneToListFn is provided', () => {
|
||||
const mockAddNoneToListFn = list => [
|
||||
{ id: 'nil', name: 'None' },
|
||||
...(list || []),
|
||||
];
|
||||
|
||||
const agents = [
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
];
|
||||
|
||||
const expectedOptions = [
|
||||
{ id: 'nil', name: 'None' },
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
];
|
||||
|
||||
expect(
|
||||
helpers.getActionOptions({
|
||||
agents,
|
||||
type: 'assign_agent',
|
||||
addNoneToListFn: mockAddNoneToListFn,
|
||||
})
|
||||
).toEqual(expectedOptions);
|
||||
});
|
||||
|
||||
it('does not add None option when addNoneToListFn is not provided', () => {
|
||||
const agents = [
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
{ id: 2, name: 'Agent 2' },
|
||||
];
|
||||
|
||||
expect(
|
||||
helpers.getActionOptions({
|
||||
agents,
|
||||
type: 'assign_agent',
|
||||
})
|
||||
).toEqual(agents);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConditionOptions', () => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"AGENT_BOTS": {
|
||||
"HEADER": "Bots",
|
||||
"LOADING_EDITOR": "Loading editor...",
|
||||
"HEADER_BTN_TXT": "Add bot configuration",
|
||||
"SIDEBAR_TXT": "<p><b>Agent Bots</b> <p>Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try.</p> <p> You can manage your bots from this page or create new ones using the 'Add bot configuraton' button.</p> <p> Open the <a href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677497472-how-to-use-agent-bots\" target=\"blank\">Agent bots handbook</a> in another tab for a helping hand.</p>",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try.You can manage your bots from this page or create new ones using the 'Configure new bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"CSML_BOT_EDITOR": {
|
||||
"NAME": {
|
||||
"LABEL": "Bot name",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"ACTION_PARAMETERS_REQUIRED": "Action parameters are required",
|
||||
"ATLEAST_ONE_CONDITION_REQUIRED": "At least one condition is required",
|
||||
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
|
||||
}
|
||||
},
|
||||
"NONE_OPTION": "None"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"BUTTON_TEXT": "Disconnect"
|
||||
},
|
||||
"SIDEBAR_DESCRIPTION": {
|
||||
"DIALOGFLOW": "Dialogflow is a natural language understanding platform that makes it easy to design and integrate a conversational user interface into your mobile app, web application, device, bot, interactive voice response system, and so on. <br /> <br /> Dialogflow integration with {installationName} allows you to configure a Dialogflow bot with your inboxes which lets the bot handle the queries initially and hand them over to an agent when needed. Dialogflow can be used to qualifying the leads, reduce the workload of agents by providing frequently asked questions etc. <br /> <br /> To add Dialogflow, you need to create a Service Account in your Google project console and share the credentials. Please refer to the Dialogflow docs for more information."
|
||||
"DIALOGFLOW": "Dialogflow is a natural language processing platform for building conversational interfaces. Integrating it with {installationName} lets bots handle queries first and transfer them to agents when needed. It helps qualify leads and reduce agent workload by answering FAQs. To add Dialogflow, create a Service Account in Google Console and share the credentials. Refer to the docs for details"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -271,7 +271,7 @@ const evenClass = [
|
||||
ghost-class="ghost"
|
||||
handle=".drag-handle"
|
||||
item-key="key"
|
||||
class="last:rounded-b-lg overflow-hidden"
|
||||
class="last:rounded-b-lg"
|
||||
:class="evenClass"
|
||||
@start="dragging = true"
|
||||
@end="onDragEnd"
|
||||
|
||||
@@ -19,12 +19,21 @@ const { t } = useI18n();
|
||||
const showNewButton = computed(
|
||||
() => props.newButtonRoutes.length && !props.showBackButton
|
||||
);
|
||||
|
||||
const showSettingsHeader = computed(
|
||||
() =>
|
||||
props.headerTitle ||
|
||||
props.icon ||
|
||||
props.showBackButton ||
|
||||
showNewButton.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col m-0 bg-n-background overflow-auto">
|
||||
<div class="max-w-6xl mx-auto w-full flex flex-col flex-1">
|
||||
<SettingsHeader
|
||||
v-if="showSettingsHeader"
|
||||
button-route="new"
|
||||
:icon="icon"
|
||||
:header-title="t(headerTitle)"
|
||||
|
||||
@@ -1,59 +1,87 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import AgentBotRow from './components/AgentBotRow.vue';
|
||||
|
||||
export default {
|
||||
components: { AgentBotRow },
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
agentBots: 'agentBots/getBots',
|
||||
uiFlags: 'agentBots/getUIFlags',
|
||||
}),
|
||||
newAgentBotsURL() {
|
||||
return frontendURL(
|
||||
`accounts/${this.accountId}/settings/agent-bots/csml/new`
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agentBots/get');
|
||||
},
|
||||
methods: {
|
||||
async onDeleteAgentBot(bot) {
|
||||
const ok = await this.$refs.confirmDialog.showConfirmation();
|
||||
if (ok) {
|
||||
try {
|
||||
await this.$store.dispatch('agentBots/delete', bot.id);
|
||||
useAlert(this.$t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
onEditAgentBot(bot) {
|
||||
this.$router.push(
|
||||
frontendURL(
|
||||
`accounts/${this.accountId}/settings/agent-bots/csml/${bot.id}`
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const agentBots = useMapGetter('agentBots/getBots');
|
||||
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const onConfigureNewBot = () => {
|
||||
router.push({
|
||||
name: 'agent_bots_csml_new',
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('agentBots/get');
|
||||
});
|
||||
|
||||
const onDeleteAgentBot = async bot => {
|
||||
const ok = await confirmDialog.value.showConfirmation();
|
||||
|
||||
if (ok) {
|
||||
try {
|
||||
await store.dispatch('agentBots/delete', bot.id);
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onEditAgentBot = bot => {
|
||||
router.push(
|
||||
frontendURL(
|
||||
`accounts/${accountId.value}/settings/agent-bots/csml/${bot.id}`
|
||||
)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="w-full lg:w-3/5">
|
||||
<woot-loading-state
|
||||
v-if="uiFlags.isFetching"
|
||||
:message="$t('AGENT_BOTS.LIST.LOADING')"
|
||||
/>
|
||||
<table v-else-if="agentBots.length" class="woot-table">
|
||||
<tbody>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:loading-message="$t('AGENT_BOTS.LIST.LOADING')"
|
||||
:no-records-found="!agentBots.length"
|
||||
:no-records-message="$t('AGENT_BOTS.LIST.404')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('AGENT_BOTS.HEADER')"
|
||||
:description="$t('AGENT_BOTS.DESCRIPTION')"
|
||||
:link-text="$t('AGENT_BOTS.LEARN_MORE')"
|
||||
feature-name="agent_bots"
|
||||
>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
class="rounded-md button nice"
|
||||
icon="add-circle"
|
||||
@click="onConfigureNewBot"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.ADD.TITLE') }}
|
||||
</woot-button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<table class="divide-y divide-slate-75 dark:divide-slate-700">
|
||||
<tbody class="divide-y divide-n-weak text-n-slate-11">
|
||||
<AgentBotRow
|
||||
v-for="(agentBot, index) in agentBots"
|
||||
:key="agentBot.id"
|
||||
@@ -64,42 +92,12 @@ export default {
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="flex flex-col items-center justify-center h-full">
|
||||
{{ $t('AGENT_BOTS.LIST.404') }}
|
||||
</p>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('AGENT_BOTS.DELETE.TITLE')"
|
||||
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-1/3 lg:block">
|
||||
<p v-dompurify-html="$t('AGENT_BOTS.SIDEBAR_TXT')" />
|
||||
</div>
|
||||
</div>
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="add-circle"
|
||||
>
|
||||
<router-link :to="newAgentBotsURL" class="white-text">
|
||||
{{ $t('AGENT_BOTS.ADD.TITLE') }}
|
||||
</router-link>
|
||||
</woot-button>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('AGENT_BOTS.DELETE.TITLE')"
|
||||
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bots-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.white-text {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import CsmlEditBot from './csml/Edit.vue';
|
||||
import CsmlNewBot from './csml/New.vue';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -12,12 +13,7 @@ export default {
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'AGENT_BOTS.HEADER',
|
||||
icon: 'bot',
|
||||
showNewButton: false,
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -28,6 +24,19 @@ export default {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/agent-bots'),
|
||||
component: SettingsContent,
|
||||
props: () => {
|
||||
return {
|
||||
headerTitle: 'AGENT_BOTS.HEADER',
|
||||
icon: 'bot',
|
||||
showBackButton: true,
|
||||
};
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'csml/new',
|
||||
name: 'agent_bots_csml_new',
|
||||
|
||||
+40
-63
@@ -1,81 +1,58 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
||||
import AgentBotType from './AgentBotType.vue';
|
||||
|
||||
export default {
|
||||
components: { ShowMore, AgentBotType },
|
||||
props: {
|
||||
agentBot: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
agentBot: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
emits: ['edit', 'delete'],
|
||||
computed: {
|
||||
isACSMLTypeBot() {
|
||||
const { bot_type: botType } = this.agentBot;
|
||||
return botType === 'csml';
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const isACSMLTypeBot = computed(() => {
|
||||
const { bot_type: botType } = props.agentBot;
|
||||
return botType === 'csml';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="space-x-2">
|
||||
<td class="agent-bot--details">
|
||||
<div class="agent-bot--link">
|
||||
<td class="py-4 ltr:pl-0 ltr:pr-4 rtl:pl-4 rtl:pr-0">
|
||||
<div class="flex items-center break-words font-medium">
|
||||
{{ agentBot.name }}
|
||||
(<AgentBotType :bot-type="agentBot.bot_type" />)
|
||||
</div>
|
||||
<div class="agent-bot--description">
|
||||
<div class="text-sm">
|
||||
<ShowMore :text="agentBot.description || ''" :limit="120" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="flex justify-end gap-1">
|
||||
<woot-button
|
||||
v-if="isACSMLTypeBot"
|
||||
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
@click="$emit('edit', agentBot)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
@click="$emit('delete', agentBot, index)"
|
||||
/>
|
||||
<td class="align-middle">
|
||||
<div class="flex justify-end gap-1 h-full items-center">
|
||||
<woot-button
|
||||
v-if="isACSMLTypeBot"
|
||||
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
@click="emit('edit', agentBot)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
@click="emit('delete', agentBot, index)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.agent-bot--link {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-weight: var(--font-weight-medium);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.agent-bot--description {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.agent-bot--type {
|
||||
color: var(--s-600);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-bottom: var(--space-small);
|
||||
}
|
||||
|
||||
.agent-bot--details {
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+22
-29
@@ -1,43 +1,36 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
botType: {
|
||||
type: String,
|
||||
default: 'webhook',
|
||||
},
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
botType: {
|
||||
type: String,
|
||||
default: 'webhook',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
botTypeConfig: {
|
||||
csml: {
|
||||
label: this.$t('AGENT_BOTS.TYPES.CSML'),
|
||||
thumbnail: '/dashboard/images/agent-bots/csml.png',
|
||||
},
|
||||
webhook: {
|
||||
label: this.$t('AGENT_BOTS.TYPES.WEBHOOK'),
|
||||
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const botTypeConfig = computed(() => ({
|
||||
csml: {
|
||||
label: t('AGENT_BOTS.TYPES.CSML'),
|
||||
thumbnail: '/dashboard/images/agent-bots/csml.png',
|
||||
},
|
||||
};
|
||||
webhook: {
|
||||
label: t('AGENT_BOTS.TYPES.WEBHOOK'),
|
||||
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<img
|
||||
v-tooltip="botTypeConfig[botType].label"
|
||||
class="agent-bot-type--thumbnail"
|
||||
class="h-3 w-auto"
|
||||
:src="botTypeConfig[botType].thumbnail"
|
||||
:alt="botTypeConfig[botType].label"
|
||||
/>
|
||||
<span>{{ botTypeConfig[botType].label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-bot-type--thumbnail {
|
||||
width: auto;
|
||||
height: var(--space-slab);
|
||||
}
|
||||
</style>
|
||||
|
||||
+3
-12
@@ -108,22 +108,13 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overflow-auto p-4 max-w-6xl mx-auto my-auto flex flex-wrap h-full"
|
||||
>
|
||||
<woot-button
|
||||
v-if="showAddButton"
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="add-circle"
|
||||
@click="openAddHookModal"
|
||||
>
|
||||
{{ $t('INTEGRATION_APPS.ADD_BUTTON') }}
|
||||
</woot-button>
|
||||
<div class="overflow-auto p-4 w-full my-auto flex flex-wrap h-full">
|
||||
<div v-if="showIntegrationHooks" class="w-full">
|
||||
<div v-if="isIntegrationMultiple">
|
||||
<MultipleIntegrationHooks
|
||||
:integration-id="integrationId"
|
||||
:show-add-button="showAddButton"
|
||||
@add="openAddHookModal"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+40
-18
@@ -1,14 +1,23 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
||||
import BaseSettingsHeader from 'dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BaseSettingsHeader,
|
||||
},
|
||||
props: {
|
||||
integrationId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showAddButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['delete'],
|
||||
emits: ['delete', 'add'],
|
||||
setup(props) {
|
||||
const { integration, isHookTypeInbox, hasConnectedHooks } =
|
||||
useIntegrationHook(props.integrationId);
|
||||
@@ -45,11 +54,37 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="w-full lg:w-3/5">
|
||||
<div class="flex flex-col flex-1 gap-8 overflow-auto">
|
||||
<BaseSettingsHeader
|
||||
:title="integration.name"
|
||||
:description="
|
||||
$t(
|
||||
`INTEGRATION_APPS.SIDEBAR_DESCRIPTION.${integration.name.toUpperCase()}`,
|
||||
{ installationName: globalConfig.installationName }
|
||||
)
|
||||
"
|
||||
:feature-name="integrationId"
|
||||
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
v-if="showAddButton"
|
||||
class="rounded-md button nice"
|
||||
icon="add-circle"
|
||||
@click="$emit('add')"
|
||||
>
|
||||
{{ $t('INTEGRATION_APPS.ADD_BUTTON') }}
|
||||
</woot-button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
<div class="w-full">
|
||||
<table v-if="hasConnectedHooks" class="woot-table">
|
||||
<thead>
|
||||
<th v-for="hookHeader in hookHeaders" :key="hookHeader">
|
||||
<th
|
||||
v-for="hookHeader in hookHeaders"
|
||||
:key="hookHeader"
|
||||
class="ltr:!pl-0 rtl:!pr-0"
|
||||
>
|
||||
{{ hookHeader }}
|
||||
</th>
|
||||
<th v-if="isHookTypeInbox">
|
||||
@@ -61,7 +96,7 @@ export default {
|
||||
<td
|
||||
v-for="property in hook.properties"
|
||||
:key="property"
|
||||
class="break-words"
|
||||
class="ltr:!pl-0 rtl:!pr-0"
|
||||
>
|
||||
{{ property }}
|
||||
</td>
|
||||
@@ -90,18 +125,5 @@ export default {
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden w-1/3 lg:block">
|
||||
<p>
|
||||
<b>{{ integration.name }}</b>
|
||||
</p>
|
||||
<p
|
||||
v-dompurify-html="
|
||||
$t(
|
||||
`INTEGRATION_APPS.SIDEBAR_DESCRIPTION.${integration.name.toUpperCase()}`,
|
||||
{ installationName: globalConfig.installationName }
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +48,14 @@ export default {
|
||||
path: frontendURL('accounts/:accountId/settings/integrations'),
|
||||
component: SettingsContent,
|
||||
props: params => {
|
||||
const integrationId = params.params?.integration_id;
|
||||
const hideHeader = ['dialogflow'].includes(integrationId);
|
||||
|
||||
// Don't show header
|
||||
if (hideHeader) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const showBackButton = params.name !== 'settings_integrations';
|
||||
const backUrl =
|
||||
params.name === 'settings_integrations_integration'
|
||||
|
||||
@@ -103,6 +103,7 @@ const getMostReadArticles = (slug, locale) => ({
|
||||
page: 1,
|
||||
sort: 'views',
|
||||
status: 1,
|
||||
per_page: 6,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
const state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
hasFetched: false,
|
||||
isError: false,
|
||||
},
|
||||
activeCampaign: {},
|
||||
@@ -30,6 +31,7 @@ const resetCampaignTimers = (
|
||||
|
||||
export const getters = {
|
||||
getCampaigns: $state => $state.records,
|
||||
getUIFlags: $state => $state.uiFlags,
|
||||
getActiveCampaign: $state => $state.activeCampaign,
|
||||
};
|
||||
|
||||
@@ -53,15 +55,21 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
initCampaigns: async (
|
||||
{ getters: { getCampaigns: campaigns }, dispatch },
|
||||
{ getters: { getCampaigns: campaigns, getUIFlags: uiFlags }, dispatch },
|
||||
{ currentURL, websiteToken, isInBusinessHours }
|
||||
) => {
|
||||
if (!campaigns.length) {
|
||||
dispatch('fetchCampaigns', {
|
||||
websiteToken,
|
||||
currentURL,
|
||||
isInBusinessHours,
|
||||
});
|
||||
// This check is added to ensure that the campaigns are fetched once
|
||||
// On high traffic sites, if the campaigns are empty, the API is called
|
||||
// every time the user changes the URL (in case of the SPA)
|
||||
// So, we need to ensure that the campaigns are fetched only once
|
||||
if (!uiFlags.hasFetched) {
|
||||
dispatch('fetchCampaigns', {
|
||||
websiteToken,
|
||||
currentURL,
|
||||
isInBusinessHours,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resetCampaignTimers(
|
||||
campaigns,
|
||||
@@ -127,6 +135,7 @@ export const actions = {
|
||||
export const mutations = {
|
||||
setCampaigns($state, data) {
|
||||
$state.records = data;
|
||||
$state.uiFlags.hasFetched = true;
|
||||
},
|
||||
setActiveCampaign($state, data) {
|
||||
$state.activeCampaign = data;
|
||||
|
||||
@@ -63,15 +63,37 @@ describe('#actions', () => {
|
||||
};
|
||||
it('sends correct actions if campaigns are empty', async () => {
|
||||
await actions.initCampaigns(
|
||||
{ dispatch, getters: { getCampaigns: [] } },
|
||||
{
|
||||
dispatch,
|
||||
getters: { getCampaigns: [], getUIFlags: { hasFetched: false } },
|
||||
},
|
||||
actionParams
|
||||
);
|
||||
expect(dispatch.mock.calls).toEqual([['fetchCampaigns', actionParams]]);
|
||||
expect(campaignTimer.initTimers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('do not refetch if the campaigns are fetched once', async () => {
|
||||
await actions.initCampaigns(
|
||||
{
|
||||
dispatch,
|
||||
getters: { getCampaigns: [], getUIFlags: { hasFetched: true } },
|
||||
},
|
||||
actionParams
|
||||
);
|
||||
expect(dispatch.mock.calls).toEqual([]);
|
||||
expect(campaignTimer.initTimers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets time if campaigns are available', async () => {
|
||||
await actions.initCampaigns(
|
||||
{ dispatch, getters: { getCampaigns: campaigns } },
|
||||
{
|
||||
dispatch,
|
||||
getters: {
|
||||
getCampaigns: campaigns,
|
||||
getUIFlags: { hasFetched: true },
|
||||
},
|
||||
},
|
||||
actionParams
|
||||
);
|
||||
expect(dispatch.mock.calls).toEqual([]);
|
||||
|
||||
@@ -7,9 +7,10 @@ vi.mock('widget/store/index.js', () => ({
|
||||
describe('#mutations', () => {
|
||||
describe('#setCampaigns', () => {
|
||||
it('set campaign records', () => {
|
||||
const state = { records: [] };
|
||||
const state = { records: [], uiFlags: {} };
|
||||
mutations.setCampaigns(state, campaigns);
|
||||
expect(state.records).toEqual(campaigns);
|
||||
expect(state.uiFlags.hasFetched).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class Webhooks::TwilioDeliveryStatusJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(params = {})
|
||||
::Twilio::DeliveryStatusService.new(params: params).perform
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class Webhooks::TwilioEventsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(params = {})
|
||||
# Skip processing if Body parameter or MediaUrl0 is not present
|
||||
# This is to skip processing delivery events being delivered to this endpoint
|
||||
return if params[:Body].blank? && params[:MediaUrl0].blank?
|
||||
|
||||
::Twilio::IncomingMessageService.new(params: params).perform
|
||||
end
|
||||
end
|
||||
@@ -137,30 +137,22 @@ class ActionCableListener < BaseListener
|
||||
|
||||
def contact_created(event)
|
||||
contact, account = extract_contact_and_account(event)
|
||||
tokens = user_tokens(account, account.agents)
|
||||
|
||||
broadcast(account, tokens, CONTACT_CREATED, contact.push_event_data)
|
||||
broadcast(account, [account_token(account)], CONTACT_CREATED, contact.push_event_data)
|
||||
end
|
||||
|
||||
def contact_updated(event)
|
||||
contact, account = extract_contact_and_account(event)
|
||||
tokens = user_tokens(account, account.agents)
|
||||
|
||||
broadcast(account, tokens, CONTACT_UPDATED, contact.push_event_data)
|
||||
broadcast(account, [account_token(account)], CONTACT_UPDATED, contact.push_event_data)
|
||||
end
|
||||
|
||||
def contact_merged(event)
|
||||
contact, account = extract_contact_and_account(event)
|
||||
tokens = event.data[:tokens]
|
||||
|
||||
broadcast(account, tokens, CONTACT_MERGED, contact.push_event_data)
|
||||
broadcast(account, [account_token(account)], CONTACT_MERGED, contact.push_event_data)
|
||||
end
|
||||
|
||||
def contact_deleted(event)
|
||||
contact, account = extract_contact_and_account(event)
|
||||
tokens = user_tokens(account, account.agents)
|
||||
|
||||
broadcast(account, tokens, CONTACT_DELETED, contact.push_event_data)
|
||||
broadcast(account, [account_token(account)], CONTACT_DELETED, contact.push_event_data)
|
||||
end
|
||||
|
||||
def conversation_mentioned(event)
|
||||
@@ -172,6 +164,10 @@ class ActionCableListener < BaseListener
|
||||
|
||||
private
|
||||
|
||||
def account_token(account)
|
||||
"account_#{account.id}"
|
||||
end
|
||||
|
||||
def typing_event_listener_tokens(account, conversation, user)
|
||||
current_user_token = user.is_a?(Contact) ? conversation.contact_inbox.pubsub_token : user.pubsub_token
|
||||
(user_tokens(account, conversation.inbox.members) + [conversation.contact_inbox.pubsub_token]) - [current_user_token]
|
||||
|
||||
@@ -23,9 +23,13 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_articles_on_account_id (account_id)
|
||||
# index_articles_on_associated_article_id (associated_article_id)
|
||||
# index_articles_on_author_id (author_id)
|
||||
# index_articles_on_portal_id (portal_id)
|
||||
# index_articles_on_slug (slug) UNIQUE
|
||||
# index_articles_on_status (status)
|
||||
# index_articles_on_views (views)
|
||||
#
|
||||
class Article < ApplicationRecord
|
||||
include PgSearch::Model
|
||||
|
||||
@@ -4,26 +4,6 @@ json.locale category.locale
|
||||
json.description category.description
|
||||
json.position category.position
|
||||
|
||||
json.related_categories do
|
||||
if category.related_categories.any?
|
||||
json.array! category.related_categories.each do |related_category|
|
||||
json.partial! partial: 'public/api/v1/models/associated_category', formats: [:json], category: related_category
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if category.parent_category.present?
|
||||
json.parent_category do
|
||||
json.partial! partial: 'public/api/v1/models/associated_category', formats: [:json], category: category.parent_category
|
||||
end
|
||||
end
|
||||
|
||||
if category.root_category.present?
|
||||
json.root_category do
|
||||
json.partial! partial: 'public/api/v1/models/associated_category', formats: [:json], category: category.root_category
|
||||
end
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.articles_count category.articles.published.size
|
||||
end
|
||||
|
||||
@@ -4,5 +4,5 @@ json.payload do
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.articles_count @articles.published.size
|
||||
json.articles_count @articles_count
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||
|
||||
<body>
|
||||
</body>
|
||||
|
||||
<%
|
||||
user_id = 1
|
||||
|
||||
@@ -5,6 +5,9 @@ default: &default
|
||||
port: <%= ENV.fetch('POSTGRES_PORT', '5432') %>
|
||||
# ref: https://github.com/mperham/sidekiq/issues/2985#issuecomment-531097962
|
||||
pool: <%= Sidekiq.server? ? ENV.fetch('SIDEKIQ_CONCURRENCY', 10) : ENV.fetch('RAILS_MAX_THREADS', 5) %>
|
||||
# frequency in seconds to periodically run the Reaper, which attempts
|
||||
# to find and recover connections from dead threads
|
||||
reaping_frequency: <%= ENV.fetch('DB_POOL_REAPING_FREQUENCY', 30) %>
|
||||
variables:
|
||||
# we are setting this value to be close to the racktimeout value. we will iterate and reduce this value going forward
|
||||
statement_timeout: <%= ENV["POSTGRES_STATEMENT_TIMEOUT"] || "14s" %>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddIndexToArticles < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_index :articles, :status unless index_exists?(:articles, :status)
|
||||
add_index :articles, :views unless index_exists?(:articles, :views)
|
||||
add_index :articles, :portal_id unless index_exists?(:articles, :portal_id)
|
||||
add_index :articles, :account_id unless index_exists?(:articles, :account_id)
|
||||
end
|
||||
end
|
||||
+5
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_03_15_202035) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -159,9 +159,13 @@ ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
|
||||
t.string "slug", null: false
|
||||
t.integer "position"
|
||||
t.string "locale", default: "en", null: false
|
||||
t.index ["account_id"], name: "index_articles_on_account_id"
|
||||
t.index ["associated_article_id"], name: "index_articles_on_associated_article_id"
|
||||
t.index ["author_id"], name: "index_articles_on_author_id"
|
||||
t.index ["portal_id"], name: "index_articles_on_portal_id"
|
||||
t.index ["slug"], name: "index_articles_on_slug", unique: true
|
||||
t.index ["status"], name: "index_articles_on_status"
|
||||
t.index ["views"], name: "index_articles_on_views"
|
||||
end
|
||||
|
||||
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||
|
||||
@@ -2,6 +2,8 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe RoomChannel do
|
||||
let!(:contact_inbox) { create(:contact_inbox) }
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
stub_connection
|
||||
@@ -12,4 +14,11 @@ RSpec.describe RoomChannel do
|
||||
expect(subscription).to be_confirmed
|
||||
expect(subscription).to have_stream_for(contact_inbox.pubsub_token)
|
||||
end
|
||||
|
||||
it 'subscribes to a stream when pubsub_token is provided for user' do
|
||||
subscribe(user_id: user.id, pubsub_token: user.pubsub_token, account_id: account.id)
|
||||
expect(subscription).to be_confirmed
|
||||
expect(subscription).to have_stream_for(user.pubsub_token)
|
||||
expect(subscription).to have_stream_for("account_#{account.id}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,6 +58,23 @@ RSpec.describe 'Public Articles API', type: :request do
|
||||
expect(response_data[1][:views]).to eq(1)
|
||||
expect(response_data.last[:id]).to eq(article.id)
|
||||
end
|
||||
|
||||
it 'limits results based on per_page parameter' do
|
||||
get "/hc/#{portal.slug}/#{category.locale}/articles.json", params: { per_page: 2 }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
|
||||
expect(response_data.length).to eq(2)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
|
||||
end
|
||||
|
||||
it 'uses default items per page if per_page is less than 1' do
|
||||
get "/hc/#{portal.slug}/#{category.locale}/articles.json", params: { per_page: 0 }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
|
||||
expect(response_data.length).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/:slug/articles/:id' do
|
||||
|
||||
@@ -2,17 +2,27 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Twilio::CallbacksController', type: :request do
|
||||
include Rails.application.routes.url_helpers
|
||||
let(:twilio_service) { instance_double(Twilio::IncomingMessageService) }
|
||||
|
||||
before do
|
||||
allow(Twilio::IncomingMessageService).to receive(:new).and_return(twilio_service)
|
||||
allow(twilio_service).to receive(:perform)
|
||||
end
|
||||
describe 'POST /twilio/callback' do
|
||||
let(:params) do
|
||||
{
|
||||
'From' => '+1234567890',
|
||||
'To' => '+0987654321',
|
||||
'Body' => 'Test message',
|
||||
'AccountSid' => 'AC123',
|
||||
'SmsSid' => 'SM123'
|
||||
}
|
||||
end
|
||||
|
||||
describe 'GET /twilio/callback' do
|
||||
it 'calls incoming message service' do
|
||||
post twilio_callback_index_url, params: {}
|
||||
expect(twilio_service).to have_received(:perform)
|
||||
it 'enqueues the Twilio events job' do
|
||||
expect do
|
||||
post twilio_callback_index_url, params: params
|
||||
end.to have_enqueued_job(Webhooks::TwilioEventsJob).with(params)
|
||||
end
|
||||
|
||||
it 'returns no content status' do
|
||||
post twilio_callback_index_url, params: params
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,17 +2,25 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Twilio::DeliveryStatusController', type: :request do
|
||||
include Rails.application.routes.url_helpers
|
||||
let(:twilio_service) { instance_double(Twilio::DeliveryStatusService) }
|
||||
|
||||
before do
|
||||
allow(Twilio::DeliveryStatusService).to receive(:new).and_return(twilio_service)
|
||||
allow(twilio_service).to receive(:perform)
|
||||
end
|
||||
describe 'POST /twilio/delivery_status' do
|
||||
let(:params) do
|
||||
{
|
||||
'MessageSid' => 'SM123',
|
||||
'MessageStatus' => 'delivered',
|
||||
'AccountSid' => 'AC123'
|
||||
}
|
||||
end
|
||||
|
||||
describe 'POST /twilio/delivery' do
|
||||
it 'calls incoming message service' do
|
||||
post twilio_delivery_status_index_url, params: {}
|
||||
expect(twilio_service).to have_received(:perform)
|
||||
it 'enqueues the Twilio delivery status job' do
|
||||
expect do
|
||||
post twilio_delivery_status_index_url, params: params
|
||||
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob).with(params)
|
||||
end
|
||||
|
||||
it 'returns no content status' do
|
||||
post twilio_delivery_status_index_url, params: params
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Webhooks::TwilioDeliveryStatusJob do
|
||||
subject(:job) { described_class.perform_later(params) }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
'MessageSid' => 'SM123',
|
||||
'MessageStatus' => 'delivered',
|
||||
'AccountSid' => 'AC123'
|
||||
}
|
||||
end
|
||||
|
||||
it 'queues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(params)
|
||||
.on_queue('low')
|
||||
end
|
||||
|
||||
it 'calls the Twilio::DeliveryStatusService' do
|
||||
service = double
|
||||
expect(Twilio::DeliveryStatusService).to receive(:new).with(params: params).and_return(service)
|
||||
expect(service).to receive(:perform)
|
||||
described_class.new.perform(params)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Webhooks::TwilioEventsJob do
|
||||
subject(:job) { described_class.perform_later(params) }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
From: '+1234567890',
|
||||
To: '+0987654321',
|
||||
Body: 'Test message',
|
||||
AccountSid: 'AC123',
|
||||
SmsSid: 'SM123'
|
||||
}
|
||||
end
|
||||
|
||||
it 'queues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(params)
|
||||
.on_queue('low')
|
||||
end
|
||||
|
||||
it 'calls the Twilio::IncomingMessageService' do
|
||||
service = double
|
||||
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params).and_return(service)
|
||||
expect(service).to receive(:perform)
|
||||
described_class.perform_now(params)
|
||||
end
|
||||
|
||||
context 'when Body parameter or MediaUrl0 is not present' do
|
||||
let(:params_without_body) do
|
||||
{
|
||||
From: '+1234567890',
|
||||
To: '+0987654321',
|
||||
AccountSid: 'AC123',
|
||||
SmsSid: 'SM123'
|
||||
}
|
||||
end
|
||||
|
||||
it 'does not process the event' do
|
||||
expect(Twilio::IncomingMessageService).not_to receive(:new)
|
||||
described_class.perform_now(params_without_body)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Body parameter is present' do
|
||||
let(:params_with_body) do
|
||||
{
|
||||
From: '+1234567890',
|
||||
To: '+0987654321',
|
||||
Body: 'Test message',
|
||||
AccountSid: 'AC123',
|
||||
SmsSid: 'SM123'
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes the event' do
|
||||
service = double
|
||||
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_body).and_return(service)
|
||||
expect(service).to receive(:perform)
|
||||
described_class.perform_now(params_with_body)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when MediaUrl0 parameter is present' do
|
||||
let(:params_with_media) do
|
||||
{
|
||||
From: '+1234567890',
|
||||
To: '+0987654321',
|
||||
MediaUrl0: 'https://example.com/media.jpg',
|
||||
AccountSid: 'AC123',
|
||||
SmsSid: 'SM123'
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes the event' do
|
||||
service = double
|
||||
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_media).and_return(service)
|
||||
expect(service).to receive(:perform)
|
||||
described_class.perform_now(params_with_media)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -121,9 +121,7 @@ describe ActionCableListener do
|
||||
|
||||
it 'sends message to account admins, inbox agents' do
|
||||
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
||||
a_collection_containing_exactly(
|
||||
agent.pubsub_token, admin.pubsub_token
|
||||
),
|
||||
["account_#{account.id}"],
|
||||
'contact.deleted',
|
||||
contact.push_event_data.merge(account_id: account.id)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user