Compare commits

...
Author SHA1 Message Date
Shivam Mishra 670ab2400a feat: disable matches filter 2025-03-27 16:31:52 +05:30
Shivam Mishra 4a800eb8d1 chore: get changes from dbc1b492 2025-03-27 16:23:12 +05:30
Shivam Mishra 1bf1075427 feat: disable get filtered conversations 2025-03-27 16:16:22 +05:30
PranavandGitHub 4b4d9f8f7c fix: Update throttle for /meta endpoints, will call every 2s for small account, 10s for large accounts (#11190)
This update improves the throttling mechanism for conversation meta
requests to optimize server load and enhance performance. The changes
implement differentiated thresholds based on account size - a 2-second
throttle for small accounts (≤100 conversations) and a 10-second
throttle for large accounts (>100 conversations).

Fixes #11178
2025-03-26 14:59:39 -07:00
PranavandGitHub 49ee147fe3 fix: Remove where query if admin (#11183)
When finding conversation if it is an admin, we don't need to filter it
by inbox ids.
2025-03-26 11:11:01 -07:00
Muhsin KelothandGitHub d9450fde4a feat: Added Instagram channel migration (#11181)
This PR is part of https://github.com/chatwoot/chatwoot/pull/11054 to
make the review cycle easier.
2025-03-26 11:12:32 +05:30
PettersonandGitHub 157ec00994 fix(i18n): correct settings name translation in pt-BR (#11172)
# Pull Request Template

## Description

In pt_BR language, some strings are being mispelled, the correct word
would be "Configurações" but instead it is being showed as
"Confirgurações", which is wrong.
Fixes: https://github.com/chatwoot/chatwoot/issues/11149

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)


## How Has This Been Tested?

I've compiled the new translations and I compared the problematic
strings in PT_BR to check if it was still with the "Confirgurações" word
instead of "Configurações"

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
2025-03-25 20:27:41 -07:00
Sivin VargheseandGitHub d797fe34fb fix: Support Business hours when downloading the agent reports.
# Pull Request Template

## Description

This PR fixes an issue where downloaded agent conversation reports from
the Conversations page under Reports do not respect business hours.

Fixes
https://linear.app/chatwoot/issue/CW-4139/downloaded-agent-reports-do-not-respect-business-hours
https://github.com/chatwoot/chatwoot/issues/11057

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/43b94494647b48c3855476a227b02acb?sid=d6072725-11e5-487c-8aa5-8ecfae6dc818


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-03-25 20:26:18 -07:00
Muhsin KelothandGitHub 8826a7066c chore: Remove delete Instagram story implementation (#11097)
Currently, Instagram stories are downloaded and displayed as
attachments. We previously implemented story deletion by checking
individual messages via
https://github.com/chatwoot/chatwoot/pull/5300/files#diff-684a16c1b0b4c099fcdfeed95b1820e11fef629fe332ec7ce6a8c600331dd06dR110,
but this approach proved impractical and was removed. This PR removes
all unused code to avoid confusion. We will revisit the story deletion
feature when we implement `instagram_manage_insights`.
2025-03-25 09:44:26 +05:30
50efd28d16 feat: Add support for frontend filtering of conversations (#11111)
This pull request includes significant changes to the filtering logic
for conversations in the frontend, here's a summary of the changes

This includes adding a `matchesFilters` method that evaluates a
conversation against the applied filters. It does so by first evaluating
all the conditions, and later converting the results into a JSONLogic
object that can be evaluated according to Postgres operator precedence

### Alignment Specs

To ensure the frontend and backend implementations always align, we've
added tests on both sides with same cases, for anyone fixing any
regressions found in the frontend implementation, they need to ensure
the existing tests always pass.

Test Case | JavaScript Spec | Ruby Spec | Match?
-- | -- | -- | --
**A AND B OR C** | Present | Present | Yes
Matches when all conditions are true | Present | Present | Yes
Matches when first condition is false but third is true | Present |
Present | Yes
Matches when first and second conditions are false but third is true |
Present | Present | Yes
Does not match when all conditions are false | Present | Present | Yes
**A OR B AND C** | Present | Present | Yes
Matches when first condition is true | Present | Present | Yes
Matches when second and third conditions are true | Present | Present |
Yes
**A AND B OR C AND D** | Present | Present | Yes
Matches when first two conditions are true | Present | Present | Yes
Matches when last two conditions are true | Present | Present | Yes
**Mixed Operators (A AND (B OR C) AND D)** | Present | Present | Yes
Matches when all conditions in the chain are true | Present | Present |
Yes
Does not match when the last condition is false | Present | Present |
Yes

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-03-24 19:39:04 -07:00
41d6f9a200 chore: Add cache to improve widget performance (#11163)
- Add dynamic importing for routes.
- Added caching for `campaign`, `articles` and `inbox_members` API end
points.

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-03-24 16:04:49 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Shivam Mishra
07d19362d2 chore(deps): Bump nokogiri from 1.18.3 to 1.18.4 (#11153)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.18.3
to 1.18.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sparklemotion/nokogiri/releases">nokogiri's
releases</a>.</em></p>
<blockquote>
<h2>v1.18.4 / 2025-03-14</h2>
<h3>Security</h3>
<ul>
<li>[CRuby] Vendored libxslt is updated to <a
href="https://gitlab.gnome.org/GNOME/libxslt/-/releases/v1.1.43">v1.1.43</a>
to address CVE-2025-24855 and CVE-2024-55549. See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-mrxw-mxhj-p664">GHSA-mrxw-mxhj-p664</a>
for more information.</li>
</ul>
<!-- raw HTML omitted -->

<pre><code>8f2263cef9953ce09bd5293d76c9bbd3013d2f94d1cca67783dfe6635c529deb
nokogiri-1.18.4-aarch64-linux-gnu.gem
4e231f8ba3128cfc2ef0cc0bdc807d7ce71fc62cb6a78216e817be8631fe6a96
nokogiri-1.18.4-aarch64-linux-musl.gem
73902663b23b1123282b9c0b6d9654b1fb286dfee8d65cb1f6029087b7f0d037
nokogiri-1.18.4-arm64-darwin.gem
cc2945e2c19560a61a97737e6bd3b329edb1f82ca204d46a18e5e98ad0a550a6
nokogiri-1.18.4-arm-linux-gnu.gem
4fb7f44de0cd85abfa869e4cfb619410da174ebf9fbe26ae0caa65462b818bcb
nokogiri-1.18.4-arm-linux-musl.gem
bb7820521c1bbae1d3e0092ff03b27a8e700912b37d80f962b7e4567947a64ac
nokogiri-1.18.4.gem
cbc0bab72eb5a9573efa7b98351fdd44c609e8d4585456ca1be18db2b7764b64
nokogiri-1.18.4-java.gem
bd567cb509eb75de8f27ca6ecaf4a38bf0563482188991f9bcccccac9c3b9a2f
nokogiri-1.18.4-x64-mingw-ucrt.gem
e4776f58eea9b94d05caf8bf351e3c6aa1cce01edcc2ed530f3c302c13178965
nokogiri-1.18.4-x86_64-darwin.gem
b1c6407b346b88704e97a342a80acd4755175324e624da34d0c5cfdc8d34191e
nokogiri-1.18.4-x86_64-linux-gnu.gem
ea7c0356a70f3d2d0d76315b533877013d20368d5c9f437c38e0bd462c4844dc
nokogiri-1.18.4-x86_64-linux-musl.gem
</code></pre>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md">nokogiri's
changelog</a>.</em></p>
<blockquote>
<h2>v1.18.4 / 2025-03-14</h2>
<h3>Security</h3>
<ul>
<li>[CRuby] Vendored libxslt is updated to <a
href="https://gitlab.gnome.org/GNOME/libxslt/-/releases/v1.1.43">v1.1.43</a>
to address CVE-2025-24855 and CVE-2024-55549. See <a
href="https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-mrxw-mxhj-p664">GHSA-mrxw-mxhj-p664</a>
for more information.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/80edf1c35d79eae3a2fd7315f1fb61831ca3c94a"><code>80edf1c</code></a>
version bump to v1.18.4</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/3c12e357632043725bb7dde0e8a9992d08c66b57"><code>3c12e35</code></a>
dep: update libxslt to 1.1.43 (v1.18.x branch) (<a
href="https://redirect.github.com/sparklemotion/nokogiri/issues/3467">#3467</a>)</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/395c085edd7bd00bfb6b36b23a0ff2f9b8e29d30"><code>395c085</code></a>
dep: update libxslt to 1.1.43</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/41257151137222136b4af1faa75ba9bf2fbd5ad2"><code>4125715</code></a>
ci: tired of waiting for gnome mirrors</li>
<li><a
href="https://github.com/sparklemotion/nokogiri/commit/35faaa3fcb4a726bfb8d5e5475b89247909fa0a2"><code>35faaa3</code></a>
doc: update CHANGELOG with GHSA</li>
<li>See full diff in <a
href="https://github.com/sparklemotion/nokogiri/compare/v1.18.3...v1.18.4">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nokogiri&package-manager=bundler&previous-version=1.18.3&new-version=1.18.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-03-24 13:55:45 +05:30
Sojan abf69c5134 Merge branch 'release/4.0.4' into develop 2025-03-21 18:55:14 -07:00
53 changed files with 2777 additions and 506 deletions
+4 -4
View File
@@ -501,14 +501,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.18.3)
nokogiri (1.18.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.3-arm64-darwin)
nokogiri (1.18.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-darwin)
nokogiri (1.18.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-gnu)
nokogiri (1.18.4-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
+45 -6
View File
@@ -12,11 +12,50 @@ class ContactInboxBuilder
private
def generate_source_id
ContactInbox::SourceIdService.new(
contact: @contact,
channel_type: @inbox.channel_type,
medium: @inbox.channel.try(:medium)
).generate
case @inbox.channel_type
when 'Channel::TwilioSms'
twilio_source_id
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
email_source_id
when 'Channel::Sms'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
end
end
def email_source_id
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
@contact.email
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
def wa_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
@contact.phone_number.delete('+').to_s
end
def twilio_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
case @inbox.channel.medium
when 'sms'
@contact.phone_number
when 'whatsapp'
"whatsapp:#{@contact.phone_number}"
end
end
def create_contact_inbox
@@ -52,7 +91,7 @@ class ContactInboxBuilder
def new_source_id
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
"#{@source_id}#{rand(100)}"
"whatsapp:#{@source_id}#{rand(100)}"
else
"#{rand(10)}#{@source_id}"
end
@@ -9,8 +9,6 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
+3 -1
View File
@@ -32,6 +32,7 @@ class ConversationFinder
def initialize(current_user, params)
@current_user = current_user
@current_account = current_user.account
@is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator?
@params = params
end
@@ -86,7 +87,8 @@ class ConversationFinder
end
def find_all_conversations
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
@conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless @is_admin
filter_by_conversation_type if params[:conversation_type]
@conversations
end
@@ -25,11 +25,6 @@ export const generateLabelForContactableInboxesList = ({
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
// Handled separately for Twilio Inbox where phone number is not mandatory.
// You can send message to a contact with Messaging Service Id.
if (!phoneNumber) {
return name;
}
return `${name} (${phoneNumber})`;
}
return name;
@@ -8,8 +8,8 @@ vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'Priority Inbox',
email: 'hello@example.com',
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
};
@@ -19,7 +19,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('Priority Inbox (hello@example.com)');
).toBe('John Doe (john@example.com)');
});
it('generates label for twilio inbox', () => {
@@ -28,14 +28,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('Priority Inbox (+1234567890)');
expect(
helpers.generateLabelForContactableInboxesList({
name: 'Priority Inbox',
channelType: INBOX_TYPES.TWILIO,
})
).toBe('Priority Inbox');
).toBe('John Doe (+1234567890)');
});
it('generates label for whatsapp inbox', () => {
@@ -44,7 +37,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('Priority Inbox (+1234567890)');
).toBe('John Doe (+1234567890)');
});
it('generates label for other inbox types', () => {
@@ -53,7 +46,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: 'Channel::Api',
})
).toBe('Priority Inbox');
).toBe('John Doe');
});
});
@@ -61,6 +61,7 @@ import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
// import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
@@ -324,6 +325,14 @@ const conversationList = computed(() => {
} else {
localConversationList = [...chatLists.value];
}
// if (activeFolder.value) {
// const { payload } = activeFolder.value.query;
// localConversationList = localConversationList.filter(conversation => {
// return matchesFilters(conversation, payload);
// });
// }
return localConversationList;
});
@@ -460,6 +469,12 @@ function setParamsForEditFolderModal() {
campaigns: campaigns.value,
languages: languages,
countries: countries,
priority: [
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
],
filterTypes: advancedFilterTypes.value,
allCustomAttributes: conversationCustomAttributes.value,
};
@@ -22,6 +22,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'priority',
attributeI18nKey: 'PRIORITY',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'inbox_id',
attributeI18nKey: 'INBOX_NAME',
@@ -0,0 +1,29 @@
class ConversationMetaThrottleManager {
constructor() {
this.lastUpdatedTime = null;
}
shouldThrottle(threshold = 10000) {
if (!this.lastUpdatedTime) {
return false;
}
const currentTime = new Date().getTime();
const lastUpdatedTime = new Date(this.lastUpdatedTime).getTime();
if (currentTime - lastUpdatedTime < threshold) {
return true;
}
return false;
}
markUpdate() {
this.lastUpdatedTime = new Date();
}
reset() {
this.lastUpdatedTime = null;
}
}
export default new ConversationMetaThrottleManager();
@@ -21,6 +21,7 @@ export const getAttributeInputType = (key, allCustomAttributes) => {
const customAttribute = allCustomAttributes.find(
attr => attr.attribute_key === key
);
const { attribute_display_type } = customAttribute;
const filterInputTypes = generateCustomAttributesInputType(
attribute_display_type
@@ -68,10 +69,22 @@ const getValuesForCountries = (values, countries) => {
}));
};
const getValuesForPriority = (values, priority) => {
return priority.filter(option => values.includes(option.id));
};
export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter;
const { languages, countries, agents, inboxes, teams, campaigns, labels } =
params;
const {
languages,
countries,
agents,
inboxes,
teams,
campaigns,
labels,
priority,
} = params;
switch (attribute_key) {
case 'status':
return getValuesForStatus(values);
@@ -83,15 +96,14 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, teams, 'id', 'name');
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels': {
case 'labels':
return getValuesForLabels(values, labels);
}
case 'browser_language': {
case 'priority':
return getValuesForPriority(values, priority);
case 'browser_language':
return getValuesForLanguages(values, languages);
}
case 'country_code': {
case 'country_code':
return getValuesForCountries(values, countries);
}
default:
return { id: values[0], name: values[0] };
}
@@ -100,9 +112,9 @@ export const getValuesForFilter = (filter, params) => {
export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params;
const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
const inputType = getInputType(attribute_key, filter_operator, filterTypes);
if (inboxType === undefined) {
if (inputType === undefined) {
const filterInputTypes = getAttributeInputType(
attribute_key,
allCustomAttributes
@@ -112,7 +124,7 @@ export const generateValuesForEditCustomViews = (filter, params) => {
: { id: values[0], name: values[0] };
}
return inboxType === 'multi_select' || inboxType === 'search_select'
return inputType === 'multi_select' || inputType === 'search_select'
? getValuesForFilter(filter, params)
: values[0].toString();
};
@@ -0,0 +1,34 @@
import ConversationMetaThrottleManager from '../ConversationMetaThrottleManager';
describe('ConversationMetaThrottleManager', () => {
beforeEach(() => {
// Reset the lastUpdatedTime before each test
ConversationMetaThrottleManager.lastUpdatedTime = null;
});
describe('shouldThrottle', () => {
it('returns false when lastUpdatedTime is not set', () => {
expect(ConversationMetaThrottleManager.shouldThrottle()).toBe(false);
});
it('returns true when time difference is less than threshold', () => {
ConversationMetaThrottleManager.markUpdate();
expect(ConversationMetaThrottleManager.shouldThrottle()).toBe(true);
});
it('returns false when time difference is more than threshold', () => {
ConversationMetaThrottleManager.lastUpdatedTime = new Date(
Date.now() - 11000
);
expect(ConversationMetaThrottleManager.shouldThrottle()).toBe(false);
});
it('respects custom threshold value', () => {
ConversationMetaThrottleManager.lastUpdatedTime = new Date(
Date.now() - 5000
);
expect(ConversationMetaThrottleManager.shouldThrottle(3000)).toBe(false);
expect(ConversationMetaThrottleManager.shouldThrottle(6000)).toBe(true);
});
});
});
@@ -100,7 +100,7 @@
"NO": "cancelar"
}
},
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"FORM": {
"UPDATE": "Atualizar a equipa",
"CREATE": "Criar uma equipa",
@@ -9,7 +9,7 @@
"FILTER": "Filtrar por",
"SORT": "Classificar por",
"LOCALE": "Localidade",
"SETTINGS_BUTTON": "Confirgurações",
"SETTINGS_BUTTON": "Configurações",
"NEW_BUTTON": "Novo artigo",
"DROPDOWN_OPTIONS": {
"PUBLISHED": "Publicado",
@@ -121,7 +121,7 @@
"COUNT_LABEL": "artigos",
"ADD": "Adicionar localidade",
"VISIT": "Visitar site",
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"DELETE": "Excluir"
},
"PORTAL_CONFIG": {
@@ -473,7 +473,7 @@
"WIDGET_BUILDER": "Construtor de Widget",
"BOT_CONFIGURATION": "Configuração do Bot"
},
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"FEATURES": {
"LABEL": "Funcionalidades",
"DISPLAY_FILE_PICKER": "Exibir seletor de arquivos no widget",
@@ -333,7 +333,7 @@
"ARTICLES": "Artigos",
"CATEGORIES": "Categorias",
"LOCALES": "Localidades",
"SETTINGS": "Confirgurações"
"SETTINGS": "Configurações"
},
"CHANNELS": "Canais",
"SET_AUTO_OFFLINE": {
@@ -100,7 +100,7 @@
"NO": "Cancelar"
}
},
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"FORM": {
"UPDATE": "Atualizar equipe",
"CREATE": "Criar nova equipe",
@@ -1,11 +1,10 @@
<script>
import V4Button from 'dashboard/components-next/button/Button.vue';
import { useAlert, useTrack } from 'dashboard/composables';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import ReportFilterSelector from './components/FilterSelector.vue';
import { GROUP_BY_FILTER } from './constants';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { generateFileName } from 'dashboard/helper/downloadHelper';
import ReportContainer from './ReportContainer.vue';
import ReportHeader from './components/ReportHeader.vue';
@@ -79,11 +78,17 @@ export default {
},
downloadAgentReports() {
const { from, to } = this;
const fileName = `agent-report-${format(
fromUnixTime(to),
'dd-MM-yyyy'
)}.csv`;
this.$store.dispatch('downloadAgentReports', { from, to, fileName });
const fileName = generateFileName({
type: 'agent',
to,
businessHours: this.businessHours,
});
this.$store.dispatch('downloadAgentReports', {
from,
to,
fileName,
businessHours: this.businessHours,
});
},
onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from;
@@ -1,28 +1,40 @@
import types from '../mutation-types';
import ConversationApi from '../../api/inbox/conversation';
import ConversationMetaThrottleManager from 'dashboard/helper/ConversationMetaThrottleManager';
const state = {
mineCount: 0,
unAssignedCount: 0,
allCount: 0,
updatedOn: null,
};
export const getters = {
getStats: $state => $state,
};
export const shouldThrottle = conversationCount => {
// The threshold for throttling is different for normal users and large accounts
// Normal users: 2 seconds
// Large accounts: 10 seconds
// We would only update the conversation stats based on the threshold above.
// This is done to reduce the number of /meta request made to the server.
const NORMAL_USER_THRESHOLD = 500;
const LARGE_ACCOUNT_THRESHOLD = 10000;
const threshold =
conversationCount > 100 ? LARGE_ACCOUNT_THRESHOLD : NORMAL_USER_THRESHOLD;
return ConversationMetaThrottleManager.shouldThrottle(threshold);
};
export const actions = {
get: async ({ commit, state: $state }, params) => {
const currentTime = new Date();
const lastUpdatedTime = new Date($state.updatedOn);
// Skip large accounts from making too many requests
if (currentTime - lastUpdatedTime < 10000 && $state.allCount > 100) {
if (shouldThrottle($state.allCount)) {
// eslint-disable-next-line no-console
console.warn('Skipping conversation meta fetch');
console.warn('Throttle /meta fetch, will resume after threshold');
return;
}
ConversationMetaThrottleManager.markUpdate();
try {
const response = await ConversationApi.meta(params);
@@ -1,6 +1,7 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { applyPageFilters, sortComparator } from './helpers';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import { matchesFilters } from './helpers/filterHelpers';
import camelcaseKeys from 'camelcase-keys';
export const getSelectedChatConversation = ({
@@ -13,6 +14,15 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
getFilteredConversations: ({
allConversations,
chatSortFilter,
appliedFilters,
}) => {
return allConversations
.filter(conversation => matchesFilters(conversation, appliedFilters))
.sort((a, b) => sortComparator(a, b, chatSortFilter));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
const selectedChat = allConversations.find(
conversation => conversation.id === selectedChatId
@@ -0,0 +1,356 @@
/**
* Conversation Filter Helpers
* ---------------------------
* This file contains helper functions for filtering conversations in the frontend.
* The filtering logic is designed to align with the backend SQL behavior to ensure
* consistent results across the application.
*
* Key components:
* 1. getValueFromConversation: Retrieves values from conversation objects, handling
* both top-level properties and nested attributes.
* 2. matchesCondition: Evaluates a single filter condition against a value.
* 3. matchesFilters: Evaluates a complete filter chain against a conversation.
* 4. buildJsonLogicRule: Transforms evaluated filters into a JSON Logic frule that
* respects SQL-like operator precedence.
*
* Filter Structure:
* -----------------
* Each filter has the following structure:
* {
* attributeKey: 'status', // The attribute to filter on
* filterOperator: 'equal_to', // The operator to use (equal_to, contains, etc.)
* values: ['open'], // The values to compare against
* queryOperator: 'and' // How this filter connects to the next one (and/or)
* }
*
* Operator Precedence:
* --------------------
* The filter evaluation respects SQL-like operator precedence using JSON Logic:
* https://www.postgresql.org/docs/17/sql-syntax-lexical.html#SQL-PRECEDENCE
* 1. First evaluates individual conditions
* 2. Then applies AND operators (groups consecutive AND conditions)
* 3. Finally applies OR operators (connects AND groups with OR operations)
*
* This means that a filter chain like "A AND B OR C" is evaluated as "(A AND B) OR C",
* and "A OR B AND C" is evaluated as "A OR (B AND C)".
*
* The implementation uses json-logic-js to apply these rules. The JsonLogic format is designed
* to allow you to share rules (logic) between front-end and back-end code
* Here we use json-logic-js to transform filter conditions into a nested JSON Logic structure that preserves proper
* operator precedence, effectively mimicking SQL-like operator precedence.
*
* Conversation Object Structure:
* -----------------------------
* The conversation object can have:
* 1. Top-level properties (status, priority, display_id, etc.)
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import jsonLogic from 'json-logic-js';
/**
* Gets a value from a conversation based on the attribute key
* @param {Object} conversation - The conversation object
* @param {String} attributeKey - The attribute key to get the value for
* @returns {*} - The value of the attribute
*
* This function handles various attribute locations:
* 1. Direct properties on the conversation object (status, priority, etc.)
* 2. Properties in conversation.additional_attributes (browser_language, referer, etc.)
* 3. Properties in conversation.custom_attributes (conversation_type, etc.)
*/
const getValueFromConversation = (conversation, attributeKey) => {
switch (attributeKey) {
case 'status':
case 'priority':
case 'display_id':
case 'labels':
case 'created_at':
case 'last_activity_at':
return conversation[attributeKey];
case 'assignee_id':
return conversation.meta?.assignee?.id;
case 'inbox_id':
return conversation.inbox_id;
case 'team_id':
return conversation.meta?.team?.id;
case 'browser_language':
case 'country_code':
case 'referer':
return conversation.additional_attributes?.[attributeKey];
default:
// Check if it's a custom attribute
if (
conversation.custom_attributes &&
conversation.custom_attributes[attributeKey]
) {
return conversation.custom_attributes[attributeKey];
}
return null;
}
};
/**
* Resolves the value from an input candidate
* @param {*} candidate - The input value to resolve
* @returns {*} - If the candidate is an object with an id property, returns the id;
* otherwise returns the candidate unchanged
*
* This helper function is used to normalize values, particularly when dealing with
* objects that represent entities like users, teams, or inboxes where we want to
* compare by ID rather than by the whole object.
*/
const resolveValue = candidate => {
if (
typeof candidate === 'object' &&
candidate !== null &&
'id' in candidate
) {
return candidate.id;
}
return candidate;
};
/**
* Checks if two values are equal in the context of filtering
* @param {*} filterValue - The filterValue value
* @param {*} conversationValue - The conversationValue value
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
*
* This function handles various equality scenarios:
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison
*/
const equalTo = (filterValue, conversationValue) => {
if (Array.isArray(filterValue)) {
if (filterValue.includes('all')) return true;
if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) {
// For array values like labels, check if any of the filter values exist in the array
return filterValue.every(val => conversationValue.includes(val));
}
if (!Array.isArray(conversationValue)) {
return filterValue.includes(conversationValue);
}
}
return conversationValue === filterValue;
};
/**
* Checks if the filterValue value is contained within the conversationValue value
* @param {*} filterValue - The value to look for
* @param {*} conversationValue - The value to search within
* @returns {Boolean} - Returns true if filterValue is contained within conversationValue
*
* This function performs case-insensitive string containment checks.
* It only works with string values and returns false for non-string types.
*/
const contains = (filterValue, conversationValue) => {
if (typeof conversationValue === 'string') {
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
}
return false;
};
/**
* Checks if a value matches a filter condition
* @param {*} conversationValue - The value to check
* @param {Object} filter - The filter condition
* @returns {Boolean} - Returns true if the value matches the filter
*/
const matchesCondition = (conversationValue, filter) => {
const { filter_operator: filterOperator, values } = filter;
// Handle null/undefined values
if (conversationValue === null || conversationValue === undefined) {
return filterOperator === 'is_not_present';
}
const filterValue = Array.isArray(values)
? values.map(resolveValue)
: resolveValue(values);
switch (filterOperator) {
case 'equal_to':
return equalTo(filterValue, conversationValue);
case 'not_equal_to':
return !equalTo(filterValue, conversationValue);
case 'contains':
return contains(filterValue, conversationValue);
case 'does_not_contain':
return !contains(filterValue, conversationValue);
case 'is_present':
return true; // We already handled null/undefined above
case 'is_not_present':
return false; // We already handled null/undefined above
case 'is_greater_than':
return new Date(conversationValue) > new Date(filterValue);
case 'is_less_than':
return new Date(conversationValue) < new Date(filterValue);
case 'days_before': {
const today = new Date();
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
const targetDate = new Date(today.getTime() - daysInMilliseconds);
return conversationValue < targetDate.getTime();
}
default:
return false;
}
};
/**
* Converts an array of evaluated filters into a JSON Logic rule
* that respects SQL-like operator precedence (AND before OR)
*
* This function transforms the linear sequence of filter results and operators
* into a nested JSON Logic structure that correctly implements SQL-like precedence:
* - AND operators are evaluated before OR operators
* - Consecutive AND conditions are grouped together
* - These AND groups are then connected with OR operators
*
* For example:
* - "A AND B AND C" becomes { "and": [A, B, C] }
* - "A OR B OR C" becomes { "or": [A, B, C] }
* - "A AND B OR C" becomes { "or": [{ "and": [A, B] }, C] }
* - "A OR B AND C" becomes { "or": [A, { "and": [B, C] }] }
*
* FILTER CHAIN: A --AND--> B --OR--> C --AND--> D --AND--> E --OR--> F
* | | | | | |
* v v v v v v
* EVALUATED: true false true false true false
* \ / \ \ / /
* \ / \ \ / /
* \ / \ \ / /
* \ / \ \ / /
* \ / \ \ / /
* AND GROUPS: [true,false] [true,false,true] [false]
* | | |
* v v v
* JSON LOGIC: {"and":[true,false]} {"and":[true,false,true]} false
* \ | /
* \ | /
* \ | /
* \ | /
* \ | /
* FINAL RULE: {"or":[{"and":[true,false]},{"and":[true,false,true]},false]}
*
* {
* "or": [
* { "and": [true, false] },
* { "and": [true, false, true] },
* { "and": [false] }
* ]
* }
* @param {Array} evaluatedFilters - Array of evaluated filter conditions with results and operators
* @returns {Object} - JSON Logic rule
*/
const buildJsonLogicRule = evaluatedFilters => {
// Step 1: Group consecutive AND conditions into logical units
// This implements the higher precedence of AND over OR
const andGroups = [];
let currentAndGroup = [evaluatedFilters[0].result];
for (let i = 0; i < evaluatedFilters.length - 1; i += 1) {
if (evaluatedFilters[i].operator === 'and') {
// When we see an AND operator, we add the next filter to the current AND group
// This builds up chains of AND conditions that will be evaluated together
currentAndGroup.push(evaluatedFilters[i + 1].result);
} else {
// When we see an OR operator, it marks the boundary between AND groups
// We finalize the current AND group and start a new one
// If the AND group has only one item, don't wrap it in an "and" operator
// Otherwise, create a proper "and" JSON Logic expression
andGroups.push(
currentAndGroup.length === 1
? currentAndGroup[0] // Single item doesn't need an "and" wrapper
: { and: currentAndGroup } // Multiple items need to be AND-ed together
);
// Start a new AND group with the next filter's result
currentAndGroup = [evaluatedFilters[i + 1].result];
}
}
// Step 2: Add the final AND group that wasn't followed by an OR
if (currentAndGroup.length > 0) {
andGroups.push(
currentAndGroup.length === 1
? currentAndGroup[0] // Single item doesn't need an "and" wrapper
: { and: currentAndGroup } // Multiple items need to be AND-ed together
);
}
// Step 3: Combine all AND groups with OR operators
// If we have multiple AND groups, they are separated by OR operators
// in the original filter chain, so we combine them with an "or" operation
if (andGroups.length > 1) {
return { or: andGroups };
}
// If there's only one AND group (which might be a single condition
// or multiple AND-ed conditions), just return it directly
return andGroups[0];
};
/**
* Evaluates each filter against the conversation and prepares the results array
* @param {Object} conversation - The conversation to evaluate
* @param {Array} filters - Filters to apply
* @returns {Array} - Array of evaluated filter results with operators
*/
const evaluateFilters = (conversation, filters) => {
return filters.map((filter, index) => {
const value = getValueFromConversation(conversation, filter.attribute_key);
const result = matchesCondition(value, filter);
// This part determines the logical operator that connects this filter to the next one:
// - If this is not the last filter (index < filters.length - 1), use the filter's query_operator
// or default to 'and' if query_operator is not specified
// - If this is the last filter, set operator to null since there's no next filter to connect to
const isLastFilter = index === filters.length - 1;
const operator = isLastFilter ? null : filter.query_operator || 'and';
return { result, operator };
});
};
/**
* Checks if a conversation matches the given filters
* @param {Object} conversation - The conversation object to check
* @param {Array} filters - Array of filter conditions
* @returns {Boolean} - Returns true if conversation matches filters, false otherwise
*/
export const matchesFilters = (conversation, filters) => {
// If no filters, return true
if (!filters || filters.length === 0) {
return true;
}
// Handle single filter case
if (filters.length === 1) {
const value = getValueFromConversation(
conversation,
filters[0].attribute_key
);
return matchesCondition(value, filters[0]);
}
// Evaluate all conditions and prepare for jsonLogic
const evaluatedFilters = evaluateFilters(conversation, filters);
return jsonLogic.apply(buildJsonLogicRule(evaluatedFilters));
};
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ConversationMetaThrottleManager from 'dashboard/helper/ConversationMetaThrottleManager';
import { shouldThrottle } from '../../conversationStats';
vi.mock('dashboard/helper/ConversationMetaThrottleManager', () => ({
default: {
shouldThrottle: vi.fn(),
},
}));
describe('shouldThrottle', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses normal threshold for accounts with 100 or fewer conversations', () => {
shouldThrottle(100);
expect(ConversationMetaThrottleManager.shouldThrottle).toHaveBeenCalledWith(
2000
);
});
it('uses large account threshold for accounts with more than 100 conversations', () => {
shouldThrottle(101);
expect(ConversationMetaThrottleManager.shouldThrottle).toHaveBeenCalledWith(
10000
);
});
it('returns the throttle value from ConversationMetaThrottleManager', () => {
ConversationMetaThrottleManager.shouldThrottle.mockReturnValue(true);
expect(shouldThrottle(50)).toBe(true);
ConversationMetaThrottleManager.shouldThrottle.mockReturnValue(false);
expect(shouldThrottle(150)).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { LocalStorage } from './localStorage';
// Default cache expiry is 24 hours
const DEFAULT_EXPIRY = 24 * 60 * 60 * 1000;
export const getFromCache = (key, expiry = DEFAULT_EXPIRY) => {
try {
const cached = LocalStorage.get(key);
if (!cached) return null;
const { data, timestamp } = cached;
const isExpired = Date.now() - timestamp > expiry;
if (isExpired) {
LocalStorage.remove(key);
return null;
}
return data;
} catch (error) {
return null;
}
};
export const setCache = (key, data) => {
try {
const cacheData = {
data,
timestamp: Date.now(),
};
LocalStorage.set(key, cacheData);
} catch (error) {
// Ignore cache errors
}
};
export const clearCache = key => {
try {
LocalStorage.remove(key);
} catch (error) {
// Ignore cache errors
}
};
@@ -0,0 +1,136 @@
import { getFromCache, setCache, clearCache } from '../cache';
import { LocalStorage } from '../localStorage';
vi.mock('../localStorage');
describe('Cache Helpers', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date(2023, 1, 1, 0, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
});
describe('getFromCache', () => {
it('returns null when no data is cached', () => {
LocalStorage.get.mockReturnValue(null);
const result = getFromCache('test-key');
expect(result).toBeNull();
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
});
it('returns cached data when not expired', () => {
// Current time is 2023-02-01 00:00:00
// Cache timestamp is 1 hour ago
const oneHourAgo =
new Date(2023, 1, 1, 0, 0, 0).getTime() - 60 * 60 * 1000;
LocalStorage.get.mockReturnValue({
data: { foo: 'bar' },
timestamp: oneHourAgo,
});
// Default expiry is 24 hours
const result = getFromCache('test-key');
expect(result).toEqual({ foo: 'bar' });
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
expect(LocalStorage.remove).not.toHaveBeenCalled();
});
it('removes and returns null when data is expired', () => {
// Current time is 2023-02-01 00:00:00
// Cache timestamp is 25 hours ago (beyond the default 24-hour expiry)
const twentyFiveHoursAgo =
new Date(2023, 1, 1, 0, 0, 0).getTime() - 25 * 60 * 60 * 1000;
LocalStorage.get.mockReturnValue({
data: { foo: 'bar' },
timestamp: twentyFiveHoursAgo,
});
const result = getFromCache('test-key');
expect(result).toBeNull();
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
});
it('respects custom expiry time', () => {
// Current time is 2023-02-01 00:00:00
// Cache timestamp is 2 hours ago
const twoHoursAgo =
new Date(2023, 1, 1, 0, 0, 0).getTime() - 2 * 60 * 60 * 1000;
LocalStorage.get.mockReturnValue({
data: { foo: 'bar' },
timestamp: twoHoursAgo,
});
// Set expiry to 1 hour
const result = getFromCache('test-key', 60 * 60 * 1000);
expect(result).toBeNull();
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
});
it('handles errors gracefully', () => {
LocalStorage.get.mockImplementation(() => {
throw new Error('Storage error');
});
const result = getFromCache('test-key');
expect(result).toBeNull();
});
});
describe('setCache', () => {
it('stores data with timestamp', () => {
const data = { name: 'test' };
const expectedCacheData = {
data,
timestamp: new Date(2023, 1, 1, 0, 0, 0).getTime(),
};
setCache('test-key', data);
expect(LocalStorage.set).toHaveBeenCalledWith(
'test-key',
expectedCacheData
);
});
it('handles errors gracefully', () => {
LocalStorage.set.mockImplementation(() => {
throw new Error('Storage error');
});
// Should not throw
expect(() => setCache('test-key', { foo: 'bar' })).not.toThrow();
});
});
describe('clearCache', () => {
it('removes cached data', () => {
clearCache('test-key');
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
});
it('handles errors gracefully', () => {
LocalStorage.remove.mockImplementation(() => {
throw new Error('Storage error');
});
// Should not throw
expect(() => clearCache('test-key')).not.toThrow();
});
});
});
+1 -1
View File
@@ -10,7 +10,7 @@
html,
body {
@apply antialiased h-full bg-n-background;
@apply antialiased h-full;
}
.is-mobile {
+6 -12
View File
@@ -1,11 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
import UnreadMessages from './views/UnreadMessages.vue';
import Campaigns from './views/Campaigns.vue';
import Home from './views/Home.vue';
import PreChatForm from './views/PreChatForm.vue';
import Messages from './views/Messages.vue';
import ArticleViewer from './views/ArticleViewer.vue';
import store from './store';
const router = createRouter({
@@ -14,12 +8,12 @@ const router = createRouter({
{
path: '/unread-messages',
name: 'unread-messages',
component: UnreadMessages,
component: () => import('./views/UnreadMessages.vue'),
},
{
path: '/campaigns',
name: 'campaigns',
component: Campaigns,
component: () => import('./views/Campaigns.vue'),
},
{
path: '/',
@@ -28,22 +22,22 @@ const router = createRouter({
{
path: '',
name: 'home',
component: Home,
component: () => import('./views/Home.vue'),
},
{
path: '/prechat-form',
name: 'prechat-form',
component: PreChatForm,
component: () => import('./views/PreChatForm.vue'),
},
{
path: '/messages',
name: 'messages',
component: Messages,
component: () => import('./views/Messages.vue'),
},
{
path: '/article',
name: 'article-viewer',
component: ArticleViewer,
component: () => import('./views/ArticleViewer.vue'),
},
],
},
@@ -1,5 +1,6 @@
import { getAvailableAgents } from 'widget/api/agent';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import { getFromCache, setCache } from 'shared/helpers/cache';
const state = {
records: [],
@@ -15,11 +16,22 @@ export const getters = {
$state.records.filter(agent => agent.availability_status === 'online'),
};
const CACHE_KEY_PREFIX = 'chatwoot_available_agents_';
export const actions = {
fetchAvailableAgents: async ({ commit }, websiteToken) => {
try {
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${websiteToken}`);
if (cachedData) {
commit('setAgents', cachedData);
commit('setError', false);
commit('setHasFetched', true);
return;
}
const { data } = await getAvailableAgents(websiteToken);
const { payload = [] } = data;
setCache(`${CACHE_KEY_PREFIX}${websiteToken}`, payload);
commit('setAgents', payload);
commit('setError', false);
commit('setHasFetched', true);
@@ -1,4 +1,7 @@
import { getMostReadArticles } from 'widget/api/article';
import { getFromCache, setCache } from 'shared/helpers/cache';
const CACHE_KEY_PREFIX = 'chatwoot_most_read_articles_';
const state = {
records: [],
@@ -20,9 +23,16 @@ export const actions = {
commit('setError', false);
try {
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
return;
}
const { data } = await getMostReadArticles(slug, locale);
const { payload = [] } = data;
setCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`, payload);
if (payload.length) {
commit('setArticles', payload);
}
@@ -4,6 +4,10 @@ import {
formatCampaigns,
filterCampaigns,
} from 'widget/helpers/campaignHelper';
import { getFromCache, setCache } from 'shared/helpers/cache';
const CACHE_KEY_PREFIX = 'chatwoot_campaigns_';
const state = {
records: [],
uiFlags: {
@@ -41,7 +45,26 @@ export const actions = {
{ websiteToken, currentURL, isInBusinessHours }
) => {
try {
// Cache for 1 hour
const CACHE_EXPIRY = 60 * 60 * 1000;
const cachedData = getFromCache(
`${CACHE_KEY_PREFIX}${websiteToken}`,
CACHE_EXPIRY
);
if (cachedData) {
commit('setCampaigns', cachedData);
commit('setError', false);
resetCampaignTimers(
cachedData,
currentURL,
websiteToken,
isInBusinessHours
);
return;
}
const { data: campaigns } = await getCampaigns(websiteToken);
setCache(`${CACHE_KEY_PREFIX}${websiteToken}`, campaigns);
commit('setCampaigns', campaigns);
commit('setError', false);
resetCampaignTimers(
@@ -1,14 +1,60 @@
import { API } from 'widget/helpers/axios';
import { actions } from '../../agent';
import { agents } from './data';
import { getFromCache, setCache } from 'shared/helpers/cache';
import { getAvailableAgents } from 'widget/api/agent';
const commit = vi.fn();
let commit = vi.fn();
vi.mock('widget/helpers/axios');
vi.mock('widget/api/agent');
vi.mock('shared/helpers/cache');
describe('#actions', () => {
describe('#fetchAvailableAgents', () => {
const websiteToken = 'test-token';
beforeEach(() => {
commit = vi.fn();
vi.clearAllMocks();
});
it('returns cached data if available', async () => {
getFromCache.mockReturnValue(agents);
await actions.fetchAvailableAgents({ commit }, websiteToken);
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_available_agents_${websiteToken}`
);
expect(getAvailableAgents).not.toHaveBeenCalled();
expect(setCache).not.toHaveBeenCalled();
expect(commit).toHaveBeenCalledWith('setAgents', agents);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).toHaveBeenCalledWith('setHasFetched', true);
});
it('fetches and caches data if no cache available', async () => {
getFromCache.mockReturnValue(null);
getAvailableAgents.mockReturnValue({ data: { payload: agents } });
await actions.fetchAvailableAgents({ commit }, websiteToken);
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_available_agents_${websiteToken}`
);
expect(getAvailableAgents).toHaveBeenCalledWith(websiteToken);
expect(setCache).toHaveBeenCalledWith(
`chatwoot_available_agents_${websiteToken}`,
agents
);
expect(commit).toHaveBeenCalledWith('setAgents', agents);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).toHaveBeenCalledWith('setHasFetched', true);
});
it('sends correct actions if API is success', async () => {
API.get.mockResolvedValue({ data: { payload: agents } });
getFromCache.mockReturnValue(null);
getAvailableAgents.mockReturnValue({ data: { payload: agents } });
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setAgents', agents],
@@ -17,7 +63,11 @@ describe('#actions', () => {
]);
});
it('sends correct actions if API is error', async () => {
API.get.mockRejectedValue({ message: 'Authentication required' });
getFromCache.mockReturnValue(null);
getAvailableAgents.mockRejectedValue({
message: 'Authentication required',
});
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setError', true],
@@ -1,127 +0,0 @@
import { mutations, actions, getters } from '../../articles'; // update this import path to your actual module location
import { getMostReadArticles } from 'widget/api/article';
vi.mock('widget/api/article');
describe('Vuex Articles Module', () => {
let state;
beforeEach(() => {
state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
isFetching: false,
},
};
});
describe('Mutations', () => {
it('sets articles correctly', () => {
const articles = [{ id: 1 }, { id: 2 }];
mutations.setArticles(state, articles);
expect(state.records).toEqual(articles);
});
it('sets error flag correctly', () => {
mutations.setError(state, true);
expect(state.uiFlags.isError).toBe(true);
});
it('sets fetching state correctly', () => {
mutations.setIsFetching(state, true);
expect(state.uiFlags.isFetching).toBe(true);
});
it('does not mutate records when no articles are provided', () => {
const previousState = { ...state };
mutations.setArticles(state, []);
expect(state.records).toEqual(previousState.records);
});
it('toggles the error state correctly', () => {
mutations.setError(state, true);
expect(state.uiFlags.isError).toBe(true);
mutations.setError(state, false);
expect(state.uiFlags.isError).toBe(false);
});
it('toggles the fetching state correctly', () => {
mutations.setIsFetching(state, true);
expect(state.uiFlags.isFetching).toBe(true);
mutations.setIsFetching(state, false);
expect(state.uiFlags.isFetching).toBe(false);
});
});
describe('Actions', () => {
it('fetches articles correctly', async () => {
const commit = vi.fn();
const articles = [{ id: 1 }, { id: 2 }];
getMostReadArticles.mockResolvedValueOnce({
data: { payload: articles },
});
await actions.fetch({ commit }, { slug: 'slug', locale: 'en' });
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).toHaveBeenCalledWith('setArticles', articles);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
it('handles fetch error correctly', async () => {
const commit = vi.fn();
getMostReadArticles.mockRejectedValueOnce(new Error('Error message'));
await actions.fetch(
{ commit },
{ websiteToken: 'token', slug: 'slug', locale: 'en' }
);
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', true);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
it('does not mutate state when fetching returns an empty payload', async () => {
const commit = vi.fn();
getMostReadArticles.mockResolvedValueOnce({ data: { payload: [] } });
await actions.fetch({ commit }, { slug: 'slug', locale: 'en' });
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).not.toHaveBeenCalledWith('setArticles', expect.any(Array));
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
it('sets error state when fetching fails', async () => {
const commit = vi.fn();
getMostReadArticles.mockRejectedValueOnce(new Error('Network error'));
await actions.fetch(
{ commit },
{ websiteToken: 'token', slug: 'slug', locale: 'en' }
);
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', true);
expect(commit).not.toHaveBeenCalledWith('setArticles', expect.any(Array));
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
});
describe('Getters', () => {
it('returns uiFlags correctly', () => {
const result = getters.uiFlags(state);
expect(result).toEqual(state.uiFlags);
});
it('returns popularArticles correctly', () => {
const result = getters.popularArticles(state);
expect(result).toEqual(state.records);
});
});
});
@@ -0,0 +1,170 @@
import { mutations, actions, getters } from '../articles';
import { getMostReadArticles } from 'widget/api/article';
import { getFromCache, setCache } from 'shared/helpers/cache';
vi.mock('widget/api/article');
vi.mock('shared/helpers/cache');
describe('Vuex Articles Module', () => {
let state;
beforeEach(() => {
state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
isFetching: false,
},
};
});
describe('Mutations', () => {
it('sets articles correctly', () => {
const articles = [{ id: 1 }, { id: 2 }];
mutations.setArticles(state, articles);
expect(state.records).toEqual(articles);
});
it('sets error flag correctly', () => {
mutations.setError(state, true);
expect(state.uiFlags.isError).toBe(true);
});
it('sets fetching state correctly', () => {
mutations.setIsFetching(state, true);
expect(state.uiFlags.isFetching).toBe(true);
});
it('does not mutate records when no articles are provided', () => {
const previousState = { ...state };
mutations.setArticles(state, []);
expect(state.records).toEqual(previousState.records);
});
it('toggles the error state correctly', () => {
mutations.setError(state, true);
expect(state.uiFlags.isError).toBe(true);
mutations.setError(state, false);
expect(state.uiFlags.isError).toBe(false);
});
it('toggles the fetching state correctly', () => {
mutations.setIsFetching(state, true);
expect(state.uiFlags.isFetching).toBe(true);
mutations.setIsFetching(state, false);
expect(state.uiFlags.isFetching).toBe(false);
});
});
describe('Actions', () => {
describe('#fetch', () => {
const slug = 'test-slug';
const locale = 'en';
const articles = [
{ id: 1, title: 'Test' },
{ id: 2, title: 'Test 2' },
];
let commit;
beforeEach(() => {
commit = vi.fn();
vi.clearAllMocks();
});
it('returns cached data if available', async () => {
getFromCache.mockReturnValue(articles);
await actions.fetch({ commit }, { slug, locale });
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_most_read_articles_${slug}_${locale}`
);
expect(getMostReadArticles).not.toHaveBeenCalled();
expect(setCache).not.toHaveBeenCalled();
expect(commit).toHaveBeenCalledWith('setArticles', articles);
expect(commit).toHaveBeenCalledWith('setError', false);
});
it('fetches and caches data if no cache available', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockReturnValue({ data: { payload: articles } });
await actions.fetch({ commit }, { slug, locale });
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_most_read_articles_${slug}_${locale}`
);
expect(getMostReadArticles).toHaveBeenCalledWith(slug, locale);
expect(setCache).toHaveBeenCalledWith(
`chatwoot_most_read_articles_${slug}_${locale}`,
articles
);
expect(commit).toHaveBeenCalledWith('setArticles', articles);
expect(commit).toHaveBeenCalledWith('setError', false);
});
it('handles API errors correctly', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockRejectedValue(new Error('API Error'));
await actions.fetch({ commit }, { slug, locale });
expect(commit).toHaveBeenCalledWith('setError', true);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
it('does not mutate state when fetching returns an empty payload', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockReturnValue({ data: { payload: [] } });
await actions.fetch({ commit }, { slug, locale });
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).not.toHaveBeenCalledWith(
'setArticles',
expect.any(Array)
);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
it('sets loading state during fetch', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockReturnValue({ data: { payload: articles } });
await actions.fetch({ commit }, { slug, locale });
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
});
it('sets error state when fetching fails', async () => {
const commit = vi.fn();
getMostReadArticles.mockRejectedValueOnce(new Error('Network error'));
await actions.fetch(
{ commit },
{ websiteToken: 'token', slug: 'slug', locale: 'en' }
);
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', true);
expect(commit).not.toHaveBeenCalledWith('setArticles', expect.any(Array));
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
});
describe('Getters', () => {
it('returns uiFlags correctly', () => {
const result = getters.uiFlags(state);
expect(result).toEqual(state.uiFlags);
});
it('returns popularArticles correctly', () => {
const result = getters.popularArticles(state);
expect(result).toEqual(state.records);
});
});
});
@@ -1,10 +1,12 @@
import { API } from 'widget/helpers/axios';
import { actions } from '../../campaign';
import { campaigns } from './data';
import { getFromCache, setCache } from 'shared/helpers/cache';
const commit = vi.fn();
const dispatch = vi.fn();
vi.mock('widget/helpers/axios');
vi.mock('shared/helpers/cache');
import campaignTimer from 'widget/helpers/campaignTimer';
vi.mock('widget/helpers/campaignTimer', () => ({
@@ -15,8 +17,17 @@ vi.mock('widget/helpers/campaignTimer', () => ({
describe('#actions', () => {
describe('#fetchCampaigns', () => {
it('sends correct actions if API is success', async () => {
API.get.mockResolvedValue({ data: campaigns });
beforeEach(() => {
commit.mockClear();
getFromCache.mockClear();
setCache.mockClear();
API.get.mockClear();
campaignTimer.initTimers.mockClear();
});
it('uses cached data when available', async () => {
getFromCache.mockReturnValue(campaigns);
await actions.fetchCampaigns(
{ commit },
{
@@ -25,6 +36,54 @@ describe('#actions', () => {
isInBusinessHours: true,
}
);
expect(getFromCache).toHaveBeenCalledWith(
'chatwoot_campaigns_XDsafmADasd',
60 * 60 * 1000
);
expect(API.get).not.toHaveBeenCalled();
expect(setCache).not.toHaveBeenCalled();
expect(commit.mock.calls).toEqual([
['setCampaigns', campaigns],
['setError', false],
]);
expect(campaignTimer.initTimers).toHaveBeenCalledWith(
{
campaigns: [
{
id: 11,
timeOnPage: '20',
url: 'https://chatwoot.com',
triggerOnlyDuringBusinessHours: false,
},
],
},
'XDsafmADasd'
);
});
it('fetches and caches data when cache is not available', async () => {
getFromCache.mockReturnValue(null);
API.get.mockResolvedValue({ data: campaigns });
await actions.fetchCampaigns(
{ commit },
{
websiteToken: 'XDsafmADasd',
currentURL: 'https://chatwoot.com',
isInBusinessHours: true,
}
);
expect(getFromCache).toHaveBeenCalledWith(
'chatwoot_campaigns_XDsafmADasd',
60 * 60 * 1000
);
expect(API.get).toHaveBeenCalled();
expect(setCache).toHaveBeenCalledWith(
'chatwoot_campaigns_XDsafmADasd',
campaigns
);
expect(commit.mock.calls).toEqual([
['setCampaigns', campaigns],
['setError', false],
@@ -43,7 +102,9 @@ describe('#actions', () => {
'XDsafmADasd'
);
});
it('sends correct actions if API is error', async () => {
getFromCache.mockReturnValue(null);
API.get.mockRejectedValue({ message: 'Authentication required' });
await actions.fetchCampaigns(
{ commit },
+1
View File
@@ -56,6 +56,7 @@ class Account < ApplicationRecord
has_many :data_imports, dependent: :destroy_async
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inboxes, dependent: :destroy_async
has_many :labels, dependent: :destroy_async
-18
View File
@@ -63,22 +63,4 @@ class Channel::FacebookPage < ApplicationRecord
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
# TODO: We will be removing this code after instagram_manage_insights is implemented
def fetch_instagram_story_link(message)
k = Koala::Facebook::API.new(page_access_token)
result = k.get_object(message.source_id, fields: %w[story]) || {}
story_link = result['story']['mention']['link']
# If the story is expired then it raises the ClientError and if the story is deleted with valid story-id it responses with nil
delete_instagram_story(message) if story_link.blank?
story_link
rescue Koala::Facebook::ClientError => e
Rails.logger.debug { "Instagram Story Expired: #{e.inspect}" }
delete_instagram_story(message)
end
def delete_instagram_story(message)
message.attachments.destroy_all
message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'), content_attributes: {})
end
end
+28
View File
@@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: channel_instagram
#
# id :bigint not null, primary key
# access_token :string not null
# expires_at :datetime not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# instagram_id :string not null
#
# Indexes
#
# index_channel_instagram_on_instagram_id (instagram_id) UNIQUE
#
class Channel::Instagram < ApplicationRecord
include Channelable
self.table_name = 'channel_instagram'
validates :access_token, presence: true
validates :instagram_id, uniqueness: true, presence: true
def name
'Instagram'
end
end
-9
View File
@@ -155,15 +155,6 @@ class Message < ApplicationRecord
}
end
# TODO: We will be removing this code after instagram_manage_insights is implemented
# Better logic is to listen to webhook and remove stories proactively rather than trying
# a fetch every time a message is returned
def validate_instagram_story
inbox.channel.fetch_instagram_story_link(self)
# we want to reload the message in case the story has expired and data got removed
reload
end
def merge_sender_attributes(data)
data[:sender] = sender.push_event_data if sender && !sender.is_a?(AgentBot)
data[:sender] = sender.push_event_data(inbox) if sender.is_a?(AgentBot)
@@ -12,10 +12,8 @@ class Base::SendOnChannelService
def perform
validate_target_channel
return unless outgoing_message?
return if invalid_message?
return if invalid_source_id?
perform_reply
end
@@ -51,29 +49,6 @@ class Base::SendOnChannelService
message.private? || outgoing_message_originated_from_channel?
end
def invalid_source_id?
return false unless channels_to_validate?
return false if contact_inbox.source_id == expected_source_id
message.update!(status: :failed, external_error: I18n.t('errors.channel_service.invalid_source_id'))
true
end
def expected_source_id
ContactInbox::SourceIdService.new(
contact: contact,
channel_type: inbox.channel_type,
medium: inbox.channel.try(:medium)
).generate
rescue ArgumentError
nil
end
def channels_to_validate?
inbox.sms? || inbox.whatsapp? || inbox.email? || inbox.twilio?
end
def validate_target_channel
raise 'Invalid channel service was called' if inbox.channel.class != channel_class
end
@@ -1,55 +0,0 @@
class ContactInbox::SourceIdService
pattr_initialize [:contact!, :channel_type!, { medium: nil }]
def generate
case channel_type
when 'Channel::TwilioSms'
twilio_source_id
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
email_source_id
when 'Channel::Sms'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise ArgumentError, "Unsupported operation for this channel: #{channel_type}"
end
end
private
def email_source_id
raise ArgumentError, 'contact email required' unless contact.email
contact.email
end
def phone_source_id
raise ArgumentError, 'contact phone number required' unless contact.phone_number
contact.phone_number
end
def wa_source_id
raise ArgumentError, 'contact phone number required' unless contact.phone_number
# whatsapp doesn't want the + in e164 format
contact.phone_number.delete('+').to_s
end
def twilio_source_id
raise ArgumentError, 'contact phone number required' unless contact.phone_number
raise ArgumentError, 'medium required for Twilio channel' if medium.blank?
case medium
when 'sms'
contact.phone_number
when 'whatsapp'
"whatsapp:#{contact.phone_number}"
else
raise ArgumentError, "Unsupported Twilio medium: #{medium}"
end
end
end
-3
View File
@@ -67,9 +67,6 @@ en:
invalid_message_type: 'Invalid message type. Action not permitted'
slack:
invalid_channel_id: 'Invalid slack channel. Please try again'
channel_service:
invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed."
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
@@ -0,0 +1,13 @@
class AddInstagramChannel < ActiveRecord::Migration[7.0]
def change
create_table :channel_instagram do |t|
t.string :access_token, null: false
t.datetime :expires_at, null: false
t.integer :account_id, null: false
t.string :instagram_id, null: false
t.timestamps
end
add_index :channel_instagram, :instagram_id, unique: true
end
end
+11 -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_03_15_202035) do
ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -377,6 +377,16 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_15_202035) do
t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id"
end
create_table "channel_instagram", force: :cascade do |t|
t.string "access_token", null: false
t.datetime "expires_at", null: false
t.integer "account_id", null: false
t.string "instagram_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["instagram_id"], name: "index_channel_instagram_on_instagram_id", unique: true
end
create_table "channel_line", force: :cascade do |t|
t.integer "account_id", null: false
t.string "line_channel_id", null: false
+1
View File
@@ -72,6 +72,7 @@
"highlight.js": "^11.10.0",
"idb": "^8.0.0",
"js-cookie": "^3.0.5",
"json-logic-js": "^2.0.5",
"lettersanitizer": "^1.0.6",
"libphonenumber-js": "^1.11.9",
"markdown-it": "^13.0.2",
+8
View File
@@ -136,6 +136,9 @@ importers:
js-cookie:
specifier: ^3.0.5
version: 3.0.5
json-logic-js:
specifier: ^2.0.5
version: 2.0.5
lettersanitizer:
specifier: ^1.0.6
version: 1.0.6
@@ -3398,6 +3401,9 @@ packages:
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-logic-js@2.0.5:
resolution: {integrity: sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -8761,6 +8767,8 @@ snapshots:
json-buffer@3.0.1: {}
json-logic-js@2.0.5: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
+5 -5
View File
@@ -59,7 +59,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: twilio_inbox
).perform
end.to raise_error(ArgumentError, 'contact phone number required')
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
@@ -117,7 +117,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: twilio_inbox
).perform
end.to raise_error(ArgumentError, 'contact phone number required')
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
@@ -174,7 +174,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: whatsapp_inbox
).perform
end.to raise_error(ArgumentError, 'contact phone number required')
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
@@ -232,7 +232,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: sms_inbox
).perform
end.to raise_error(ArgumentError, 'contact phone number required')
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
@@ -290,7 +290,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: email_inbox
).perform
end.to raise_error(ArgumentError, 'contact email required')
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact email')
end
end
@@ -0,0 +1,13 @@
FactoryBot.define do
factory :channel_instagram, class: 'Channel::Instagram' do
account
access_token { SecureRandom.hex(32) }
instagram_id { SecureRandom.hex(16) }
expires_at { 60.days.from_now }
updated_at { 25.hours.ago }
after(:create) do |channel|
create(:inbox, channel: channel, account: channel.account)
end
end
end
-36
View File
@@ -30,42 +30,6 @@ RSpec.describe Channel::FacebookPage do
channel.prompt_reauthorization!
end
end
context 'when fetch instagram story' do
let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let(:fb_object) { double }
let(:message) { create(:message, inbox_id: instagram_inbox.id) }
let(:instagram_message) { create(:message, :instagram_story_mention, inbox_id: instagram_inbox.id) }
it '#fetch_instagram_story_link' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{ story:
{
mention: {
link: 'https://www.example.com/test.jpeg',
id: '17920786367196703'
}
},
from: {
username: 'Sender-id-1', id: 'Sender-id-1'
},
id: 'instagram-message-id-1234' }.with_indifferent_access
)
story_link = instagram_channel.fetch_instagram_story_link(message)
expect(story_link).to eq('https://www.example.com/test.jpeg')
end
it '#delete_instagram_story' do
expect(instagram_message.attachments.count).to eq(1)
instagram_channel.delete_instagram_story(instagram_message)
expect(instagram_message.attachments.count).to eq(0)
end
end
end
it 'has a valid name' do
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::Instagram do
let(:channel) { create(:channel_instagram) }
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:access_token) }
it { is_expected.to validate_presence_of(:instagram_id) }
it { is_expected.to belong_to(:account) }
it { is_expected.to have_one(:inbox).dependent(:destroy_async) }
it 'has a valid name' do
expect(channel.name).to eq('Instagram')
end
end
@@ -1,117 +0,0 @@
require 'rails_helper'
RSpec.describe ContactInbox::SourceIdService do
let(:contact) { create(:contact, email: 'test@example.com', phone_number: '+1234567890') }
describe '#generate' do
context 'when channel is TwilioSms' do
let(:channel_type) { 'Channel::TwilioSms' }
context 'with SMS medium' do
subject { described_class.new(contact: contact, channel_type: channel_type, medium: 'sms') }
it 'returns phone number as source id' do
expect(subject.generate).to eq(contact.phone_number)
end
end
context 'with WhatsApp medium' do
subject { described_class.new(contact: contact, channel_type: channel_type, medium: 'whatsapp') }
it 'returns whatsapp prefixed phone number as source id' do
expect(subject.generate).to eq("whatsapp:#{contact.phone_number}")
end
end
context 'with invalid medium' do
subject { described_class.new(contact: contact, channel_type: channel_type, medium: 'invalid') }
it 'raises ArgumentError' do
expect { subject.generate }.to raise_error(ArgumentError, 'Unsupported Twilio medium: invalid')
end
end
context 'without medium' do
subject { described_class.new(contact: contact, channel_type: channel_type) }
it 'raises ArgumentError' do
expect { subject.generate }.to raise_error(ArgumentError, 'medium required for Twilio channel')
end
end
end
context 'when channel is Whatsapp' do
subject { described_class.new(contact: contact, channel_type: 'Channel::Whatsapp') }
it 'returns phone number without + as source id' do
expect(subject.generate).to eq(contact.phone_number.delete('+'))
end
context 'when contact has no phone number' do
let(:contact) { create(:contact, phone_number: nil) }
it 'raises ArgumentError' do
expect { subject.generate }.to raise_error(ArgumentError, 'contact phone number required')
end
end
end
context 'when channel is Email' do
subject { described_class.new(contact: contact, channel_type: 'Channel::Email') }
it 'returns email as source id' do
expect(subject.generate).to eq(contact.email)
end
context 'when contact has no email' do
let(:contact) { create(:contact, email: nil) }
it 'raises ArgumentError' do
expect { subject.generate }.to raise_error(ArgumentError, 'contact email required')
end
end
end
context 'when channel is SMS' do
subject { described_class.new(contact: contact, channel_type: 'Channel::Sms') }
it 'returns phone number as source id' do
expect(subject.generate).to eq(contact.phone_number)
end
context 'when contact has no phone number' do
let(:contact) { create(:contact, phone_number: nil) }
it 'raises ArgumentError' do
expect { subject.generate }.to raise_error(ArgumentError, 'contact phone number required')
end
end
end
context 'when channel is Api' do
subject { described_class.new(contact: contact, channel_type: 'Channel::Api') }
it 'returns a UUID as source id' do
allow(SecureRandom).to receive(:uuid).and_return('uuid-123')
expect(subject.generate).to eq('uuid-123')
end
end
context 'when channel is WebWidget' do
subject { described_class.new(contact: contact, channel_type: 'Channel::WebWidget') }
it 'returns a UUID as source id' do
allow(SecureRandom).to receive(:uuid).and_return('uuid-123')
expect(subject.generate).to eq('uuid-123')
end
end
context 'when channel is unsupported' do
subject { described_class.new(contact: contact, channel_type: 'Channel::Unknown') }
it 'raises ArgumentError' do
expect { subject.generate }.to raise_error(ArgumentError, 'Unsupported operation for this channel: Channel::Unknown')
end
end
end
end
@@ -521,4 +521,241 @@ describe Conversations::FilterService do
end
end
end
describe 'Frontend alignment tests' do
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:params) { { payload: [], page: 1 } }
before do
account.conversations.destroy_all
end
context 'with A AND B OR C filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['12345'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' }
)
end
it 'matches when all conditions are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when first condition is false but third is true' do
conversation.update!(status: 'resolved', priority: 'urgent', display_id: '12345')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when first and second condition is false but third is true' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '12345')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'does not match when all conditions are false' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 0
end
end
context 'with A OR B AND C filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['low'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' }
)
end
it 'matches when first condition is true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when second and third conditions are true' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
end
context 'with complex filter chain A AND B OR C AND D' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: ['tr'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' },
custom_attributes: { conversation_type: 'platinum' }
)
end
it 'matches when first two conditions are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when last two conditions are true' do
conversation.update!(
status: 'resolved',
priority: 'low',
display_id: '67890',
additional_attributes: { 'browser_language': 'tr' }
)
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
end
context 'with mixed operators filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'conversation_type',
filter_operator: 'equal_to',
values: ['platinum'],
custom_attribute_type: '',
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' },
custom_attributes: { conversation_type: 'platinum' }
)
end
it 'matches when all conditions in the chain are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'does not match when the last condition is false' do
conversation.update!(custom_attributes: { conversation_type: 'silver' })
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
end
end
end
@@ -5,9 +5,8 @@ describe Sms::SendOnSmsService do
context 'when a valid message' do
let(:sms_request) { double }
let!(:sms_channel) { create(:channel_sms) }
let!(:contact) { create(:contact, phone_number: '+123456789') }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: sms_channel.inbox, source_id: '+123456789') }
let!(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: sms_channel.inbox, contact: contact) }
let!(:contact_inbox) { create(:contact_inbox, inbox: sms_channel.inbox, source_id: '+123456789') }
let!(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: sms_channel.inbox) }
it 'calls channel.send_message' do
message = create(:message, message_type: :outgoing, content: 'test',
@@ -13,11 +13,9 @@ describe Twilio::SendOnTwilioService do
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
let!(:contact) { create(:contact, account: account, phone_number: '+123456789') }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox, source_id: '+123456789') }
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+123456789') }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) }
let(:whatsapp_conversation) { create(:conversation, contact: contact, inbox: twilio_whatsapp_inbox, contact_inbox: whatsapp_contact_inbox) }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
@@ -73,7 +71,7 @@ describe Twilio::SendOnTwilioService do
allow(message_record_double).to receive(:sid).and_return('1234')
message = build(
:message, message_type: 'outgoing', inbox: twilio_whatsapp_inbox, account: account, conversation: whatsapp_conversation
:message, message_type: 'outgoing', inbox: twilio_whatsapp_inbox, account: account, conversation: conversation
)
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
@@ -18,9 +18,8 @@ describe Whatsapp::SendOnWhatsappService do
context 'when a valid message' do
let(:whatsapp_request) { instance_double(HTTParty::Response) }
let!(:whatsapp_channel) { create(:channel_whatsapp, sync_templates: false) }
let!(:contact) { create(:contact, phone_number: '+123456789') }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: whatsapp_channel.inbox, source_id: '123456789') }
let!(:conversation) { create(:conversation, contact: contact, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) }
let!(:contact_inbox) { create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '123456789') }
let!(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) }
let(:api_key) { 'test_key' }
let(:headers) { { 'D360-API-KEY' => api_key, 'Content-Type' => 'application/json' } }
let(:template_body) do
@@ -36,12 +35,14 @@ describe Whatsapp::SendOnWhatsappService do
}
end
let(:success_response) { { 'messages' => [{ 'id' => 'message-123456789' }] }.to_json }
let(:success_response) { { 'messages' => [{ 'id' => '123456789' }] }.to_json }
it 'calls channel.send_message when with in 24 hour limit' do
# to handle the case of 24 hour window limit.
create(:message, message_type: :incoming, content: 'test', conversation: conversation)
message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation)
create(:message, message_type: :incoming, content: 'test',
conversation: conversation)
message = create(:message, message_type: :outgoing, content: 'test',
conversation: conversation)
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
.with(
@@ -51,7 +52,7 @@ describe Whatsapp::SendOnWhatsappService do
.to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('message-123456789')
expect(message.reload.source_id).to eq('123456789')
end
it 'calls channel.send_template when after 24 hour limit' do
@@ -65,7 +66,7 @@ describe Whatsapp::SendOnWhatsappService do
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('message-123456789')
expect(message.reload.source_id).to eq('123456789')
end
it 'calls channel.send_template if template_params are present' do
@@ -78,7 +79,7 @@ describe Whatsapp::SendOnWhatsappService do
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('message-123456789')
expect(message.reload.source_id).to eq('123456789')
end
it 'calls channel.send_template when template has regexp characters' do
@@ -105,18 +106,7 @@ describe Whatsapp::SendOnWhatsappService do
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
described_class.new(message: message).perform
expect(message.reload.source_id).to eq('message-123456789')
end
context 'when source_id validation is required' do
let(:message) { create(:message, conversation: conversation, message_type: :outgoing) }
it 'marks message as failed when source_ids do not match' do
contact_inbox.update!(source_id: '1234567890')
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to include('This conversation may have originally belonged to a different contact')
end
expect(message.reload.source_id).to eq('123456789')
end
end
end