Merge branch 'develop' into chore/update-rails

This commit is contained in:
Sojan Jose
2025-03-18 01:29:19 -07:00
committed by GitHub
42 changed files with 576 additions and 310 deletions
+10 -7
View File
@@ -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"
}
}
}
@@ -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',
@@ -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>
@@ -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>
@@ -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>
@@ -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'
+1
View File
@@ -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
+11
View File
@@ -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
+8 -12
View File
@@ -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]
+4
View File
@@ -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
+2 -1
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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|
+9
View File
@@ -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
+1 -3
View File
@@ -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)
)