Compare commits

..
Author SHA1 Message Date
Sojan bc5f1722e1 Merge branch 'release/4.0.4'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-03-21 18:55:06 -07:00
Sojan 8f39e62570 Merge branch 'release/4.0.3'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-27 21:05:35 -08:00
Sojan 7520ca7a99 Merge branch 'release/4.0.2'
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-21 17:04:31 -08:00
Sojan 35f4e63605 Merge branch 'release/4.0.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-17 01:13:27 +05:30
Sojan 5773089865 Merge branch 'release/4.0.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-16 17:11:45 +05:30
Sojan d01c7d3fa7 Merge branch 'release/3.16.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-12-17 23:03:24 +05:30
Sojan 959d2c0d8c Merge branch 'release/3.15.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-11-19 18:02:07 +08:00
Vishnu Narayanan 622f29a0b7 Merge branch 'hotfix/3.14.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-10-22 16:03:47 +05:30
53 changed files with 505 additions and 2776 deletions
+4 -4
View File
@@ -501,14 +501,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.18.4)
nokogiri (1.18.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.4-arm64-darwin)
nokogiri (1.18.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.4-x86_64-darwin)
nokogiri (1.18.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.4-x86_64-linux-gnu)
nokogiri (1.18.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
+6 -45
View File
@@ -12,50 +12,11 @@ class ContactInboxBuilder
private
def generate_source_id
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
ContactInbox::SourceIdService.new(
contact: @contact,
channel_type: @inbox.channel_type,
medium: @inbox.channel.try(:medium)
).generate
end
def create_contact_inbox
@@ -91,7 +52,7 @@ class ContactInboxBuilder
def new_source_id
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
"whatsapp:#{@source_id}#{rand(100)}"
"#{@source_id}#{rand(100)}"
else
"#{rand(10)}#{@source_id}"
end
@@ -9,6 +9,8 @@ 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
+1 -3
View File
@@ -32,7 +32,6 @@ 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
@@ -87,8 +86,7 @@ class ConversationFinder
end
def find_all_conversations
@conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless @is_admin
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
filter_by_conversation_type if params[:conversation_type]
@conversations
end
@@ -25,6 +25,11 @@ 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: 'John Doe',
email: 'john@example.com',
name: 'Priority Inbox',
email: 'hello@example.com',
phoneNumber: '+1234567890',
};
@@ -19,7 +19,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('John Doe (john@example.com)');
).toBe('Priority Inbox (hello@example.com)');
});
it('generates label for twilio inbox', () => {
@@ -28,7 +28,14 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('John Doe (+1234567890)');
).toBe('Priority Inbox (+1234567890)');
expect(
helpers.generateLabelForContactableInboxesList({
name: 'Priority Inbox',
channelType: INBOX_TYPES.TWILIO,
})
).toBe('Priority Inbox');
});
it('generates label for whatsapp inbox', () => {
@@ -37,7 +44,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('John Doe (+1234567890)');
).toBe('Priority Inbox (+1234567890)');
});
it('generates label for other inbox types', () => {
@@ -46,7 +53,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe');
).toBe('Priority Inbox');
});
});
@@ -61,7 +61,6 @@ 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';
@@ -325,14 +324,6 @@ 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;
});
@@ -469,12 +460,6 @@ 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,14 +22,6 @@ 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',
@@ -1,29 +0,0 @@
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,7 +21,6 @@ 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
@@ -69,22 +68,10 @@ 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,
priority,
} = params;
const { languages, countries, agents, inboxes, teams, campaigns, labels } =
params;
switch (attribute_key) {
case 'status':
return getValuesForStatus(values);
@@ -96,14 +83,15 @@ 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 'priority':
return getValuesForPriority(values, priority);
case 'browser_language':
}
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] };
}
@@ -112,9 +100,9 @@ export const getValuesForFilter = (filter, params) => {
export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params;
const inputType = getInputType(attribute_key, filter_operator, filterTypes);
const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
if (inputType === undefined) {
if (inboxType === undefined) {
const filterInputTypes = getAttributeInputType(
attribute_key,
allCustomAttributes
@@ -124,7 +112,7 @@ export const generateValuesForEditCustomViews = (filter, params) => {
: { id: values[0], name: values[0] };
}
return inputType === 'multi_select' || inputType === 'search_select'
return inboxType === 'multi_select' || inboxType === 'search_select'
? getValuesForFilter(filter, params)
: values[0].toString();
};
@@ -1,34 +0,0 @@
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": "Configurações",
"SETTINGS": "Confirgurações",
"FORM": {
"UPDATE": "Atualizar a equipa",
"CREATE": "Criar uma equipa",
@@ -9,7 +9,7 @@
"FILTER": "Filtrar por",
"SORT": "Classificar por",
"LOCALE": "Localidade",
"SETTINGS_BUTTON": "Configurações",
"SETTINGS_BUTTON": "Confirgurações",
"NEW_BUTTON": "Novo artigo",
"DROPDOWN_OPTIONS": {
"PUBLISHED": "Publicado",
@@ -121,7 +121,7 @@
"COUNT_LABEL": "artigos",
"ADD": "Adicionar localidade",
"VISIT": "Visitar site",
"SETTINGS": "Configurações",
"SETTINGS": "Confirgurações",
"DELETE": "Excluir"
},
"PORTAL_CONFIG": {
@@ -473,7 +473,7 @@
"WIDGET_BUILDER": "Construtor de Widget",
"BOT_CONFIGURATION": "Configuração do Bot"
},
"SETTINGS": "Configurações",
"SETTINGS": "Confirgurações",
"FEATURES": {
"LABEL": "Funcionalidades",
"DISPLAY_FILE_PICKER": "Exibir seletor de arquivos no widget",
@@ -333,7 +333,7 @@
"ARTICLES": "Artigos",
"CATEGORIES": "Categorias",
"LOCALES": "Localidades",
"SETTINGS": "Configurações"
"SETTINGS": "Confirgurações"
},
"CHANNELS": "Canais",
"SET_AUTO_OFFLINE": {
@@ -100,7 +100,7 @@
"NO": "Cancelar"
}
},
"SETTINGS": "Configurações",
"SETTINGS": "Confirgurações",
"FORM": {
"UPDATE": "Atualizar equipe",
"CREATE": "Criar nova equipe",
@@ -1,10 +1,11 @@
<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';
@@ -78,17 +79,11 @@ export default {
},
downloadAgentReports() {
const { from, to } = this;
const fileName = generateFileName({
type: 'agent',
to,
businessHours: this.businessHours,
});
this.$store.dispatch('downloadAgentReports', {
from,
to,
fileName,
businessHours: this.businessHours,
});
const fileName = `agent-report-${format(
fromUnixTime(to),
'dd-MM-yyyy'
)}.csv`;
this.$store.dispatch('downloadAgentReports', { from, to, fileName });
},
onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from;
@@ -1,40 +1,28 @@
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) => {
if (shouldThrottle($state.allCount)) {
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) {
// eslint-disable-next-line no-console
console.warn('Throttle /meta fetch, will resume after threshold');
console.warn('Skipping conversation meta fetch');
return;
}
ConversationMetaThrottleManager.markUpdate();
try {
const response = await ConversationApi.meta(params);
@@ -1,7 +1,6 @@
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 = ({
@@ -14,15 +13,6 @@ 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
@@ -1,356 +0,0 @@
/**
* 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));
};
@@ -1,37 +0,0 @@
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
@@ -1,43 +0,0 @@
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
}
};
@@ -1,136 +0,0 @@
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;
@apply antialiased h-full bg-n-background;
}
.is-mobile {
+12 -6
View File
@@ -1,5 +1,11 @@
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({
@@ -8,12 +14,12 @@ const router = createRouter({
{
path: '/unread-messages',
name: 'unread-messages',
component: () => import('./views/UnreadMessages.vue'),
component: UnreadMessages,
},
{
path: '/campaigns',
name: 'campaigns',
component: () => import('./views/Campaigns.vue'),
component: Campaigns,
},
{
path: '/',
@@ -22,22 +28,22 @@ const router = createRouter({
{
path: '',
name: 'home',
component: () => import('./views/Home.vue'),
component: Home,
},
{
path: '/prechat-form',
name: 'prechat-form',
component: () => import('./views/PreChatForm.vue'),
component: PreChatForm,
},
{
path: '/messages',
name: 'messages',
component: () => import('./views/Messages.vue'),
component: Messages,
},
{
path: '/article',
name: 'article-viewer',
component: () => import('./views/ArticleViewer.vue'),
component: ArticleViewer,
},
],
},
@@ -1,6 +1,5 @@
import { getAvailableAgents } from 'widget/api/agent';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import { getFromCache, setCache } from 'shared/helpers/cache';
const state = {
records: [],
@@ -16,22 +15,11 @@ 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,7 +1,4 @@
import { getMostReadArticles } from 'widget/api/article';
import { getFromCache, setCache } from 'shared/helpers/cache';
const CACHE_KEY_PREFIX = 'chatwoot_most_read_articles_';
const state = {
records: [],
@@ -23,16 +20,9 @@ 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,10 +4,6 @@ import {
formatCampaigns,
filterCampaigns,
} from 'widget/helpers/campaignHelper';
import { getFromCache, setCache } from 'shared/helpers/cache';
const CACHE_KEY_PREFIX = 'chatwoot_campaigns_';
const state = {
records: [],
uiFlags: {
@@ -45,26 +41,7 @@ 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,60 +1,14 @@
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';
let commit = vi.fn();
const 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 () => {
getFromCache.mockReturnValue(null);
getAvailableAgents.mockReturnValue({ data: { payload: agents } });
API.get.mockResolvedValue({ data: { payload: agents } });
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setAgents', agents],
@@ -63,11 +17,7 @@ describe('#actions', () => {
]);
});
it('sends correct actions if API is error', async () => {
getFromCache.mockReturnValue(null);
getAvailableAgents.mockRejectedValue({
message: 'Authentication required',
});
API.get.mockRejectedValue({ message: 'Authentication required' });
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setError', true],
@@ -0,0 +1,127 @@
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);
});
});
});
@@ -1,170 +0,0 @@
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,12 +1,10 @@
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', () => ({
@@ -17,55 +15,8 @@ vi.mock('widget/helpers/campaignTimer', () => ({
describe('#actions', () => {
describe('#fetchCampaigns', () => {
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 },
{
websiteToken: 'XDsafmADasd',
currentURL: 'https://chatwoot.com',
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);
it('sends correct actions if API is success', async () => {
API.get.mockResolvedValue({ data: campaigns });
await actions.fetchCampaigns(
{ commit },
{
@@ -74,16 +25,6 @@ describe('#actions', () => {
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],
@@ -102,9 +43,7 @@ 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,7 +56,6 @@ 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,4 +63,22 @@ 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
@@ -1,28 +0,0 @@
# == 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,6 +155,15 @@ 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,8 +12,10 @@ class Base::SendOnChannelService
def perform
validate_target_channel
return unless outgoing_message?
return if invalid_message?
return if invalid_source_id?
perform_reply
end
@@ -49,6 +51,29 @@ 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
@@ -0,0 +1,55 @@
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,6 +67,9 @@ 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.
@@ -1,13 +0,0 @@
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
+1 -11
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_26_034635) 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"
@@ -377,16 +377,6 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_26_034635) 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,7 +72,6 @@
"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,9 +136,6 @@ 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
@@ -3401,9 +3398,6 @@ 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==}
@@ -8767,8 +8761,6 @@ 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(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end.to raise_error(ArgumentError, 'contact phone number required')
end
end
@@ -117,7 +117,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: twilio_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end.to raise_error(ArgumentError, 'contact phone number required')
end
end
@@ -174,7 +174,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: whatsapp_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end.to raise_error(ArgumentError, 'contact phone number required')
end
end
@@ -232,7 +232,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: sms_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end.to raise_error(ArgumentError, 'contact phone number required')
end
end
@@ -290,7 +290,7 @@ describe ContactInboxBuilder do
contact: contact,
inbox: email_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact email')
end.to raise_error(ArgumentError, 'contact email required')
end
end
@@ -1,13 +0,0 @@
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,6 +30,42 @@ 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
@@ -1,17 +0,0 @@
# 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
@@ -0,0 +1,117 @@
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,241 +521,4 @@ 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,8 +5,9 @@ describe Sms::SendOnSmsService do
context 'when a valid message' do
let(:sms_request) { double }
let!(:sms_channel) { create(:channel_sms) }
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) }
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) }
it 'calls channel.send_message' do
message = create(:message, message_type: :outgoing, content: 'test',
@@ -13,9 +13,11 @@ 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) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
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(: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)
@@ -71,7 +73,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: conversation
:message, message_type: 'outgoing', inbox: twilio_whatsapp_inbox, account: account, conversation: whatsapp_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,8 +18,9 @@ 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_inbox) { create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '123456789') }
let!(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) }
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(:api_key) { 'test_key' }
let(:headers) { { 'D360-API-KEY' => api_key, 'Content-Type' => 'application/json' } }
let(:template_body) do
@@ -35,14 +36,12 @@ describe Whatsapp::SendOnWhatsappService do
}
end
let(:success_response) { { 'messages' => [{ 'id' => '123456789' }] }.to_json }
let(:success_response) { { 'messages' => [{ 'id' => 'message-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(
@@ -52,7 +51,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('123456789')
expect(message.reload.source_id).to eq('message-123456789')
end
it 'calls channel.send_template when after 24 hour limit' do
@@ -66,7 +65,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('123456789')
expect(message.reload.source_id).to eq('message-123456789')
end
it 'calls channel.send_template if template_params are present' do
@@ -79,7 +78,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('123456789')
expect(message.reload.source_id).to eq('message-123456789')
end
it 'calls channel.send_template when template has regexp characters' do
@@ -106,7 +105,18 @@ 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('123456789')
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
end
end
end