Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b545653a93 | ||
|
|
0f02455224 | ||
|
|
1c2c8f26fc | ||
|
|
82408a8e03 | ||
|
|
c17c9b10d9 | ||
|
|
7e1458fd32 | ||
|
|
d017156f32 | ||
|
|
c1f6d9f76f | ||
|
|
e5d41f4b03 | ||
|
|
ed562832a6 | ||
|
|
2be2c9aa04 | ||
|
|
d9b86182f2 | ||
|
|
79e46d1758 | ||
|
|
5895d7b2bd | ||
|
|
bf5220e881 | ||
|
|
6bc29bfef2 | ||
|
|
9dddd1b020 | ||
|
|
bc294d8829 | ||
|
|
4b243a44ee | ||
|
|
f2961c4f8e | ||
|
|
c4ea779759 | ||
|
|
cfb74b12f9 | ||
|
|
63bea5219b | ||
|
|
74c611325f | ||
|
|
71ebacc46e | ||
|
|
770e395f17 | ||
|
|
4a925619a1 | ||
|
|
db537b5c86 | ||
|
|
12d7be62d3 | ||
|
|
cd1dbb67eb | ||
|
|
114596db19 | ||
|
|
96a3999c8b | ||
|
|
6040e50265 |
@@ -16,7 +16,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
- name: get installer
|
||||
|
||||
+2
-2
@@ -561,7 +561,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.11)
|
||||
rack (2.2.12)
|
||||
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 (0.13.0)
|
||||
uri (1.0.3)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="reset"],
|
||||
input[type="submit"],
|
||||
.button {
|
||||
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) {
|
||||
appearance: none;
|
||||
background-color: $color-woot;
|
||||
border: 0;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
.icon-container {
|
||||
margin-right: 2px;
|
||||
|
||||
}
|
||||
|
||||
.value-container {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
@@ -78,7 +78,11 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {}.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
|
||||
|
||||
# Overwrite this method to customize how accounts are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {}.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
|
||||
|
||||
# Overwrite this method to customize how users are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module FilterHelper
|
||||
module Filters::FilterHelper
|
||||
def build_condition_query(model_filters, query_hash, current_index)
|
||||
current_filter = model_filters[query_hash['attribute_key']]
|
||||
|
||||
@@ -89,4 +89,18 @@ module 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,7 +2,10 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
CONTACT_ATTRIBUTES,
|
||||
} from './helper/filterHelper.js';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
|
||||
/**
|
||||
@@ -59,7 +62,11 @@ export function useContactFilterContext() {
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
|
||||
buildAttributesFilterTypes(
|
||||
contactAttributes.value,
|
||||
getOperatorTypes,
|
||||
'contact'
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -67,8 +74,8 @@ export function useContactFilterContext() {
|
||||
*/
|
||||
const filterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: 'name',
|
||||
value: 'name',
|
||||
attributeKey: CONTACT_ATTRIBUTES.NAME,
|
||||
value: CONTACT_ATTRIBUTES.NAME,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
inputType: 'plainText',
|
||||
@@ -77,8 +84,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'email',
|
||||
value: 'email',
|
||||
attributeKey: CONTACT_ATTRIBUTES.EMAIL,
|
||||
value: CONTACT_ATTRIBUTES.EMAIL,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
inputType: 'plainText',
|
||||
@@ -87,8 +94,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'phone_number',
|
||||
value: 'phone_number',
|
||||
attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||
value: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
inputType: 'plainText',
|
||||
@@ -97,8 +104,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'identifier',
|
||||
value: 'identifier',
|
||||
attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||
value: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
inputType: 'plainText',
|
||||
@@ -107,8 +114,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
value: 'country_code',
|
||||
attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -118,8 +125,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'city',
|
||||
value: 'city',
|
||||
attributeKey: CONTACT_ATTRIBUTES.CITY,
|
||||
value: CONTACT_ATTRIBUTES.CITY,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
inputType: 'plainText',
|
||||
@@ -128,8 +135,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
inputType: 'date',
|
||||
@@ -138,8 +145,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
inputType: 'date',
|
||||
@@ -148,8 +155,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeKey: CONTACT_ATTRIBUTES.REFERER,
|
||||
value: CONTACT_ATTRIBUTES.REFERER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
@@ -158,8 +165,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'blocked',
|
||||
value: 'blocked',
|
||||
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
value: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
inputType: 'searchSelect',
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -20,24 +52,37 @@ 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) => {
|
||||
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',
|
||||
}));
|
||||
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',
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useChannelIcon } from 'next/icon/provider';
|
||||
import { buildAttributesFilterTypes } from './helper/filterHelper';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
} from './helper/filterHelper';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
@@ -70,7 +73,11 @@ export function useConversationFilterContext() {
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
|
||||
buildAttributesFilterTypes(
|
||||
conversationAttributes.value,
|
||||
getOperatorTypes,
|
||||
'conversation'
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -78,8 +85,8 @@ export function useConversationFilterContext() {
|
||||
*/
|
||||
const filterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: 'status',
|
||||
value: 'status',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.STATUS,
|
||||
value: CONVERSATION_ATTRIBUTES.STATUS,
|
||||
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
|
||||
label: t('FILTER.ATTRIBUTES.STATUS'),
|
||||
inputType: 'multiSelect',
|
||||
@@ -94,8 +101,24 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'assignee_id',
|
||||
value: 'assignee_id',
|
||||
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,
|
||||
attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -110,8 +133,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'inbox_id',
|
||||
value: 'inbox_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -126,8 +149,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'team_id',
|
||||
value: 'team_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -137,8 +160,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
value: 'display_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||
inputType: 'plainText',
|
||||
@@ -147,8 +170,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'campaign_id',
|
||||
value: 'campaign_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -161,8 +184,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'labels',
|
||||
value: 'labels',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.LABELS,
|
||||
value: CONVERSATION_ATTRIBUTES.LABELS,
|
||||
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
|
||||
label: t('FILTER.ATTRIBUTES.LABELS'),
|
||||
inputType: 'multiSelect',
|
||||
@@ -185,8 +208,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'browser_language',
|
||||
value: 'browser_language',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||
value: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -196,8 +219,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
value: 'country_code',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -207,8 +230,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
value: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
@@ -217,8 +240,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||
value: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||
inputType: 'date',
|
||||
@@ -227,8 +250,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
value: CONVERSATION_ATTRIBUTES.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 status.value === MESSAGE_STATUS.SENT;
|
||||
return sourceId.value && 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 status.value === MESSAGE_STATUS.DELIVERED;
|
||||
return sourceId.value && 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 status.value === MESSAGE_STATUS.READ;
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
|
||||
@@ -39,13 +39,14 @@ export default {
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
setup() {
|
||||
const { isEditorHotKeyEnabled } = useUISettings();
|
||||
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
||||
const { currentFontSize, updateFontSize } = useFontSize();
|
||||
|
||||
return {
|
||||
currentFontSize,
|
||||
updateFontSize,
|
||||
isEditorHotKeyEnabled,
|
||||
updateUISettings,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -2,8 +2,15 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform
|
||||
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
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_fetch_emails?(inbox)
|
||||
inbox.channel.imap_enabled && !inbox.account.suspended?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
# 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
|
||||
|
||||
@@ -31,6 +37,7 @@ 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 }
|
||||
@@ -48,4 +55,11 @@ 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
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# == 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
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'json'
|
||||
|
||||
class FilterService
|
||||
include FilterHelper
|
||||
include Filters::FilterHelper
|
||||
include CustomExceptions::CustomFilter
|
||||
|
||||
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
|
||||
@@ -43,18 +43,15 @@ class FilterService
|
||||
end
|
||||
|
||||
def filter_values(query_hash)
|
||||
case query_hash['attribute_key']
|
||||
when 'status'
|
||||
return Conversation.statuses.values if query_hash['values'].include?('all')
|
||||
attribute_key = query_hash['attribute_key']
|
||||
values = query_hash['values']
|
||||
|
||||
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
|
||||
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)
|
||||
end
|
||||
|
||||
def downcase_array_values(values)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
<% 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| %>
|
||||
@@ -15,18 +23,36 @@
|
||||
</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 %>
|
||||
@@ -43,3 +69,26 @@
|
||||
</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 %>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<%#
|
||||
# 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 %>
|
||||
@@ -0,0 +1,24 @@
|
||||
<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,27 +28,36 @@ It renders the `_table` partial to display details about the resources.
|
||||
<% end %>
|
||||
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title" id="page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
<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>
|
||||
|
||||
<% if show_search_bar %>
|
||||
<%= render(
|
||||
"search",
|
||||
search_term: search_term,
|
||||
resource_name: display_resource_name(page.resource_name)
|
||||
) %>
|
||||
<% end %>
|
||||
<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 %>
|
||||
|
||||
<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 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>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -103,13 +103,16 @@
|
||||
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`'
|
||||
@@ -131,6 +134,7 @@
|
||||
- name: AZURE_APP_SECRET
|
||||
display_title: 'Azure App Secret'
|
||||
locked: false
|
||||
type: secret
|
||||
# End of Microsoft Email Channel Config
|
||||
|
||||
# MARK: Captain Config
|
||||
@@ -138,6 +142,7 @@
|
||||
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'
|
||||
@@ -146,6 +151,7 @@
|
||||
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'
|
||||
@@ -160,11 +166,13 @@
|
||||
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:
|
||||
@@ -180,10 +188,12 @@
|
||||
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'
|
||||
@@ -206,12 +216,14 @@
|
||||
- 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:
|
||||
@@ -245,6 +257,7 @@
|
||||
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 ------ ##
|
||||
|
||||
@@ -259,4 +272,5 @@
|
||||
value:
|
||||
locked: false
|
||||
description: 'Linear client secret'
|
||||
type: secret
|
||||
## ------ End of Configs added for Linear ------ ##
|
||||
|
||||
@@ -82,6 +82,8 @@ 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
|
||||
|
||||
@@ -453,6 +453,11 @@ 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
+15
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_03_06_081627) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -639,6 +639,20 @@ ActiveRecord::Schema[7.0].define(version: 2025_02_28_185548) 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
|
||||
|
||||
@@ -41,4 +41,7 @@ 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,6 +89,30 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,59 @@
|
||||
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,11 +2,19 @@ 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, imap_address: 'imap.gmail.com', imap_port: 993, imap_login: 'imap@gmail.com',
|
||||
imap_password: 'password', account: account)
|
||||
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)
|
||||
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)
|
||||
@@ -14,9 +22,26 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
|
||||
end
|
||||
|
||||
context 'when called' do
|
||||
it 'fetch all the email channels' do
|
||||
it 'fetches emails only for active accounts with imap enabled' do
|
||||
# Should call perform_later only once for the active, imap-enabled inbox
|
||||
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,6 +77,63 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user