diff --git a/app/channels/room_channel.rb b/app/channels/room_channel.rb
index 0680f1458..629078229 100644
--- a/app/channels/room_channel.rb
+++ b/app/channels/room_channel.rb
@@ -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
diff --git a/app/controllers/api/v1/widget/campaigns_controller.rb b/app/controllers/api/v1/widget/campaigns_controller.rb
index cb9b96a38..10a73aa62 100644
--- a/app/controllers/api/v1/widget/campaigns_controller.rb
+++ b/app/controllers/api/v1/widget/campaigns_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/widget/inbox_members_controller.rb b/app/controllers/api/v1/widget/inbox_members_controller.rb
index c4bc377ea..22934388b 100644
--- a/app/controllers/api/v1/widget/inbox_members_controller.rb
+++ b/app/controllers/api/v1/widget/inbox_members_controller.rb
@@ -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
diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb
index f0fcf403d..d07dbcb9d 100644
--- a/app/controllers/public/api/v1/portals/articles_controller.rb
+++ b/app/controllers/public/api/v1/portals/articles_controller.rb
@@ -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
diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb
index 7723e5dd2..ff5db952d 100644
--- a/app/controllers/twilio/callback_controller.rb
+++ b/app/controllers/twilio/callback_controller.rb
@@ -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
diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb
index cc7afb0fc..1c756a1c2 100644
--- a/app/controllers/twilio/delivery_status_controller.rb
+++ b/app/controllers/twilio/delivery_status_controller.rb
@@ -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
diff --git a/app/javascript/dashboard/composables/useAutomationValues.js b/app/javascript/dashboard/composables/useAutomationValues.js
index 88568fee5..4df46be8f 100644
--- a/app/javascript/dashboard/composables/useAutomationValues.js
+++ b/app/javascript/dashboard/composables/useAutomationValues.js
@@ -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,
});
};
diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js
index 6ff941822..07186c122 100644
--- a/app/javascript/dashboard/helper/automationHelper.js
+++ b/app/javascript/dashboard/helper/automationHelper.js
@@ -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'),
diff --git a/app/javascript/dashboard/helper/specs/automationHelper.spec.js b/app/javascript/dashboard/helper/specs/automationHelper.spec.js
index b55da1bad..10088963a 100644
--- a/app/javascript/dashboard/helper/specs/automationHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/automationHelper.spec.js
@@ -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', () => {
diff --git a/app/javascript/dashboard/i18n/locale/en/agentBots.json b/app/javascript/dashboard/i18n/locale/en/agentBots.json
index fb744b4a9..41b8fcb1b 100644
--- a/app/javascript/dashboard/i18n/locale/en/agentBots.json
+++ b/app/javascript/dashboard/i18n/locale/en/agentBots.json
@@ -2,8 +2,8 @@
"AGENT_BOTS": {
"HEADER": "Bots",
"LOADING_EDITOR": "Loading editor...",
- "HEADER_BTN_TXT": "Add bot configuration",
- "SIDEBAR_TXT": "
Agent Bots
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 'Add bot configuraton' button.
Open the Agent bots handbook in another tab for a helping hand.
",
+ "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",
diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json
index 483965f53..bb4946416 100644
--- a/app/javascript/dashboard/i18n/locale/en/automation.json
+++ b/app/javascript/dashboard/i18n/locale/en/automation.json
@@ -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"
}
}
diff --git a/app/javascript/dashboard/i18n/locale/en/integrationApps.json b/app/javascript/dashboard/i18n/locale/en/integrationApps.json
index 9404e12c2..b91b434f7 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrationApps.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrationApps.json
@@ -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.
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.
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"
}
}
}
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue b/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue
index 01fbe9b71..4f0cf6340 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue
@@ -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"
diff --git a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue
index f5ba2b4eb..59cf178ba 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue
@@ -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
+);
-import { mapGetters } from 'vuex';
+
-
-
-
-
-
-
+
+
+
+
+
+ {{ $t('AGENT_BOTS.ADD.TITLE') }}
+
+
+
+
+
+
+
-
- {{ $t('AGENT_BOTS.LIST.404') }}
-
+
-
-
-
-
-
- {{ $t('AGENT_BOTS.ADD.TITLE') }}
-
-
-
-
+
+
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js b/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js
index b3942d0fe..d83335d7e 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js
@@ -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',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotRow.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotRow.vue
index 23079d2e3..f96ed8018 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotRow.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotRow.vue
@@ -1,81 +1,58 @@
-
- |
- |
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotType.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotType.vue
index ac3ac90b9..b207e8637 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotType.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotType.vue
@@ -1,43 +1,36 @@
-
{{ botTypeConfig[botType].label }}
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue
index 792e06b4d..0dc4c3372 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue
@@ -108,22 +108,13 @@ export default {
-
-
- {{ $t('INTEGRATION_APPS.ADD_BUTTON') }}
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/MultipleIntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/MultipleIntegrationHooks.vue
index 8b8967089..ee472789b 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/MultipleIntegrationHooks.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/MultipleIntegrationHooks.vue
@@ -1,14 +1,23 @@
-
-
+
+
+
+
+ {{ $t('INTEGRATION_APPS.ADD_BUTTON') }}
+
+
+
+
- |
+ |
{{ hookHeader }}
|
@@ -61,7 +96,7 @@ export default {
|
{{ property }}
|
@@ -90,18 +125,5 @@ export default {
}}
-
-
- {{ integration.name }}
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
index dca2a19ed..f302f36ab 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
@@ -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'
diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js
index e6ada5914..b595fdf00 100755
--- a/app/javascript/widget/api/endPoints.js
+++ b/app/javascript/widget/api/endPoints.js
@@ -103,6 +103,7 @@ const getMostReadArticles = (slug, locale) => ({
page: 1,
sort: 'views',
status: 1,
+ per_page: 6,
},
});
diff --git a/app/javascript/widget/store/modules/campaign.js b/app/javascript/widget/store/modules/campaign.js
index 317c64148..ad2822dd9 100644
--- a/app/javascript/widget/store/modules/campaign.js
+++ b/app/javascript/widget/store/modules/campaign.js
@@ -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;
diff --git a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js
index ca4a9736b..c24490171 100644
--- a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js
+++ b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js
@@ -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([]);
diff --git a/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js b/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js
index 28efb3146..9554a9d6b 100644
--- a/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js
+++ b/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js
@@ -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);
});
});
diff --git a/app/jobs/webhooks/twilio_delivery_status_job.rb b/app/jobs/webhooks/twilio_delivery_status_job.rb
new file mode 100644
index 000000000..d57946b4c
--- /dev/null
+++ b/app/jobs/webhooks/twilio_delivery_status_job.rb
@@ -0,0 +1,7 @@
+class Webhooks::TwilioDeliveryStatusJob < ApplicationJob
+ queue_as :low
+
+ def perform(params = {})
+ ::Twilio::DeliveryStatusService.new(params: params).perform
+ end
+end
diff --git a/app/jobs/webhooks/twilio_events_job.rb b/app/jobs/webhooks/twilio_events_job.rb
new file mode 100644
index 000000000..5f44d981b
--- /dev/null
+++ b/app/jobs/webhooks/twilio_events_job.rb
@@ -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
diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb
index 99a8fe2af..37dc939e7 100644
--- a/app/listeners/action_cable_listener.rb
+++ b/app/listeners/action_cable_listener.rb
@@ -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]
diff --git a/app/models/article.rb b/app/models/article.rb
index ace793016..48e0529a2 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -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
diff --git a/app/views/public/api/v1/models/_category.json.jbuilder b/app/views/public/api/v1/models/_category.json.jbuilder
index 915914e57..15dc61668 100644
--- a/app/views/public/api/v1/models/_category.json.jbuilder
+++ b/app/views/public/api/v1/models/_category.json.jbuilder
@@ -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
diff --git a/app/views/public/api/v1/portals/articles/index.json.jbuilder b/app/views/public/api/v1/portals/articles/index.json.jbuilder
index 1927d5a09..43a9a173a 100644
--- a/app/views/public/api/v1/portals/articles/index.json.jbuilder
+++ b/app/views/public/api/v1/portals/articles/index.json.jbuilder
@@ -4,5 +4,5 @@ json.payload do
end
json.meta do
- json.articles_count @articles.published.size
+ json.articles_count @articles_count
end
diff --git a/app/views/widget_tests/index.html.erb b/app/views/widget_tests/index.html.erb
index cfc084384..9471c44c2 100644
--- a/app/views/widget_tests/index.html.erb
+++ b/app/views/widget_tests/index.html.erb
@@ -1,5 +1,6 @@
-
+
+
<%
user_id = 1
diff --git a/config/database.yml b/config/database.yml
index 5cd278f8d..0577e3ee6 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -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" %>
diff --git a/db/migrate/20250315202035_add_index_to_articles.rb b/db/migrate/20250315202035_add_index_to_articles.rb
new file mode 100644
index 000000000..8b26344de
--- /dev/null
+++ b/db/migrate/20250315202035_add_index_to_articles.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index a68593c68..0818d1117 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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|
diff --git a/spec/channels/room_channel_spec.rb b/spec/channels/room_channel_spec.rb
index 362a3d405..b39725f04 100644
--- a/spec/channels/room_channel_spec.rb
+++ b/spec/channels/room_channel_spec.rb
@@ -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
diff --git a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
index 249d1e78a..08c5206c1 100644
--- a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
+++ b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
@@ -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
diff --git a/spec/controllers/twilio/callbacks_controller_spec.rb b/spec/controllers/twilio/callbacks_controller_spec.rb
index bf975be6b..d16acf229 100644
--- a/spec/controllers/twilio/callbacks_controller_spec.rb
+++ b/spec/controllers/twilio/callbacks_controller_spec.rb
@@ -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
diff --git a/spec/controllers/twilio/delivery_status_controller_spec.rb b/spec/controllers/twilio/delivery_status_controller_spec.rb
index 05c236259..fc21f8f94 100644
--- a/spec/controllers/twilio/delivery_status_controller_spec.rb
+++ b/spec/controllers/twilio/delivery_status_controller_spec.rb
@@ -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
diff --git a/spec/jobs/webhooks/twilio_delivery_status_job_spec.rb b/spec/jobs/webhooks/twilio_delivery_status_job_spec.rb
new file mode 100644
index 000000000..dc94169ae
--- /dev/null
+++ b/spec/jobs/webhooks/twilio_delivery_status_job_spec.rb
@@ -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
diff --git a/spec/jobs/webhooks/twilio_events_job_spec.rb b/spec/jobs/webhooks/twilio_events_job_spec.rb
new file mode 100644
index 000000000..f42caf675
--- /dev/null
+++ b/spec/jobs/webhooks/twilio_events_job_spec.rb
@@ -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
diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb
index b353f330d..55b74116c 100644
--- a/spec/listeners/action_cable_listener_spec.rb
+++ b/spec/listeners/action_cable_listener_spec.rb
@@ -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)
)