Compare commits

..
Author SHA1 Message Date
Shivam Mishra 6f2e5abdda fix: don't check for source id 2025-03-03 15:54:02 +05:30
34 changed files with 121 additions and 756 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ on:
jobs:
nightly:
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: get installer
+2 -2
View File
@@ -561,7 +561,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.12)
rack (2.2.11)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -799,7 +799,7 @@ GEM
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
uri (1.0.3)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1,8 +1,8 @@
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
button,
input[type="button"],
input[type="reset"],
input[type="submit"],
.button {
appearance: none;
background-color: $color-woot;
border: 0;
@@ -10,6 +10,7 @@
.icon-container {
margin-right: 2px;
}
.value-container {
@@ -1,35 +0,0 @@
module FacebookConcern
extend ActiveSupport::Concern
class InvalidDigestError < StandardError; end
private
def deletion_processed?(code)
request = DeleteRequest.find_by!(confirmation_code: code)
request.complete?
rescue ActiveRecord::RecordNotFound
false
end
def parse_fb_signed_request(signed_request)
encoded_signature, payload = signed_request.split('.', 2)
decoded_signature = Base64.urlsafe_decode64(encoded_signature)
decoded_payload = JSON.parse(Base64.urlsafe_decode64(payload))
expected_signature = OpenSSL::HMAC.digest('sha256', app_secret, payload)
raise InvalidDigestError if decoded_signature != expected_signature
decoded_payload
end
def app_url_base
ENV.fetch('FRONTEND_URL', nil)
end
def app_secret
GlobalConfigService.load('FB_APP_SECRET', '')
end
end
@@ -1,11 +0,0 @@
class Facebook::ConfirmController < ApplicationController
include FacebookConcern
def show
if deletion_processed?(params[:id])
render plain: 'Data Deleted Successfully', status: :ok
else
render plain: 'Processing. If there is an issue, please contact support', status: :ok
end
end
end
@@ -1,23 +0,0 @@
class Facebook::DeleteController < ApplicationController
include FacebookConcern
def create
signed_request = params['signed_request']
payload = parse_fb_signed_request(signed_request)
id_to_process = payload['user_id']
delete_request = DeleteRequest.create(fb_id: id_to_process)
status_url = "#{app_url_base}/facebook/confirm/#{delete_request.confirmation_code}"
# IMPORTANT: Do not change the response format below.
# Facebook's Data Deletion Request system specifically expects responses in this format
# with a 'url' for status confirmation and a 'confirmation_code' field.
# See: https://developers.facebook.com/docs/development/create-an-app/app-dashboard/data-deletion-callback/#implementing
render json: { url: status_url, confirmation_code: delete_request.confirmation_code }, status: :ok
rescue InvalidDigestError
render json: { error: 'Invalid signature' }, status: :unprocessable_entity
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
render json: { error: e.message }, status: :malformed_request
end
end
+1 -5
View File
@@ -78,11 +78,7 @@ class AccountDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {
active: ->(resources) { resources.where(status: :active) },
suspended: ->(resources) { resources.where(status: :suspended) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
}.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how accounts are displayed
# across all pages of the admin dashboard.
+1 -6
View File
@@ -94,12 +94,7 @@ class UserDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {
super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
}.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how users are displayed
# across all pages of the admin dashboard.
@@ -1,4 +1,4 @@
module Filters::FilterHelper
module FilterHelper
def build_condition_query(model_filters, query_hash, current_index)
current_filter = model_filters[query_hash['attribute_key']]
@@ -89,18 +89,4 @@ module Filters::FilterHelper
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
def conversation_status_values(values)
return Conversation.statuses.values if values.include?('all')
values.map { |x| Conversation.statuses[x.to_sym] }
end
def conversation_priority_values(values)
values.map { |x| Conversation.priorities[x.to_sym] }
end
def message_type_values(values)
values.map { |x| Message.message_types[x.to_sym] }
end
end
@@ -2,10 +2,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import {
buildAttributesFilterTypes,
CONTACT_ATTRIBUTES,
} from './helper/filterHelper.js';
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
import countries from 'shared/constants/countries.js';
/**
@@ -62,11 +59,7 @@ export function useContactFilterContext() {
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const customFilterTypes = computed(() =>
buildAttributesFilterTypes(
contactAttributes.value,
getOperatorTypes,
'contact'
)
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
);
/**
@@ -74,8 +67,8 @@ export function useContactFilterContext() {
*/
const filterTypes = computed(() => [
{
attributeKey: CONTACT_ATTRIBUTES.NAME,
value: CONTACT_ATTRIBUTES.NAME,
attributeKey: 'name',
value: 'name',
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
inputType: 'plainText',
@@ -84,8 +77,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.EMAIL,
value: CONTACT_ATTRIBUTES.EMAIL,
attributeKey: 'email',
value: 'email',
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
inputType: 'plainText',
@@ -94,8 +87,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER,
value: CONTACT_ATTRIBUTES.PHONE_NUMBER,
attributeKey: 'phone_number',
value: 'phone_number',
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
inputType: 'plainText',
@@ -104,8 +97,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER,
value: CONTACT_ATTRIBUTES.IDENTIFIER,
attributeKey: 'identifier',
value: 'identifier',
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
inputType: 'plainText',
@@ -114,8 +107,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE,
value: CONTACT_ATTRIBUTES.COUNTRY_CODE,
attributeKey: 'country_code',
value: 'country_code',
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
@@ -125,8 +118,8 @@ export function useContactFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: CONTACT_ATTRIBUTES.CITY,
value: CONTACT_ATTRIBUTES.CITY,
attributeKey: 'city',
value: 'city',
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
inputType: 'plainText',
@@ -135,8 +128,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
attributeKey: 'created_at',
value: 'created_at',
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
inputType: 'date',
@@ -145,8 +138,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
inputType: 'date',
@@ -155,8 +148,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.REFERER,
value: CONTACT_ATTRIBUTES.REFERER,
attributeKey: 'referer',
value: 'referer',
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
inputType: 'plainText',
@@ -165,8 +158,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
value: CONTACT_ATTRIBUTES.BLOCKED,
attributeKey: 'blocked',
value: 'blocked',
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
inputType: 'searchSelect',
@@ -1,35 +1,3 @@
/**
* Standard attributes of the conversation model
*/
export const CONVERSATION_ATTRIBUTES = {
STATUS: 'status',
PRIORITY: 'priority',
ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id',
DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
BROWSER_LANGUAGE: 'browser_language',
COUNTRY_CODE: 'country_code',
REFERER: 'referer',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
};
export const CONTACT_ATTRIBUTES = {
NAME: 'name',
EMAIL: 'email',
PHONE_NUMBER: 'phone_number',
IDENTIFIER: 'identifier',
COUNTRY_CODE: 'country_code',
CITY: 'city',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
BLOCKED: 'blocked',
};
/**
* Determines the input type for a custom attribute based on its key
* @param {string} key - The attribute display type key
@@ -52,37 +20,24 @@ export const getCustomAttributeInputType = key => {
/**
* Builds filter types for custom attributes
* This also removes any conflicting attributes
* @param {Array} attributes - The attributes array
* @param {Function} getOperatorTypes - Function to get operator types
* @returns {Array} Array of filter types
*/
export const buildAttributesFilterTypes = (
attributes,
getOperatorTypes,
filterModel = 'conversation'
) => {
const standardAttributes = Object.values(
filterModel === 'conversation'
? CONVERSATION_ATTRIBUTES
: CONTACT_ATTRIBUTES
);
return attributes
.filter(attr => !standardAttributes.includes(attr.attributeKey))
.map(attr => ({
attributeKey: attr.attributeKey,
value: attr.attributeKey,
attributeName: attr.attributeDisplayName,
label: attr.attributeDisplayName,
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
filterOperators: getOperatorTypes(attr.attributeDisplayType),
options:
attr.attributeDisplayType === 'list'
? attr.attributeValues.map(item => ({ id: item, name: item }))
: [],
attributeModel: 'customAttributes',
}));
export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
return attributes.map(attr => ({
attributeKey: attr.attributeKey,
value: attr.attributeKey,
attributeName: attr.attributeDisplayName,
label: attr.attributeDisplayName,
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
filterOperators: getOperatorTypes(attr.attributeDisplayType),
options:
attr.attributeDisplayType === 'list'
? attr.attributeValues.map(item => ({ id: item, name: item }))
: [],
attributeModel: 'customAttributes',
}));
};
/**
@@ -3,10 +3,7 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper';
import { buildAttributesFilterTypes } from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
@@ -73,11 +70,7 @@ export function useConversationFilterContext() {
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const customFilterTypes = computed(() =>
buildAttributesFilterTypes(
conversationAttributes.value,
getOperatorTypes,
'conversation'
)
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
);
/**
@@ -85,8 +78,8 @@ export function useConversationFilterContext() {
*/
const filterTypes = computed(() => [
{
attributeKey: CONVERSATION_ATTRIBUTES.STATUS,
value: CONVERSATION_ATTRIBUTES.STATUS,
attributeKey: 'status',
value: 'status',
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
label: t('FILTER.ATTRIBUTES.STATUS'),
inputType: 'multiSelect',
@@ -101,24 +94,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.PRIORITY,
value: CONVERSATION_ATTRIBUTES.PRIORITY,
attributeName: t('FILTER.ATTRIBUTES.PRIORITY'),
label: t('FILTER.ATTRIBUTES.PRIORITY'),
inputType: 'multiSelect',
options: ['low', 'medium', 'high', 'urgent'].map(id => {
return {
id,
name: t(`CONVERSATION.PRIORITY.OPTIONS.${id.toUpperCase()}`),
};
}),
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
value: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
attributeKey: 'assignee_id',
value: 'assignee_id',
attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
inputType: 'searchSelect',
@@ -133,8 +110,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.INBOX_ID,
value: CONVERSATION_ATTRIBUTES.INBOX_ID,
attributeKey: 'inbox_id',
value: 'inbox_id',
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
inputType: 'searchSelect',
@@ -149,8 +126,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.TEAM_ID,
value: CONVERSATION_ATTRIBUTES.TEAM_ID,
attributeKey: 'team_id',
value: 'team_id',
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
inputType: 'searchSelect',
@@ -160,8 +137,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
attributeKey: 'display_id',
value: 'display_id',
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
inputType: 'plainText',
@@ -170,8 +147,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
value: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
attributeKey: 'campaign_id',
value: 'campaign_id',
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
inputType: 'searchSelect',
@@ -184,8 +161,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.LABELS,
value: CONVERSATION_ATTRIBUTES.LABELS,
attributeKey: 'labels',
value: 'labels',
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
label: t('FILTER.ATTRIBUTES.LABELS'),
inputType: 'multiSelect',
@@ -208,8 +185,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
value: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
attributeKey: 'browser_language',
value: 'browser_language',
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
inputType: 'searchSelect',
@@ -219,8 +196,8 @@ export function useConversationFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
attributeKey: 'country_code',
value: 'country_code',
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
@@ -230,8 +207,8 @@ export function useConversationFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
value: CONVERSATION_ATTRIBUTES.REFERER,
attributeKey: 'referer',
value: 'referer',
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
inputType: 'plainText',
@@ -240,8 +217,8 @@ export function useConversationFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.CREATED_AT,
value: CONVERSATION_ATTRIBUTES.CREATED_AT,
attributeKey: 'created_at',
value: 'created_at',
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
inputType: 'date',
@@ -250,8 +227,8 @@ export function useConversationFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
value: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
inputType: 'date',
@@ -49,7 +49,7 @@ const isSent = computed(() => {
isASmsInbox.value ||
isATelegramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
return status.value === MESSAGE_STATUS.SENT;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
@@ -67,7 +67,7 @@ const isDelivered = computed(() => {
isASmsInbox.value ||
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
return status.value === MESSAGE_STATUS.DELIVERED;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
@@ -88,7 +88,7 @@ const isRead = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
return status.value === MESSAGE_STATUS.READ;
}
if (isAWebWidgetInbox.value || isAPIInbox.value) {
@@ -39,14 +39,13 @@ export default {
},
mixins: [globalConfigMixin],
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { isEditorHotKeyEnabled } = useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
return {
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
updateUISettings,
};
},
data() {
@@ -2,15 +2,8 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
queue_as :scheduled_jobs
def perform
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
email_inboxes.find_each(batch_size: 100) do |inbox|
::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if should_fetch_emails?(inbox)
Inbox.where(channel_type: 'Channel::Email').all.find_each(batch_size: 100) do |inbox|
::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if inbox.channel.imap_enabled
end
end
private
def should_fetch_emails?(inbox)
inbox.channel.imap_enabled && !inbox.account.suspended?
end
end
-14
View File
@@ -22,12 +22,6 @@
# index_custom_attribute_definitions_on_account_id (account_id)
#
class CustomAttributeDefinition < ApplicationRecord
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
}.freeze
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
validates :attribute_display_name, presence: true
@@ -37,7 +31,6 @@ class CustomAttributeDefinition < ApplicationRecord
validates :attribute_display_type, presence: true
validates :attribute_model, presence: true
validate :attribute_must_not_conflict, on: :create
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 }
enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5, list: 6, checkbox: 7 }
@@ -55,11 +48,4 @@ class CustomAttributeDefinition < ApplicationRecord
def update_widget_pre_chat_custom_fields
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
def attribute_must_not_conflict
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
end
end
-50
View File
@@ -1,50 +0,0 @@
# == Schema Information
#
# Table name: delete_requests
#
# id :bigint not null, primary key
# completed_at :datetime
# confirmation_code :string not null
# deleted_type :string
# status :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# deleted_id :integer
# fb_id :string not null
#
# Indexes
#
# index_delete_requests_on_account_id (account_id)
# index_delete_requests_on_confirmation_code (confirmation_code) UNIQUE
#
class DeleteRequest < ApplicationRecord
belongs_to :account, optional: true
enum :status, %w[pending complete].index_by(&:itself), default: :pending
before_create :ensure_unqiue_confirmation_code
private
def ensure_unqiue_confirmation_code
max_retries = 3
retry_count = 0
begin
self.confirmation_code = generate_confirmation_code
raise ActiveRecord::RecordNotUnique if self.class.exists?(confirmation_code: confirmation_code)
rescue ActiveRecord::RecordNotUnique
retry_count += 1
if retry_count > max_retries
# it's really really unlikely that we'll ever ever hit this case, but just in case
self.confirmation_code = generate_confirmation_code + SecureRandom.alphanumeric(3)
else
retry
end
end
end
def generate_confirmation_code
SecureRandom.uuid.delete('-')
end
end
+12 -9
View File
@@ -1,7 +1,7 @@
require 'json'
class FilterService
include Filters::FilterHelper
include FilterHelper
include CustomExceptions::CustomFilter
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
@@ -43,15 +43,18 @@ class FilterService
end
def filter_values(query_hash)
attribute_key = query_hash['attribute_key']
values = query_hash['values']
case query_hash['attribute_key']
when 'status'
return Conversation.statuses.values if query_hash['values'].include?('all')
return conversation_status_values(values) if attribute_key == 'status'
return conversation_priority_values(values) if attribute_key == 'priority'
return message_type_values(values) if attribute_key == 'message_type'
return downcase_array_values(values) if attribute_key == 'content'
case_insensitive_values(query_hash)
query_hash['values'].map { |x| Conversation.statuses[x.to_sym] }
when 'message_type'
query_hash['values'].map { |x| Message.message_types[x.to_sym] }
when 'content'
downcase_array_values(query_hash['values'])
else
case_insensitive_values(query_hash)
end
end
def downcase_array_values(values)
@@ -1,19 +1,11 @@
<% content_for(:title) do %>
Configure Settings - <%= @config.titleize %>
<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">
<%= content_for(:title) %>
</h1>
</header>
<style>
.eye-icon.eye-hide path {
d: path('M3 3l18 18M10.5 10.677a2 2 0 002.823 2.823M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c4.008 0 6.701 3.009 9 6a15.66 15.66 0 01-1.078 1.5');
}
</style>
<section class="main-content__body">
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
<% @allowed_configs.each do |key| %>
@@ -23,36 +15,18 @@
</div>
<div class="-mt-2 field-unit__field ">
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
class: "mt-2 border border-slate-100 p-1 rounded-md"
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
rows: 12,
wrap: 'off',
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
%>
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
<div class="relative">
<%= form.password_field "app_config[#{key}]",
id: "app_config_#{key}",
value: @app_config[key],
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
%>
<button
type="button"
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
data-target="app_config_<%= key %>"
>
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
</svg>
</button>
</div>
<% else %>
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
<% end %>
@@ -69,26 +43,3 @@
</div>
<% end %>
</section>
<% content_for :javascript do %>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.toggle-password').forEach(button => {
button.addEventListener('click', () => {
const targetId = button.dataset.target;
const input = document.getElementById(targetId);
const type = input.type === 'password' ? 'text' : 'password';
input.type = type;
// Toggle icon
const svg = button.querySelector('.eye-icon');
if (type === 'password') {
svg.classList.remove('eye-hide')
} else {
svg.classList.add('eye-hide')
}
});
});
});
</script>
<% end %>
@@ -1,63 +0,0 @@
<%#
# Filters
This partial is used on the `index` page to display available filters
for a collection of resources.
## Local variables:
- `page`:
An instance of [Administrate::Page::Collection][1].
Contains helper methods to help display a table,
and knows which attributes should be displayed in the resource's table.
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection
%>
<%
# Get the dashboard class name from the resource name
resource_name = page.resource_name.classify
dashboard_class_name = "#{resource_name}Dashboard"
dashboard_class = dashboard_class_name.constantize
# Get the current filter if any
current_filter = nil
if params[:search] && params[:search].include?(':')
current_filter = params[:search].split(':').first
end
%>
<% if dashboard_class.const_defined?(:COLLECTION_FILTERS) && !dashboard_class::COLLECTION_FILTERS.empty? %>
<div class="flex items-center bg-gray-100 border-0 rounded-md shadow-none relative w-[260px]">
<div class="flex items-center h-10 px-2 w-full">
<div class="flex items-center justify-center flex-shrink-0 mr-2 text-gray-500" title="Filter by">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</div>
<div class="flex-1 h-full min-w-0 relative">
<select id="filter-select" class="appearance-none bg-gray-100 border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
<option value="">All records</option>
<% dashboard_class::COLLECTION_FILTERS.each do |filter_name, _| %>
<option value="<%= filter_name %>" <%= 'selected' if filter_name.to_s == current_filter %>>
<%= filter_name.to_s.titleize %>
</option>
<% end %>
</select>
<% if current_filter %>
<a href="?" class="flex items-center justify-center rounded-full text-gray-500 text-xl font-bold h-[18px] w-[18px] leading-none absolute right-5 top-1/2 -translate-y-1/2 no-underline z-2 hover:text-gray-900" title="Clear filter">×</a>
<% end %>
</div>
</div>
</div>
<script>
function applyFilter(filterName) {
if (filterName) {
window.location.href = "?search=" + encodeURIComponent(filterName) + "%3A";
} else {
window.location.href = "?";
}
}
</script>
<% end %>
@@ -1,24 +0,0 @@
<form class="search" role="search">
<label class="search__label" for="search">
<svg class="search__eyeglass-icon" role="img">
<title>
<%= t("administrate.search.label", resource: resource_name) %>
</title>
<use xlink:href="#icon-eyeglass" />
</svg>
</label>
<input class="search__input"
id="search"
type="search"
name="search"
placeholder="<%= t("administrate.search.label", resource: resource_name) %>"
value="<%= search_term %>">
<%= link_to clear_search_params, class: "search__clear-link" do %>
<svg class="search__clear-icon" role="img">
<title><%= t("administrate.search.clear") %></title>
<use xlink:href="#icon-cancel" />
</svg>
<% end %>
</form>
@@ -28,36 +28,27 @@ It renders the `_table` partial to display details about the resources.
<% end %>
<header class="main-content__header" role="banner">
<div class="flex items-center justify-between w-full">
<h1 class="main-content__page-title m-0 mr-6" id="page-title">
<%= content_for(:title) %>
</h1>
<h1 class="main-content__page-title" id="page-title">
<%= content_for(:title) %>
</h1>
<div class="flex items-center">
<% if show_search_bar %>
<div class="flex items-center">
<%= render("filters", page: page) %>
<div class="ml-3">
<%= render(
"search",
search_term: search_term,
resource_name: display_resource_name(page.resource_name)
) %>
</div>
</div>
<% end %>
<% if show_search_bar %>
<%= render(
"search",
search_term: search_term,
resource_name: display_resource_name(page.resource_name)
) %>
<% end %>
<div class="whitespace-nowrap ml-4">
<%= link_to(
t(
"administrate.actions.new_resource",
name: page.resource_name.titleize.downcase
),
[:new, namespace, page.resource_path.to_sym],
class: "button",
) if accessible_action?(new_resource, :new) %>
</div>
</div>
<div>
<%= link_to(
t(
"administrate.actions.new_resource",
name: page.resource_name.titleize.downcase
),
[:new, namespace, page.resource_path.to_sym],
class: "button",
) if accessible_action?(new_resource, :new) %>
</div>
</header>
-14
View File
@@ -103,16 +103,13 @@
display_title: 'Facebook Verify Token'
description: 'The verify token used for Facebook Messenger Webhook'
locked: false
type: secret
- name: FB_APP_SECRET
display_title: 'Facebook App Secret'
locked: false
type: secret
- name: IG_VERIFY_TOKEN
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
type: secret
- name: FACEBOOK_API_VERSION
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
@@ -134,7 +131,6 @@
- name: AZURE_APP_SECRET
display_title: 'Azure App Secret'
locked: false
type: secret
# End of Microsoft Email Channel Config
# MARK: Captain Config
@@ -142,7 +138,6 @@
display_title: 'OpenAI API Key'
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'
locked: false
type: secret
- name: CAPTAIN_OPEN_AI_MODEL
display_title: 'OpenAI Model'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
@@ -151,7 +146,6 @@
display_title: 'FireCrawl API Key (optional)'
description: 'The FireCrawl API key for the Captain AI service'
locked: false
type: secret
- name: CAPTAIN_CLOUD_PLAN_LIMITS
display_title: 'Captain Cloud Plan Limits'
description: 'The limits for the Captain AI service for different plans'
@@ -166,13 +160,11 @@
display_title: 'Inbox Token'
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
locked: false
type: secret
- name: CHATWOOT_INBOX_HMAC_KEY
value:
display_title: 'Inbox HMAC Key'
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
locked: false
type: secret
- name: CHATWOOT_CLOUD_PLANS
display_title: 'Cloud Plans'
value:
@@ -188,12 +180,10 @@
value:
display_title: 'Analytics Token'
description: 'The June.so analytics token for Chatwoot cloud'
type: secret
- name: CLEARBIT_API_KEY
value:
display_title: 'Clearbit API Key'
description: 'This API key is used for onboarding the users, to pre-fill account data.'
type: secret
- name: DASHBOARD_SCRIPTS
value:
display_title: 'Dashboard Scripts'
@@ -216,14 +206,12 @@
- name: CHATWOOT_SUPPORT_WEBSITE_TOKEN
value:
description: 'The Chatwoot website token, used to identify the Chatwoot inbox and display the "Contact Support" option on the billing page'
type: secret
- name: CHATWOOT_SUPPORT_SCRIPT_URL
value:
description: 'The Chatwoot script base URL, to display the "Contact Support" option on the billing page'
- name: CHATWOOT_SUPPORT_IDENTIFIER_HASH
value:
description: 'The Chatwoot identifier hash, to validate the contact in the live chat window.'
type: secret
- name: ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
display_title: Webhook URL to post security analysis
value:
@@ -257,7 +245,6 @@
display_title: 'Firebase Credentials'
value:
locked: false
type: secret
description: 'Contents on your firebase credentials json file'
## ------ End of Configs added for FCM v1 notifications ------ ##
@@ -272,5 +259,4 @@
value:
locked: false
description: 'Linear client secret'
type: secret
## ------ End of Configs added for Linear ------ ##
-2
View File
@@ -82,8 +82,6 @@ en:
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
-5
View File
@@ -453,11 +453,6 @@ Rails.application.routes.draw do
resource :callback, only: [:show]
end
namespace :facebook do
resources :delete, only: [:create]
resources :confirm, only: [:show]
end
namespace :linear do
resource :callback, only: [:show]
end
@@ -1,17 +0,0 @@
class CreateDeleteRequests < ActiveRecord::Migration[7.0]
def change
create_table :delete_requests do |t|
t.string :fb_id, null: false
t.string :status, null: false
t.string :confirmation_code, null: false
t.datetime :completed_at
t.string :deleted_type
t.integer :deleted_id
t.references :account, index: true, null: true
t.timestamps
end
add_index :delete_requests, :confirmation_code, unique: true
end
end
+1 -15
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_06_081627) do
ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -639,20 +639,6 @@ ActiveRecord::Schema[7.0].define(version: 2025_03_06_081627) do
t.index ["account_id"], name: "index_data_imports_on_account_id"
end
create_table "delete_requests", force: :cascade do |t|
t.string "fb_id", null: false
t.string "status", null: false
t.string "confirmation_code", null: false
t.datetime "completed_at"
t.string "deleted_type"
t.integer "deleted_id"
t.bigint "account_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_delete_requests_on_account_id"
t.index ["confirmation_code"], name: "index_delete_requests_on_confirmation_code", unique: true
end
create_table "email_templates", force: :cascade do |t|
t.string "name", null: false
t.text "body", null: false
-3
View File
@@ -41,7 +41,4 @@ module Redis::RedisKeys
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
## Meta deletion flags
META_DELETE_PROCESSING = 'META_DELETE_PROCESSING::%<id>s'.freeze
end
@@ -89,30 +89,6 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
json_response = response.parsed_body
expect(json_response['attribute_key']).to eq 'developer_id'
end
context 'when creating with a conflicting attribute_key' do
let(:standard_key) { CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation].first }
let(:conflicting_payload) do
{
custom_attribute_definition: {
attribute_display_name: 'Conflicting Key',
attribute_key: standard_key,
attribute_model: 'conversation_attribute',
attribute_display_type: 'text'
}
}
end
it 'returns error for conflicting key' do
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
headers: user.create_new_auth_token,
params: conflicting_payload
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to include('The provided key is not allowed as it might conflict with default attributes.')
end
end
end
end
@@ -1,35 +0,0 @@
require 'rails_helper'
RSpec.describe Facebook::ConfirmController do
describe 'GET #show' do
let(:user_id) { '12345' }
context 'when deletion is in progress' do
before do
redis_key = format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id)
allow(Redis::Alfred).to receive(:get).with(redis_key).and_return('true')
end
it 'returns processing status' do
get :show, params: { id: user_id }
expect(response).to have_http_status(:ok)
expect(response.body).to eq('Processing')
end
end
context 'when deletion is completed' do
before do
redis_key = format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id)
allow(Redis::Alfred).to receive(:get).with(redis_key).and_return(nil)
end
it 'returns success message' do
get :show, params: { id: user_id }
expect(response).to have_http_status(:ok)
expect(response.body).to eq('Data Deleted Successfully')
end
end
end
end
@@ -1,59 +0,0 @@
require 'rails_helper'
RSpec.describe Facebook::DeleteController do
describe 'POST #create' do
let(:user_id) { '12345' }
let(:app_secret) { 'test_app_secret' }
let(:frontend_url) { ENV.fetch('FRONTEND_URL', nil) }
let(:redis_key) { format(Redis::Alfred::META_DELETE_PROCESSING, id: user_id) }
before do
allow(GlobalConfigService).to receive(:load).with('FB_APP_SECRET', '').and_return(app_secret)
end
context 'with valid signed request' do
let(:payload) { { 'user_id' => user_id }.to_json }
let(:encoded_payload) { Base64.urlsafe_encode64(payload) }
let(:signature) { OpenSSL::HMAC.digest('sha256', app_secret, encoded_payload) }
let(:encoded_signature) { Base64.urlsafe_encode64(signature) }
let(:signed_request) { "#{encoded_signature}.#{encoded_payload}" }
before do
allow(Redis::Alfred).to receive(:set)
allow(Channels::Facebook::RedactContactDataJob).to receive(:perform_later)
end
it 'processes the delete request and returns status URL' do
post :create, params: { signed_request: signed_request }
expect(Redis::Alfred).to have_received(:set).with(redis_key, true)
expect(Channels::Facebook::RedactContactDataJob).to have_received(:perform_later).with(user_id)
expect(response).to have_http_status(:ok)
response_json = response.parsed_body
# NOTE: this is an important condition since this is exactly the format facebook expects
expect(response_json['url']).to eq("#{frontend_url}/facebook/confirm/#{user_id}")
expect(response_json['confirmation_code']).to eq(user_id)
end
end
context 'with invalid signed request' do
let(:invalid_signed_request) { 'invalid_signature.invalid_payload' }
before do
# Mock the Base64.urlsafe_decode64 to return valid values for testing
allow(Base64).to receive(:urlsafe_decode64).with('invalid_signature').and_return('decoded_signature')
allow(Base64).to receive(:urlsafe_decode64).with('invalid_payload').and_return('{"user_id":"12345"}')
allow(JSON).to receive(:parse).with('{"user_id":"12345"}').and_return({ 'user_id' => user_id })
allow(OpenSSL::HMAC).to receive(:digest).and_return('different_signature')
end
it 'returns an error for invalid signature' do
post :create, params: { signed_request: invalid_signed_request }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
@@ -2,19 +2,11 @@ require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, account: account)
end
let(:imap_email_channel_suspended) do
create(:channel_email, imap_enabled: true, account: suspended_account)
end
let(:disabled_imap_channel) do
create(:channel_email, imap_enabled: false, account: account)
create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', imap_port: 993, imap_login: 'imap@gmail.com',
imap_password: 'password', account: account)
end
let(:email_inbox) { create(:inbox, channel: imap_email_channel, account: account) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
@@ -22,26 +14,9 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
end
context 'when called' do
it 'fetches emails only for active accounts with imap enabled' do
# Should call perform_later only once for the active, imap-enabled inbox
it 'fetch all the email channels' do
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(imap_email_channel).once
# Should not call for suspended account or disabled IMAP channels
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
described_class.perform_now
end
it 'skips suspended accounts' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
described_class.perform_now
end
it 'skips disabled imap channels' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
described_class.perform_now
end
end
@@ -77,63 +77,6 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to be conversations.count
end
it 'filter conversations by priority' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
params[:payload] = [
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['high'],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
expect(result[:conversations].length).to eq 1
expect(result[:conversations][0][:id]).to eq conversation.id
end
it 'filter conversations by multiple priority values' do
high_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
urgent_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
params[:payload] = [
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: %w[high urgent],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
expect(result[:conversations].length).to eq 2
expect(result[:conversations].pluck(:id)).to include(high_priority.id, urgent_priority.id)
end
it 'filter conversations with not_equal_to priority operator' do
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
low_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
medium_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :medium)
params[:payload] = [
{
attribute_key: 'priority',
filter_operator: 'not_equal_to',
values: %w[high urgent],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
# Only include conversations with medium and low priority, excluding high and urgent
expect(result[:conversations].length).to eq 2
expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
end
it 'filter conversations by additional_attributes and status with pagination' do
params[:payload] = payload
params[:page] = 2