Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54308a4145 | ||
|
|
2eeec22868 | ||
|
|
ef50edb9e2 | ||
|
|
3ed80fa867 | ||
|
|
88f44c21f2 | ||
|
|
cf664ca2a0 | ||
|
|
485c561b18 | ||
|
|
766698cb3a | ||
|
|
feb8ec7879 | ||
|
|
082793290a | ||
|
|
12916ceca6 | ||
|
|
b5e81ccd3b | ||
|
|
b31040901e | ||
|
|
3c952e6a4a | ||
|
|
59184122f7 | ||
|
|
381423b1ae | ||
|
|
b7c9f779ad | ||
|
|
904d76420d | ||
|
|
fa907840c7 | ||
|
|
3760f206e8 | ||
|
|
a861257f73 | ||
|
|
143299f138 | ||
|
|
1f610c7fc5 | ||
|
|
232369cd5c | ||
|
|
4b40c61201 | ||
|
|
834c219b9b | ||
|
|
d0cd1c8887 | ||
|
|
188a8a67c0 | ||
|
|
682a2aea1c | ||
|
|
1dc66db516 | ||
|
|
bc04d81a5a | ||
|
|
d2c5c2f9a3 | ||
|
|
381fda270a | ||
|
|
b3c9d1f1a5 | ||
|
|
fd4376d062 | ||
|
|
a8f053921b | ||
|
|
8e15ada164 | ||
|
|
51093fd543 | ||
|
|
48e638cc56 | ||
|
|
e6dfc159ed | ||
|
|
900c2de89a | ||
|
|
29106ebc4b | ||
|
|
a579684b34 | ||
|
|
c29a9ad062 | ||
|
|
0ac015ce7a | ||
|
|
19474e0074 | ||
|
|
ce8190dacf | ||
|
|
aacf326ca1 | ||
|
|
e39c14460b | ||
|
|
5f6e17f307 | ||
|
|
4bf23adcf5 | ||
|
|
1f4d860d9d | ||
|
|
fdbb3bf4b1 | ||
|
|
c899cc825d | ||
|
|
eb972684b3 | ||
|
|
896473f03e | ||
|
|
8c43edbd50 |
@@ -39,6 +39,8 @@ gem 'rack-attack', '>= 6.7.0'
|
||||
gem 'down'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||
gem 'net-smtp', '~> 0.3.4'
|
||||
# Prevent CSV injection
|
||||
gem 'csv-safe'
|
||||
|
||||
@@ -74,7 +76,7 @@ gem 'devise_token_auth'
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.19.0'
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.1'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
|
||||
@@ -114,7 +116,7 @@ gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.14.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.1.3'
|
||||
gem 'sidekiq', '>= 7.2.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
|
||||
|
||||
+15
-14
@@ -105,12 +105,12 @@ GEM
|
||||
activerecord (>= 6.0, < 7.1)
|
||||
addressable (2.8.4)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
administrate (0.19.0)
|
||||
actionpack (>= 5.0)
|
||||
actionview (>= 5.0)
|
||||
activerecord (>= 5.0)
|
||||
jquery-rails (>= 4.0)
|
||||
kaminari (>= 1.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
activerecord (>= 6.0, < 8.0)
|
||||
jquery-rails (~> 4.6.0)
|
||||
kaminari (~> 1.2.2)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (1.0.1)
|
||||
@@ -169,7 +169,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.2.2)
|
||||
concurrent-ruby (1.2.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
@@ -461,7 +461,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.20.0)
|
||||
minitest (5.21.2)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
@@ -479,7 +479,7 @@ GEM
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.4.0)
|
||||
net-smtp (0.3.4)
|
||||
net-protocol
|
||||
netrc (0.11.0)
|
||||
newrelic-sidekiq-metrics (1.6.2)
|
||||
@@ -610,7 +610,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.19.0)
|
||||
redis-client (0.19.1)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
@@ -720,11 +720,11 @@ GEM
|
||||
sexp_processor (4.17.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.2.0)
|
||||
sidekiq (7.2.1)
|
||||
concurrent-ruby (< 2)
|
||||
connection_pool (>= 2.3.0)
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.14.0)
|
||||
redis-client (>= 0.19.0)
|
||||
sidekiq-cron (1.12.0)
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
@@ -840,7 +840,7 @@ DEPENDENCIES
|
||||
active_record_query_trace
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.19.0)
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.1)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
annotate
|
||||
@@ -903,6 +903,7 @@ DEPENDENCIES
|
||||
meta_request
|
||||
mock_redis
|
||||
neighbor
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth (>= 2.1.2)
|
||||
@@ -939,7 +940,7 @@ DEPENDENCIES
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.14.0)
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.1.3)
|
||||
sidekiq (>= 7.2.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.2.0)
|
||||
|
||||
@@ -12,6 +12,7 @@ class ContactMergeAction
|
||||
merge_conversations
|
||||
merge_messages
|
||||
merge_contact_inboxes
|
||||
merge_contact_notes
|
||||
merge_and_remove_mergee_contact
|
||||
end
|
||||
@base_contact
|
||||
@@ -33,6 +34,10 @@ class ContactMergeAction
|
||||
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
|
||||
end
|
||||
|
||||
def merge_contact_notes
|
||||
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id)
|
||||
end
|
||||
|
||||
def merge_messages
|
||||
Message.where(sender: @mergee_contact).update(sender: @base_contact)
|
||||
end
|
||||
|
||||
@@ -46,6 +46,14 @@ class V2::ReportBuilder
|
||||
}
|
||||
end
|
||||
|
||||
def short_summary
|
||||
{
|
||||
conversations_count: conversations.count,
|
||||
avg_first_response_time: avg_first_response_time_summary,
|
||||
avg_resolution_time: avg_resolution_time_summary
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_metrics
|
||||
if params[:type].equal?(:account)
|
||||
live_conversations
|
||||
|
||||
@@ -39,6 +39,8 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
|
||||
:attribute_display_type,
|
||||
:attribute_key,
|
||||
:attribute_model,
|
||||
:regex_pattern,
|
||||
:regex_cue,
|
||||
attribute_values: []
|
||||
)
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
def index
|
||||
@unread_count = notification_finder.unread_count
|
||||
@notifications = notification_finder.perform
|
||||
@count = @notifications.count
|
||||
@count = notification_finder.count
|
||||
end
|
||||
|
||||
def read_all
|
||||
|
||||
@@ -14,7 +14,14 @@ module AccessTokenAuthHelper
|
||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||
|
||||
@resource = @access_token.owner
|
||||
Current.user = @resource if [User, AgentBot].include?(@resource.class)
|
||||
Current.user = @resource if allowed_current_user_type?(@resource)
|
||||
end
|
||||
|
||||
def allowed_current_user_type?(resource)
|
||||
return true if resource.is_a?(User)
|
||||
return true if resource.is_a?(AgentBot)
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def validate_bot_access_token!
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Public::Api::V1::Portals::BaseController < PublicController
|
||||
before_action :show_plain_layout
|
||||
before_action :set_color_scheme
|
||||
before_action :set_global_config
|
||||
around_action :set_locale
|
||||
after_action :allow_iframe_requests
|
||||
|
||||
@@ -60,4 +61,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
portal
|
||||
render 'public/api/v1/portals/error/404', status: :not_found
|
||||
end
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,7 +53,7 @@ module Api::V1::InboxesHelper
|
||||
rescue StandardError => e
|
||||
raise StandardError, e.message
|
||||
ensure
|
||||
ChatwootExceptionTracker.new(e).capture_exception if e.present?
|
||||
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
|
||||
end
|
||||
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
|
||||
@@ -37,7 +37,7 @@ module Api::V2::Accounts::ReportsHelper
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
}
|
||||
)
|
||||
).summary
|
||||
).short_summary
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -29,11 +29,12 @@ export default {
|
||||
return fetchPromise;
|
||||
},
|
||||
hasAuthCookie() {
|
||||
return !!Cookies.getJSON('cw_d_session_info');
|
||||
return !!Cookies.get('cw_d_session_info');
|
||||
},
|
||||
getAuthData() {
|
||||
if (this.hasAuthCookie()) {
|
||||
return Cookies.getJSON('cw_d_session_info');
|
||||
const savedAuthInfo = Cookies.get('cw_d_session_info');
|
||||
return JSON.parse(savedAuthInfo || '{}');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -519,6 +519,9 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
teamId() {
|
||||
this.updateVirtualListProps('teamId', this.teamId);
|
||||
},
|
||||
activeTeam() {
|
||||
this.resetAndFetchData();
|
||||
},
|
||||
|
||||
@@ -126,18 +126,26 @@ import { required, url } from 'vuelidate/lib/validators';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
|
||||
import { isValidURL } from '../helper/URLHelper';
|
||||
import customAttributeMixin from '../mixins/customAttributeMixin';
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MultiselectDropdown,
|
||||
},
|
||||
mixins: [customAttributeMixin],
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
values: { type: Array, default: () => [] },
|
||||
value: { type: [String, Number, Boolean], default: '' },
|
||||
showActions: { type: Boolean, default: false },
|
||||
attributeType: { type: String, default: 'text' },
|
||||
attributeRegex: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
regexCue: { type: String, default: null },
|
||||
regexEnabled: { type: Boolean, default: false },
|
||||
attributeKey: { type: String, required: true },
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
@@ -204,6 +212,11 @@ export default {
|
||||
if (this.$v.editedValue.url) {
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
|
||||
}
|
||||
if (!this.$v.editedValue.regexValidation) {
|
||||
return this.regexCue
|
||||
? this.regexCue
|
||||
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
|
||||
}
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
},
|
||||
},
|
||||
@@ -221,7 +234,15 @@ export default {
|
||||
};
|
||||
}
|
||||
return {
|
||||
editedValue: { required },
|
||||
editedValue: {
|
||||
required,
|
||||
regexValidation: value => {
|
||||
return !(
|
||||
this.attributeRegex &&
|
||||
!this.getRegexp(this.attributeRegex).test(value)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@
|
||||
class="bg-slate-50 dark:bg-slate-700 rounded text-xxs font-medium mx-1 py-0 px-1"
|
||||
:class="
|
||||
isCountZero
|
||||
? `text-slate-300 dark:text-slate-700`
|
||||
? `text-slate-300 dark:text-slate-500`
|
||||
: `text-slate-700 dark:text-slate-50`
|
||||
"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
|
||||
<div :class="wrapClass">
|
||||
<div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert">
|
||||
<div
|
||||
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
|
||||
class="message-failed--alert"
|
||||
>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
|
||||
size="tiny"
|
||||
@@ -203,6 +206,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAnEmailInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-an-email-inbox="isAnEmailChannel"
|
||||
:is-instagram="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
|
||||
@click="toggleCountryDropdown"
|
||||
>
|
||||
<h5 v-if="activeCountry.emoji" class="mb-0">
|
||||
<h5 v-if="activeCountry" class="mb-0">
|
||||
{{ activeCountry.emoji }}
|
||||
</h5>
|
||||
<fluent-icon v-else icon="globe" class="fluent-icon" size="16" />
|
||||
|
||||
@@ -47,6 +47,8 @@ export const getCustomFields = ({ standardFields, customAttributes }) => {
|
||||
type: attribute.attribute_display_type,
|
||||
values: attribute.attribute_values,
|
||||
field_type: attribute.attribute_model,
|
||||
regex_pattern: attribute.regex_pattern,
|
||||
regex_cue: attribute.regex_cue,
|
||||
required: false,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
@@ -44,4 +44,18 @@ export default {
|
||||
created_at: '2021-11-29T10:20:04.563Z',
|
||||
},
|
||||
],
|
||||
customAttributesWithRegex: [
|
||||
{
|
||||
id: 2,
|
||||
attribute_description: 'Test contact Attribute',
|
||||
attribute_display_name: 'Test contact Attribute',
|
||||
attribute_display_type: 'text',
|
||||
attribute_key: 'test_contact_attribute',
|
||||
attribute_model: 'contact_attribute',
|
||||
attribute_values: Array(0),
|
||||
created_at: '2023-09-20T10:20:04.563Z',
|
||||
regex_pattern: '^w+$',
|
||||
regex_cue: 'It should be a combination of alphabets and numbers',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
} from '../preChat';
|
||||
import inboxFixture from './inboxFixture';
|
||||
|
||||
const { customFields, customAttributes } = inboxFixture;
|
||||
const { customFields, customAttributes, customAttributesWithRegex } =
|
||||
inboxFixture;
|
||||
describe('#Pre chat Helpers', () => {
|
||||
describe('getPreChatFields', () => {
|
||||
it('should return correct pre-chat fields form options passed', () => {
|
||||
@@ -27,7 +28,6 @@ describe('#Pre chat Helpers', () => {
|
||||
placeholder: 'Please enter your email address',
|
||||
type: 'email',
|
||||
field_type: 'standard',
|
||||
|
||||
required: false,
|
||||
enabled: false,
|
||||
},
|
||||
@@ -71,6 +71,26 @@ describe('#Pre chat Helpers', () => {
|
||||
values: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getCustomFields({
|
||||
standardFields: { pre_chat_fields: customFields.pre_chat_fields },
|
||||
customAttributes: customAttributesWithRegex,
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
enabled: false,
|
||||
label: 'Test contact Attribute',
|
||||
placeholder: 'Test contact Attribute',
|
||||
name: 'test_contact_attribute',
|
||||
required: false,
|
||||
field_type: 'contact_attribute',
|
||||
type: 'text',
|
||||
values: [],
|
||||
regex_pattern: '^w+$',
|
||||
regex_cue: 'It should be a combination of alphabets and numbers',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,17 @@
|
||||
"PLACEHOLDER": "Enter custom attribute key",
|
||||
"ERROR": "Key is required",
|
||||
"IN_VALID": "Invalid key"
|
||||
},
|
||||
"REGEX_PATTERN": {
|
||||
"LABEL": "Regex Pattern",
|
||||
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
|
||||
},
|
||||
"REGEX_CUE": {
|
||||
"LABEL": "Regex Cue",
|
||||
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
@@ -88,6 +99,17 @@
|
||||
"EMPTY_RESULT": {
|
||||
"404": "There are no custom attributes created",
|
||||
"NOT_FOUND": "There are no custom attributes configured"
|
||||
},
|
||||
"REGEX_PATTERN": {
|
||||
"LABEL": "Regex Pattern",
|
||||
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
|
||||
},
|
||||
"REGEX_CUE": {
|
||||
"LABEL": "Regex Cue",
|
||||
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +339,8 @@
|
||||
},
|
||||
"VALIDATIONS": {
|
||||
"REQUIRED": "Valid value is required",
|
||||
"INVALID_URL": "Invalid URL"
|
||||
"INVALID_URL": "Invalid URL",
|
||||
"INVALID_INPUT": "Invalid Input"
|
||||
}
|
||||
},
|
||||
"MERGE_CONTACTS": {
|
||||
|
||||
@@ -324,7 +324,8 @@
|
||||
"LAST_EDITED": "Last edited"
|
||||
},
|
||||
"COLUMNS": {
|
||||
"BY": "by"
|
||||
"BY": "by",
|
||||
"AUTHOR_NOT_AVAILABLE": "Author is not available"
|
||||
}
|
||||
},
|
||||
"EDIT_ARTICLE": {
|
||||
|
||||
@@ -569,7 +569,7 @@
|
||||
"UPDATE": "Update business hours settings",
|
||||
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
|
||||
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
|
||||
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
|
||||
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.",
|
||||
"DAY": {
|
||||
"ENABLE": "Enable availability for this day",
|
||||
"UNAVAILABLE": "Unavailable",
|
||||
|
||||
@@ -126,6 +126,6 @@
|
||||
"NO_CONTENT": "Содержимое отсутствует",
|
||||
"HIDE_QUOTED_TEXT": "Скрыть цитируемый текст",
|
||||
"SHOW_QUOTED_TEXT": "Показать цитируемый текст",
|
||||
"MESSAGE_READ": "Читать"
|
||||
"MESSAGE_READ": "Прочитано"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
methods: {
|
||||
getRegexp(regexPatternValue) {
|
||||
let lastSlash = regexPatternValue.lastIndexOf('/');
|
||||
return new RegExp(
|
||||
regexPatternValue.slice(1, lastSlash),
|
||||
regexPatternValue.slice(lastSlash + 1)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
+2
@@ -11,6 +11,8 @@
|
||||
emoji=""
|
||||
:value="attribute.value"
|
||||
:show-actions="true"
|
||||
:attribute-regex="attribute.regex_pattern"
|
||||
:regex-cue="attribute.regex_cue"
|
||||
:class="attributeClass"
|
||||
@update="onUpdate"
|
||||
@delete="onDelete"
|
||||
|
||||
@@ -11,9 +11,30 @@
|
||||
{{ title }}
|
||||
</h6>
|
||||
</router-link>
|
||||
<div class="author">
|
||||
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
|
||||
<span class="name">{{ articleAuthorName }}</span>
|
||||
<div class="flex gap-1 items-center">
|
||||
<Thumbnail
|
||||
v-if="author"
|
||||
:src="author.thumbnail"
|
||||
:username="author.name"
|
||||
size="16px"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip.right="
|
||||
$t('HELP_CENTER.TABLE.COLUMNS.AUTHOR_NOT_AVAILABLE')
|
||||
"
|
||||
class="flex items-center justify-center rounded w-4 h-4 bg-woot-100 dark:bg-woot-700"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="person"
|
||||
type="filled"
|
||||
size="10"
|
||||
class="text-woot-300 dark:text-woot-300"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-normal text-slate-500 dark:text-slate-200 text-sm">
|
||||
{{ articleAuthorName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
@@ -57,10 +78,12 @@ import timeMixin from 'dashboard/mixins/time';
|
||||
import portalMixin from '../mixins/portalMixin';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import EmojiOrIcon from '../../../../../shared/components/EmojiOrIcon.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmojiOrIcon,
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [timeMixin, portalMixin],
|
||||
props: {
|
||||
@@ -110,7 +133,7 @@ export default {
|
||||
}).format(this.views || 0);
|
||||
},
|
||||
articleAuthorName() {
|
||||
return this.author.name;
|
||||
return this.author?.name || '-';
|
||||
},
|
||||
labelColor() {
|
||||
switch (this.status) {
|
||||
@@ -189,15 +212,6 @@ export default {
|
||||
.article-block {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.author {
|
||||
.by {
|
||||
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
|
||||
}
|
||||
.name {
|
||||
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="my-0 py-0 px-4 grid grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900"
|
||||
class="my-0 py-0 px-4 grid grid-cols-6 z-10 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900"
|
||||
:class="{ draggable: onCategoryPage }"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
// import { defineProps } from 'vue';
|
||||
|
||||
import PriorityIcon from './components/PriorityIcon.vue';
|
||||
import StatusIcon from './components/StatusIcon.vue';
|
||||
import InboxNameAndId from './components/InboxNameAndId.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
|
||||
// const props = defineProps({
|
||||
// notificationItem: {
|
||||
// type: Object,
|
||||
// default: () => {},
|
||||
// },
|
||||
// });
|
||||
|
||||
const assigneeMeta = {
|
||||
thumbnail: '',
|
||||
name: 'Michael Johnson',
|
||||
};
|
||||
const { thumbnail, name } = assigneeMeta || {};
|
||||
const status = 'open';
|
||||
const priority = 'high';
|
||||
const inboxTypeMessage = 'Mentioned by Michael';
|
||||
const inboxMessage = 'What is the best way to get started?';
|
||||
const inbox = {
|
||||
inbox_id: 16787,
|
||||
inbox_name: 'Chatwoot Support',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex max-w-[360px] flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-500 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
|
||||
>
|
||||
<div class="flex relative items-center justify-between w-full">
|
||||
<div
|
||||
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
|
||||
/>
|
||||
<InboxNameAndId :inbox="inbox" />
|
||||
<div class="flex gap-2">
|
||||
<PriorityIcon :priority="priority" />
|
||||
<StatusIcon :status="status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between items-center w-full">
|
||||
<div class="flex gap-1.5 items-center max-w-[80%]">
|
||||
<Thumbnail
|
||||
v-if="assigneeMeta"
|
||||
:src="thumbnail"
|
||||
:username="name"
|
||||
size="20px"
|
||||
/>
|
||||
<div class="flex min-w-0">
|
||||
<span
|
||||
class="font-medium text-slate-800 dark:text-slate-100 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ inboxTypeMessage }}<span v-if="inboxTypeMessage">:</span>
|
||||
<span class="font-normal">{{ inboxMessage }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
|
||||
>
|
||||
10h ago
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const { inbox } = props;
|
||||
const { inbox_id: inboxId, inbox_name: inboxName } = inbox;
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-600 divide-x divide-slate-100 dark:divide-slate-600 bg-none"
|
||||
>
|
||||
<div class="flex items-center gap-0.5 py-0.5 px-1.5">
|
||||
<fluent-icon
|
||||
class="text-slate-600 dark:text-slate-300"
|
||||
icon="globe-desktop"
|
||||
size="14"
|
||||
/>
|
||||
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center py-0.5 px-1.5">
|
||||
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
|
||||
{{ inboxId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.HIGH"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#FFC291" />
|
||||
</svg>
|
||||
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.LOW"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#DDDDDD" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.URGENT"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#E5484D" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#E5484D" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#E5484D" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Pending -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.PENDING"
|
||||
class="h-3.5 w-3.5"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.1 0.0449978V1.8558C4.5486 2.2986 1.8 5.328 1.8 9C1.8 12.9762 5.0238 16.2 9 16.2C10.6641 16.2 12.195 15.6357 13.4154 14.688L14.6961 15.9687C13.1445 17.2377 11.16 18 9 18C4.0293 18 0 13.9707 0 9C0 4.3335 3.5523 0.495898 8.1 0.0449978ZM17.955 9.9C17.775 11.7099 17.0604 13.3623 15.9687 14.6952L14.688 13.4154C15.462 12.4191 15.9804 11.2149 16.1442 9.9H17.9559H17.955ZM9.9018 0.0449978C14.1534 0.467098 17.5338 3.8484 17.9568 8.1H16.1451C15.7392 4.8438 13.158 2.2626 9.9018 1.8558V0.0440979V0.0449978Z"
|
||||
class="fill-[#B9BBC6]"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Open -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.OPEN"
|
||||
class="h-3.5 w-3.5"
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.375 18.875C4.19733 18.875 0 14.6776 0 9.5C0 4.32233 4.19733 0.125 9.375 0.125C14.5526 0.125 18.75 4.32233 18.75 9.5C18.75 14.6776 14.5526 18.875 9.375 18.875ZM9.375 17C13.5172 17 16.875 13.6422 16.875 9.5C16.875 5.35786 13.5172 2 9.375 2C5.23286 2 1.875 5.35786 1.875 9.5C1.875 13.6422 5.23286 17 9.375 17Z"
|
||||
class="fill-[#ED8A5C]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Snoozed -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.SNOOZED"
|
||||
class="h-3.5 w-3.5"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 18C4.0293 18 0 13.9707 0 9C0 4.0293 4.0293 0 9 0C13.9707 0 18 4.0293 18 9C18 13.9707 13.9707 18 9 18ZM2.9961 12.9825C3.58766 13.8676 4.36812 14.6105 5.28129 15.1577C6.19446 15.7049 7.21761 16.0428 8.27707 16.147C9.33652 16.2513 10.4059 16.1193 11.4082 15.7606C12.4105 15.4019 13.3208 14.8254 14.0736 14.0726C14.8263 13.3198 15.4027 12.4094 15.7613 11.4071C16.12 10.4047 16.2518 9.33532 16.1475 8.27588C16.0431 7.21644 15.7052 6.19332 15.1579 5.2802C14.6106 4.36707 13.8676 3.58668 12.9825 2.9952C13.3706 4.3796 13.383 5.84237 13.0186 7.23318C12.6542 8.62399 11.926 9.89269 10.9089 10.9089C9.89277 11.9258 8.62423 12.6539 7.23359 13.0183C5.84296 13.3828 4.38037 13.3704 2.9961 12.9825Z"
|
||||
class="fill-[#0B68CB]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Resolved -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.RESOLVED"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="3 3 17.92 17.92"
|
||||
>
|
||||
<path
|
||||
d="M11.96 20.92C7.01152 20.92 3 16.9084 3 11.96C3 7.01152 7.01152 3 11.96 3C16.9084 3 20.92 7.01152 20.92 11.96C20.92 16.9084 16.9084 20.92 11.96 20.92ZM11.96 19.128C15.9188 19.128 19.128 15.9188 19.128 11.96C19.128 8.00122 15.9188 4.792 11.96 4.792C8.00122 4.792 4.792 8.00122 4.792 11.96C4.792 15.9188 8.00122 19.128 11.96 19.128Z"
|
||||
class="fill-[#5BB98C]"
|
||||
/>
|
||||
<path
|
||||
d="M11.9599 17.9333C15.2589 17.9333 17.9332 15.2589 17.9332 11.96C17.9332 8.66098 15.2589 5.98663 11.9599 5.98663C8.66092 5.98663 5.98657 8.66098 5.98657 11.96C5.98657 15.2589 8.66092 17.9333 11.9599 17.9333Z"
|
||||
class="fill-[#5BB98C]"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -56,7 +56,7 @@
|
||||
<span
|
||||
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
|
||||
>
|
||||
{{ dynamicTime(notificationItem.created_at) }}
|
||||
{{ dynamicTime(notificationItem.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@
|
||||
<td>
|
||||
<div class="text-right timestamp--column">
|
||||
<span class="notification--created-at">
|
||||
{{ dynamicTime(notificationItem.created_at) }}
|
||||
{{ dynamicTime(notificationItem.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<table-footer
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.count"
|
||||
:page-size="15"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,30 @@
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isAttributeTypeText">
|
||||
<input
|
||||
v-model="regexEnabled"
|
||||
type="checkbox"
|
||||
@input="toggleRegexEnabled"
|
||||
/>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
|
||||
</div>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexPattern"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexCue"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
|
||||
type="text"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
|
||||
<woot-submit-button
|
||||
:disabled="isButtonDisabled"
|
||||
@@ -124,6 +148,9 @@ export default {
|
||||
attributeModel: 0,
|
||||
attributeType: 0,
|
||||
attributeKey: '',
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
models: ATTRIBUTE_MODELS,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
values: [],
|
||||
@@ -163,6 +190,12 @@ export default {
|
||||
isAttributeTypeList() {
|
||||
return this.attributeType === 6;
|
||||
},
|
||||
isAttributeTypeText() {
|
||||
return this.attributeType === 0;
|
||||
},
|
||||
isRegexEnabled() {
|
||||
return this.regexEnabled;
|
||||
},
|
||||
},
|
||||
|
||||
validations: {
|
||||
@@ -201,11 +234,18 @@ export default {
|
||||
onDisplayNameChange() {
|
||||
this.attributeKey = convertToAttributeSlug(this.displayName);
|
||||
},
|
||||
toggleRegexEnabled() {
|
||||
this.regexEnabled = !this.regexEnabled;
|
||||
},
|
||||
async addAttributes() {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
if (!this.regexEnabled) {
|
||||
this.regexPattern = null;
|
||||
this.regexCue = null;
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('attributes/create', {
|
||||
attribute_display_name: this.displayName,
|
||||
@@ -214,6 +254,10 @@ export default {
|
||||
attribute_display_type: this.attributeType,
|
||||
attribute_key: this.attributeKey,
|
||||
attribute_values: this.attributeListValues,
|
||||
regex_pattern: this.regexPattern
|
||||
? new RegExp(this.regexPattern).toString()
|
||||
: null,
|
||||
regex_cue: this.regexCue,
|
||||
});
|
||||
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
|
||||
this.onClose();
|
||||
|
||||
@@ -70,6 +70,30 @@
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isAttributeTypeText">
|
||||
<input
|
||||
v-model="regexEnabled"
|
||||
type="checkbox"
|
||||
@input="toggleRegexEnabled"
|
||||
/>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
|
||||
</div>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexPattern"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexCue"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
|
||||
type="text"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
|
||||
<woot-button :is-loading="isUpdating" :disabled="isButtonDisabled">
|
||||
@@ -88,9 +112,10 @@ import { mapGetters } from 'vuex';
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import customAttributeMixin from '../../../../mixins/customAttributeMixin';
|
||||
export default {
|
||||
components: {},
|
||||
mixins: [alertMixin],
|
||||
mixins: [alertMixin, customAttributeMixin],
|
||||
props: {
|
||||
selectedAttribute: {
|
||||
type: Object,
|
||||
@@ -106,6 +131,9 @@ export default {
|
||||
displayName: '',
|
||||
description: '',
|
||||
attributeType: 0,
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
@@ -152,6 +180,7 @@ export default {
|
||||
this.isAttributeTypeList && this.isTouched && this.values.length === 0
|
||||
);
|
||||
},
|
||||
|
||||
pageTitle() {
|
||||
return `${this.$t('ATTRIBUTES_MGMT.EDIT.TITLE')} - ${
|
||||
this.selectedAttribute.attribute_display_name
|
||||
@@ -173,6 +202,12 @@ export default {
|
||||
isAttributeTypeList() {
|
||||
return this.attributeType === 6;
|
||||
},
|
||||
isAttributeTypeText() {
|
||||
return this.attributeType === 0;
|
||||
},
|
||||
isRegexEnabled() {
|
||||
return this.regexEnabled;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setFormValues();
|
||||
@@ -189,10 +224,16 @@ export default {
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
setFormValues() {
|
||||
const regexPattern = this.selectedAttribute.regex_pattern
|
||||
? this.getRegexp(this.selectedAttribute.regex_pattern).source
|
||||
: null;
|
||||
this.displayName = this.selectedAttribute.attribute_display_name;
|
||||
this.description = this.selectedAttribute.attribute_description;
|
||||
this.attributeType = this.selectedAttributeType;
|
||||
this.attributeKey = this.selectedAttribute.attribute_key;
|
||||
this.regexPattern = regexPattern;
|
||||
this.regexCue = this.selectedAttribute.regex_cue;
|
||||
this.regexEnabled = regexPattern != null;
|
||||
this.values = this.setAttributeListValue;
|
||||
},
|
||||
async editAttributes() {
|
||||
@@ -200,14 +241,21 @@ export default {
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
if (!this.regexEnabled) {
|
||||
this.regexPattern = null;
|
||||
this.regexCue = null;
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('attributes/update', {
|
||||
id: this.selectedAttribute.id,
|
||||
attribute_description: this.description,
|
||||
attribute_display_name: this.displayName,
|
||||
attribute_values: this.updatedAttributeListValues,
|
||||
regex_pattern: this.regexPattern
|
||||
? new RegExp(this.regexPattern).toString()
|
||||
: null,
|
||||
regex_cue: this.regexCue,
|
||||
});
|
||||
|
||||
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
@@ -218,6 +266,9 @@ export default {
|
||||
this.showAlert(this.alertMessage);
|
||||
}
|
||||
},
|
||||
toggleRegexEnabled() {
|
||||
this.regexEnabled = !this.regexEnabled;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@ jest.mock('../../../utils/api', () => ({
|
||||
getHeaderExpiry: jest.fn(),
|
||||
}));
|
||||
jest.mock('js-cookie', () => ({
|
||||
getJSON: jest.fn(),
|
||||
get: jest.fn(),
|
||||
}));
|
||||
|
||||
const commit = jest.fn();
|
||||
@@ -155,14 +155,14 @@ describe('#actions', () => {
|
||||
|
||||
describe('#setUser', () => {
|
||||
it('sends correct actions if user is logged in', async () => {
|
||||
Cookies.getJSON.mockImplementation(() => true);
|
||||
Cookies.get.mockImplementation(() => true);
|
||||
actions.setUser({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
expect(dispatch.mock.calls).toEqual([['validityCheck']]);
|
||||
});
|
||||
|
||||
it('sends correct actions if user is not logged in', async () => {
|
||||
Cookies.getJSON.mockImplementation(() => false);
|
||||
Cookies.get.mockImplementation(() => false);
|
||||
actions.setUser({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.CLEAR_USER],
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getHeaderExpiry = response =>
|
||||
|
||||
export const setAuthCredentials = response => {
|
||||
const expiryDate = getHeaderExpiry(response);
|
||||
Cookies.set('cw_d_session_info', response.headers, {
|
||||
Cookies.set('cw_d_session_info', JSON.stringify(response.headers), {
|
||||
expires: differenceInDays(expiryDate, new Date()),
|
||||
});
|
||||
setUser(response.data.data, expiryDate);
|
||||
|
||||
@@ -84,6 +84,10 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.typingTimer);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChange(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
|
||||
@@ -34,5 +34,12 @@ export const setCookieWithDomain = (
|
||||
domain: baseDomain,
|
||||
};
|
||||
|
||||
// if type of value is object, stringify it
|
||||
// this is because js-cookies 3.0 removed builtin json support
|
||||
// ref: https://github.com/js-cookie/js-cookie/releases/tag/v3.0.0
|
||||
if (typeof value === 'object') {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
|
||||
Cookies.set(name, value, cookieOptions);
|
||||
};
|
||||
|
||||
@@ -107,6 +107,26 @@ describe('setCookieWithDomain', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify the cookie value when setting', () => {
|
||||
setCookieWithDomain(
|
||||
'myCookie',
|
||||
{ value: 'cookieValue' },
|
||||
{
|
||||
baseDomain: 'example.com',
|
||||
}
|
||||
);
|
||||
|
||||
expect(Cookies.set).toHaveBeenCalledWith(
|
||||
'myCookie',
|
||||
JSON.stringify({ value: 'cookieValue' }),
|
||||
expect.objectContaining({
|
||||
expires: 365,
|
||||
sameSite: 'Lax',
|
||||
domain: 'example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => {
|
||||
setCookieWithDomain('myCookie', 'cookieValue', {
|
||||
expires: 7,
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"person-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11zm-5.477 2a6.47 6.47 0 0 0-.709 1.5H4.253a.749.749 0 0 0-.75.75v.577c0 .535.192 1.053.54 1.46c1.253 1.469 3.22 2.214 5.957 2.214c.597 0 1.157-.035 1.68-.106c.246.495.553.954.912 1.367c-.795.16-1.66.24-2.592.24c-3.146 0-5.532-.906-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.578A2.249 2.249 0 0 1 4.253 14h7.77zm5.477 0l-.09.008a.5.5 0 0 0-.402.402L17 14.5V17h-2.496l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17L17 20.5l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402L18 20.5V18h2.504l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.402-.402l-.09-.008H18L18 14.5l-.008-.09a.5.5 0 0 0-.402-.402L17.5 14zM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z",
|
||||
"person-assign-outline": "M11.313 15.5a6.471 6.471 0 0 1 .709-1.5h-7.77a2.249 2.249 0 0 0-2.249 2.25v.577c0 .892.319 1.756.899 2.435c1.566 1.834 3.952 2.74 7.098 2.74c.931 0 1.796-.08 2.592-.24a6.51 6.51 0 0 1-.913-1.366c-.524.07-1.083.105-1.68.105c-2.737 0-4.703-.745-5.957-2.213a2.25 2.25 0 0 1-.539-1.461v-.578a.75.75 0 0 1 .75-.749h7.06ZM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7ZM23 17.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Zm-4.647-2.853a.5.5 0 0 0-.707.707L19.293 17H15a.5.5 0 1 0 0 1h4.293l-1.647 1.647a.5.5 0 0 0 .707.707l2.5-2.5a.497.497 0 0 0 .147-.345V17.5a.498.498 0 0 0-.15-.357l-2.497-2.496Z",
|
||||
"person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",
|
||||
"person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z",
|
||||
"play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z",
|
||||
"power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z",
|
||||
"quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export const hasAuthCookie = () => {
|
||||
return !!Cookies.getJSON('cw_d_session_info');
|
||||
return !!Cookies.get('cw_d_session_info');
|
||||
};
|
||||
|
||||
const getSSOAccountPath = ({ ssoAccountId, user }) => {
|
||||
|
||||
@@ -10,10 +10,10 @@ jest.mock('dashboard/store/utils/api', () => ({
|
||||
jest.mock('../CommonHelper', () => ({ replaceRouteWithReload: jest.fn() }));
|
||||
|
||||
jest.mock('js-cookie', () => ({
|
||||
getJSON: jest.fn(),
|
||||
get: jest.fn(),
|
||||
}));
|
||||
|
||||
Cookies.getJSON.mockReturnValueOnce(true).mockReturnValue(false);
|
||||
Cookies.get.mockReturnValueOnce(true).mockReturnValue(false);
|
||||
describe('#validateRouteAccess', () => {
|
||||
it('reset cookies and continues to the login page if the SSO parameters are present', () => {
|
||||
validateRouteAccess(
|
||||
@@ -40,7 +40,7 @@ describe('#validateRouteAccess', () => {
|
||||
});
|
||||
|
||||
it('redirects to dashboard if auth cookie is present', () => {
|
||||
Cookies.getJSON.mockImplementation(() => true);
|
||||
Cookies.get.mockImplementation(() => true);
|
||||
validateRouteAccess({ name: 'login' }, next);
|
||||
expect(clearBrowserSessionCookies).not.toHaveBeenCalled();
|
||||
expect(replaceRouteWithReload).toHaveBeenCalledWith('/app/');
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
isValidPhoneNumber: $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR'),
|
||||
email: $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'),
|
||||
required: $t('PRE_CHAT_FORM.REQUIRED'),
|
||||
matches: item.regex_cue
|
||||
? item.regex_cue
|
||||
: $t('PRE_CHAT_FORM.REGEX_ERROR'),
|
||||
}"
|
||||
:has-error-in-phone-input="hasErrorInPhoneInput"
|
||||
/>
|
||||
@@ -68,13 +71,20 @@ import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import darkModeMixin from 'widget/mixins/darkModeMixin';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import customAttributeMixin from '../../../dashboard/mixins/customAttributeMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CustomButton,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin],
|
||||
mixins: [
|
||||
routerMixin,
|
||||
darkModeMixin,
|
||||
messageFormatterMixin,
|
||||
configMixin,
|
||||
customAttributeMixin,
|
||||
],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
@@ -235,30 +245,37 @@ export default {
|
||||
}
|
||||
return this.formValues[name] || null;
|
||||
},
|
||||
getValidation({ type, name }) {
|
||||
getValidation({ type, name, field_type, regex_pattern }) {
|
||||
let regex = regex_pattern ? this.getRegexp(regex_pattern) : null;
|
||||
const validations = {
|
||||
emailAddress: 'email',
|
||||
phoneNumber: 'startsWithPlus|isValidPhoneNumber',
|
||||
phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],
|
||||
url: 'url',
|
||||
date: 'date',
|
||||
text: null,
|
||||
select: null,
|
||||
number: null,
|
||||
checkbox: false,
|
||||
contact_attribute: regex ? [['matches', regex]] : null,
|
||||
conversation_attribute: regex ? [['matches', regex]] : null,
|
||||
};
|
||||
const validationKeys = Object.keys(validations);
|
||||
const isRequired = this.isContactFieldRequired(name);
|
||||
const validation = isRequired ? 'bail|required' : 'bail|optional';
|
||||
const validation = isRequired
|
||||
? ['bail', 'required']
|
||||
: ['bail', 'optional'];
|
||||
|
||||
if (validationKeys.includes(name) || validationKeys.includes(type)) {
|
||||
const validationType = validations[type] || validations[name];
|
||||
const validationString = validationType
|
||||
? `${validation}|${validationType}`
|
||||
: validation;
|
||||
return validationString;
|
||||
if (
|
||||
validationKeys.includes(name) ||
|
||||
validationKeys.includes(type) ||
|
||||
validationKeys.includes(field_type)
|
||||
) {
|
||||
const validationType =
|
||||
validations[type] || validations[name] || validations[field_type];
|
||||
return validationType ? validation.concat(validationType) : validation;
|
||||
}
|
||||
|
||||
return '';
|
||||
return [];
|
||||
},
|
||||
findFieldType(type) {
|
||||
if (type === 'link') {
|
||||
|
||||
@@ -24,6 +24,11 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timeOutID: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
replyToAttachment() {
|
||||
if (!this.replyTo?.attachments?.length) {
|
||||
@@ -34,6 +39,10 @@ export default {
|
||||
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.timeOutID);
|
||||
},
|
||||
methods: {
|
||||
navigateTo(id) {
|
||||
const elementId = `cwmsg-${id}`;
|
||||
@@ -41,7 +50,7 @@ export default {
|
||||
const el = document.getElementById(elementId);
|
||||
el.scrollIntoView();
|
||||
el.classList.add('bg-slate-100', 'dark:bg-slate-900');
|
||||
setTimeout(() => {
|
||||
this.timeOutID = setTimeout(() => {
|
||||
el.classList.remove('bg-slate-100', 'dark:bg-slate-900');
|
||||
}, 500);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ export default {
|
||||
scrollPosition: 0,
|
||||
ticking: true,
|
||||
disableBranding: window.chatwootWebChannel.disableBranding || false,
|
||||
requestID: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -120,6 +121,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$el.removeEventListener('scroll', this.updateScrollPosition);
|
||||
cancelAnimationFrame(this.requestID);
|
||||
},
|
||||
methods: {
|
||||
closeWindow() {
|
||||
@@ -128,7 +130,7 @@ export default {
|
||||
updateScrollPosition(event) {
|
||||
this.scrollPosition = event.target.scrollTop;
|
||||
if (!this.ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.requestID = window.requestAnimationFrame(() => {
|
||||
this.ticking = false;
|
||||
});
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required"
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"CHAT_FORM": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
|
||||
discard_on ActiveJob::DeserializationError do |_job, error|
|
||||
Rails.logger.error("Skipping job because of ActiveJob::DeserializationError (#{error.message})")
|
||||
discard_on ActiveJob::DeserializationError do |job, error|
|
||||
Rails.logger.info("Skipping #{job.class} with #{
|
||||
job.instance_variable_get(:@serialized_arguments)
|
||||
} because of ActiveJob::DeserializationError (#{error.message})")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,8 +8,12 @@ class DataImportJob < ApplicationJob
|
||||
def perform(data_import)
|
||||
@data_import = data_import
|
||||
@contact_manager = DataImport::ContactManager.new(@data_import.account)
|
||||
process_import_file
|
||||
send_import_notification_to_admin
|
||||
begin
|
||||
process_import_file
|
||||
send_import_notification_to_admin
|
||||
rescue CSV::MalformedCSVError => e
|
||||
handle_csv_error(e)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -83,7 +87,16 @@ class DataImportJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
|
||||
@data_import.update!(status: :failed)
|
||||
send_import_failed_notification_to_admin
|
||||
end
|
||||
|
||||
def send_import_notification_to_admin
|
||||
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
|
||||
end
|
||||
|
||||
def send_import_failed_notification_to_admin
|
||||
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_failed.deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
require 'net/imap'
|
||||
|
||||
class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform(channel)
|
||||
return unless should_fetch_email?(channel)
|
||||
|
||||
process_email_for_channel(channel)
|
||||
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
|
||||
with_lock(key, 5.minutes) do
|
||||
process_email_for_channel(channel)
|
||||
end
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
Rails.logger.error e
|
||||
channel.authorization_error!
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
|
||||
Rails.logger.error e
|
||||
rescue LockAcquisitionError
|
||||
Rails.logger.error "Lock failed for #{channel.inbox.id}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
@@ -23,7 +28,6 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
end
|
||||
|
||||
def process_email_for_channel(channel)
|
||||
# fetching email for microsoft provider
|
||||
if channel.microsoft?
|
||||
fetch_mail_for_ms_provider(channel)
|
||||
else
|
||||
@@ -34,32 +38,66 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
end
|
||||
|
||||
def fetch_mail_for_channel(channel)
|
||||
imap_inbox = authenticated_imap_inbox(channel, channel.imap_password, 'PLAIN')
|
||||
last_email_time = DateTime.parse(Net::IMAP.format_datetime(last_email_time(channel)))
|
||||
imap_client = build_imap_client(channel, channel.imap_password, 'PLAIN')
|
||||
|
||||
received_mails(imap_inbox).each do |message_id|
|
||||
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
mail_info_logger(channel, inbound_mail, message_id)
|
||||
|
||||
next if email_already_present?(channel, inbound_mail, last_email_time)
|
||||
|
||||
process_mail(inbound_mail, channel)
|
||||
message_ids_with_seq = fetch_message_ids_with_sequence(imap_client, channel)
|
||||
message_ids_with_seq.each do |message_id_with_seq|
|
||||
process_message_id(channel, imap_client, message_id_with_seq)
|
||||
end
|
||||
end
|
||||
|
||||
def email_already_present?(channel, inbound_mail, _last_email_time)
|
||||
channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
|
||||
def process_message_id(channel, imap_client, message_id_with_seq)
|
||||
seq_no, message_id = message_id_with_seq
|
||||
|
||||
return if email_already_present?(channel, message_id)
|
||||
|
||||
# Fetch the original mail content using the sequence no
|
||||
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
if mail_str.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
|
||||
return
|
||||
end
|
||||
|
||||
inbound_mail = build_mail_from_string(mail_str)
|
||||
mail_info_logger(channel, inbound_mail, seq_no)
|
||||
process_mail(inbound_mail, channel)
|
||||
end
|
||||
|
||||
def received_mails(imap_inbox)
|
||||
imap_inbox.search(['BEFORE', tomorrow, 'SINCE', yesterday])
|
||||
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
|
||||
# You can send batches of message sequence number in `.fetch` method.
|
||||
def fetch_message_ids_with_sequence(imap_client, channel)
|
||||
seq_nums = fetch_available_mail_sequence_numbers(imap_client)
|
||||
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
|
||||
|
||||
message_ids_with_seq = []
|
||||
seq_nums.each_slice(10).each do |batch|
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]')
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
# Check
|
||||
if batch_message_ids.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
||||
next
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
message_id = build_mail_from_string(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']).message_id
|
||||
message_ids_with_seq.push([data.seqno, message_id])
|
||||
end
|
||||
end
|
||||
|
||||
message_ids_with_seq
|
||||
end
|
||||
|
||||
def processed_email?(current_email, last_email_time)
|
||||
return current_email.date < last_email_time if current_email.date.present?
|
||||
|
||||
false
|
||||
# Sends a SEARCH command to search the mailbox for messages that were
|
||||
# created between yesterday and today and returns message sequence numbers.
|
||||
# Return <message set>
|
||||
def fetch_available_mail_sequence_numbers(imap_client)
|
||||
imap_client.search(['BEFORE', tomorrow, 'SINCE', yesterday])
|
||||
end
|
||||
|
||||
def fetch_mail_for_ms_provider(channel)
|
||||
@@ -69,16 +107,15 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
|
||||
return unless access_token
|
||||
|
||||
imap_inbox = authenticated_imap_inbox(channel, access_token, 'XOAUTH2')
|
||||
|
||||
process_mails(imap_inbox, channel)
|
||||
imap_client = build_imap_client(channel, access_token, 'XOAUTH2')
|
||||
process_mails(imap_client, channel)
|
||||
end
|
||||
|
||||
def process_mails(imap_inbox, channel)
|
||||
received_mails(imap_inbox).each do |message_id|
|
||||
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
|
||||
def process_mails(imap_client, channel)
|
||||
fetch_available_mail_sequence_numbers(imap_client).each do |seq_no|
|
||||
inbound_mail = Mail.read_from_string imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
mail_info_logger(channel, inbound_mail, message_id)
|
||||
mail_info_logger(channel, inbound_mail, seq_no)
|
||||
|
||||
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
|
||||
|
||||
@@ -86,38 +123,26 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def mail_info_logger(channel, inbound_mail, message_id)
|
||||
def mail_info_logger(channel, inbound_mail, uid)
|
||||
return if Rails.env.test?
|
||||
|
||||
Rails.logger.info("
|
||||
#{channel.provider} Email id: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}, message_id: #{message_id}")
|
||||
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{uid}")
|
||||
end
|
||||
|
||||
def authenticated_imap_inbox(channel, access_token, auth_method)
|
||||
def build_imap_client(channel, access_token, auth_method)
|
||||
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
|
||||
imap.authenticate(auth_method, channel.imap_login, access_token)
|
||||
imap.select('INBOX')
|
||||
imap
|
||||
end
|
||||
|
||||
def last_email_time(channel)
|
||||
# we are only checking for emails in last 2 day
|
||||
last_email_incoming_message = channel.inbox.messages.incoming.where('messages.created_at >= ?', 2.days.ago).last
|
||||
if last_email_incoming_message.present?
|
||||
time = last_email_incoming_message.content_attributes['email']['date']
|
||||
time ||= last_email_incoming_message.created_at.to_s
|
||||
end
|
||||
time ||= 1.hour.ago.to_s
|
||||
|
||||
DateTime.parse(time)
|
||||
def email_already_present?(channel, message_id)
|
||||
channel.inbox.messages.find_by(source_id: message_id).present?
|
||||
end
|
||||
|
||||
def yesterday
|
||||
(Time.zone.today - 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
|
||||
def tomorrow
|
||||
(Time.zone.today + 1).strftime('%d-%b-%Y')
|
||||
def build_mail_from_string(raw_email_content)
|
||||
Mail.read_from_string(raw_email_content)
|
||||
end
|
||||
|
||||
def process_mail(inbound_mail, channel)
|
||||
@@ -132,4 +157,12 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
def valid_access_token(channel)
|
||||
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
|
||||
end
|
||||
|
||||
def yesterday
|
||||
(Time.zone.today - 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
|
||||
def tomorrow
|
||||
(Time.zone.today + 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,9 @@ class Inboxes::UpdateWidgetPreChatCustomFieldsJob < ApplicationJob
|
||||
pre_chat_field.deep_merge({
|
||||
'label' => custom_attribute['attribute_display_name'],
|
||||
'placeholder' => custom_attribute['attribute_display_name'],
|
||||
'values' => custom_attribute['attribute_values']
|
||||
'values' => custom_attribute['attribute_values'],
|
||||
'regex_pattern' => custom_attribute['regex_pattern'],
|
||||
'regex_cue' => custom_attribute['regex_cue']
|
||||
})
|
||||
end
|
||||
web_widget.save!
|
||||
|
||||
@@ -14,22 +14,36 @@
|
||||
class MutexApplicationJob < ApplicationJob
|
||||
class LockAcquisitionError < StandardError; end
|
||||
|
||||
def with_lock(key_format, *args)
|
||||
lock_key = format(key_format, *args)
|
||||
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
|
||||
lock_manager = Redis::LockManager.new
|
||||
|
||||
if lock_manager.locked?(lock_key)
|
||||
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
|
||||
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
|
||||
end
|
||||
|
||||
begin
|
||||
lock_manager.lock(lock_key)
|
||||
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
|
||||
yield
|
||||
ensure
|
||||
# Ensure that the lock is released even if there's an error in processing
|
||||
lock_manager.unlock(lock_key)
|
||||
if lock_manager.lock(lock_key, timeout)
|
||||
log_attempt(lock_key, executions)
|
||||
yield
|
||||
# release the lock after the block has been executed
|
||||
lock_manager.unlock(lock_key)
|
||||
else
|
||||
handle_failed_lock_acquisition(lock_key)
|
||||
end
|
||||
rescue StandardError => e
|
||||
handle_error(e, lock_manager, lock_key)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_attempt(lock_key, executions)
|
||||
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
|
||||
end
|
||||
|
||||
def handle_error(err, lock_manager, lock_key)
|
||||
lock_manager.unlock(lock_key) unless err.is_a?(LockAcquisitionError)
|
||||
raise err
|
||||
end
|
||||
|
||||
def handle_failed_lock_acquisition(lock_key)
|
||||
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
|
||||
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class Notification::RemoveOldNotificationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform
|
||||
Notification.where('created_at < ?', 1.month.ago)
|
||||
.find_each(batch_size: 1000, &:delete)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,8 @@ class SendOnSlackJob < MutexApplicationJob
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(message, hook)
|
||||
with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do
|
||||
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
|
||||
with_lock(key) do
|
||||
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,5 +19,8 @@ class TriggerScheduledItemsJob < ApplicationJob
|
||||
|
||||
# Job to sync whatsapp templates
|
||||
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
|
||||
|
||||
# Job to clear notifications which are older than 1 month
|
||||
Notification::RemoveOldNotificationJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,8 @@ class Webhooks::FacebookEventsJob < MutexApplicationJob
|
||||
def perform(message)
|
||||
response = ::Integrations::Facebook::MessageParser.new(message)
|
||||
|
||||
with_lock(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id) do
|
||||
key = format(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id)
|
||||
with_lock(key) do
|
||||
process_message(response)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,8 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
def perform(entries)
|
||||
@entries = entries
|
||||
|
||||
with_lock(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id) do
|
||||
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
|
||||
with_lock(key) do
|
||||
process_entries(entries)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,11 +6,12 @@ class ReplyMailbox < ApplicationMailbox
|
||||
EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
|
||||
before_processing :conversation_uuid_from_to_address,
|
||||
:find_relative_conversation,
|
||||
:verify_decoded_params,
|
||||
:decorate_mail
|
||||
:find_relative_conversation
|
||||
|
||||
def process
|
||||
return if @conversation.blank?
|
||||
|
||||
decorate_mail
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
end
|
||||
@@ -78,12 +79,8 @@ class ReplyMailbox < ApplicationMailbox
|
||||
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
|
||||
end
|
||||
|
||||
def verify_decoded_params
|
||||
raise 'Conversation uuid not found' if conversation_uuid.nil?
|
||||
end
|
||||
|
||||
def validate_resource(resource)
|
||||
raise "Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
|
||||
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
|
||||
|
||||
resource
|
||||
end
|
||||
|
||||
@@ -51,6 +51,15 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
|
||||
send_mail_with_liquid(to: admin_emails, subject: subject) and return
|
||||
end
|
||||
|
||||
def contact_import_failed
|
||||
return unless smtp_config_set_or_development?
|
||||
|
||||
subject = 'Contact Import Failed'
|
||||
|
||||
@meta = {}
|
||||
send_mail_with_liquid(to: admin_emails, subject: subject) and return
|
||||
end
|
||||
|
||||
def contact_export_complete(file_url)
|
||||
return unless smtp_config_set_or_development?
|
||||
|
||||
|
||||
@@ -149,24 +149,32 @@ class Channel::Telegram < ApplicationRecord
|
||||
})
|
||||
end
|
||||
|
||||
def convert_markdown_to_telegram(text)
|
||||
## supported characters : https://core.telegram.org/bots/api#markdown-style
|
||||
## To implement MarkdownV2, we will need to do a lot of escaping
|
||||
def convert_markdown_to_telegram_html(text)
|
||||
# ref: https://core.telegram.org/bots/api#html-style
|
||||
|
||||
# Convert bold - double asterisks to single asterisk in Telegram
|
||||
# Chatwoot uses double asterisks for bold, while telegram used single asterisk
|
||||
text.gsub!(/\*\*(.*?)\*\*/, '*\1*')
|
||||
text
|
||||
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
|
||||
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
|
||||
|
||||
# convert markdown to html
|
||||
html = CommonMarker.render_html(text).strip
|
||||
|
||||
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
|
||||
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
|
||||
attributes: %w[href])
|
||||
|
||||
# converted escaped br tags to \n
|
||||
stripped_html.gsub('<br>', "\n")
|
||||
end
|
||||
|
||||
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
|
||||
text_to_md = convert_markdown_to_telegram(text)
|
||||
text_payload = convert_markdown_to_telegram_html(text)
|
||||
|
||||
HTTParty.post("#{telegram_api_url}/sendMessage",
|
||||
body: {
|
||||
chat_id: chat_id,
|
||||
text: text_to_md,
|
||||
text: text_payload,
|
||||
reply_markup: reply_markup,
|
||||
parse_mode: 'Markdown',
|
||||
parse_mode: 'HTML',
|
||||
reply_to_message_id: reply_to_message_id
|
||||
})
|
||||
end
|
||||
|
||||
@@ -35,7 +35,7 @@ class Channel::WebWidget < ApplicationRecord
|
||||
{ pre_chat_form_options: [:pre_chat_message, :require_email,
|
||||
{ pre_chat_fields:
|
||||
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
|
||||
:locale, { values: [] }] }] },
|
||||
:locale, { values: [] }, :regex_pattern, :regex_cue] }] },
|
||||
{ selected_feature_flags: [] }].freeze
|
||||
|
||||
before_validation :validate_pre_chat_options
|
||||
|
||||
@@ -2,7 +2,7 @@ module AccountCacheRevalidator
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :update_account_cache, on: [:create, :update]
|
||||
after_commit :update_account_cache, on: [:create, :update, :destroy]
|
||||
end
|
||||
|
||||
def update_account_cache
|
||||
|
||||
@@ -4,6 +4,8 @@ module CacheKeys
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
CACHE_KEYS_EXPIRY = 72.hours
|
||||
|
||||
included do
|
||||
class_attribute :cacheable_models
|
||||
self.cacheable_models = [Label, Inbox, Team]
|
||||
@@ -18,26 +20,26 @@ module CacheKeys
|
||||
keys
|
||||
end
|
||||
|
||||
def invalidate_cache_key_for(key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(id, key)
|
||||
Redis::Alfred.delete(prefixed_cache_key)
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
def update_cache_key(key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(id, key)
|
||||
Redis::Alfred.set(prefixed_cache_key, Time.now.utc.to_i)
|
||||
update_cache_key_for_account(id, key)
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
def reset_cache_keys
|
||||
self.class.cacheable_models.each do |model|
|
||||
invalidate_cache_key_for(model.name.underscore)
|
||||
update_cache_key_for_account(id, model.name.underscore)
|
||||
end
|
||||
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_cache_key_for_account(account_id, key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
|
||||
Redis::Alfred.setex(prefixed_cache_key, Time.now.utc.to_i, CACHE_KEYS_EXPIRY)
|
||||
end
|
||||
|
||||
def dispatch_cache_update_event
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
|
||||
end
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# additional_attributes :jsonb
|
||||
# contact_type :integer default("visitor")
|
||||
# country_code :string default("")
|
||||
# custom_attributes :jsonb
|
||||
# email :string
|
||||
# identifier :string
|
||||
# last_activity_at :datetime
|
||||
# last_name :string default("")
|
||||
# location :string default("")
|
||||
# middle_name :string default("")
|
||||
# name :string default("")
|
||||
# phone_number :string
|
||||
# created_at :datetime not null
|
||||
@@ -55,6 +60,8 @@ class Contact < ApplicationRecord
|
||||
after_update_commit :dispatch_update_event
|
||||
after_destroy_commit :dispatch_destroy_event
|
||||
|
||||
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
|
||||
|
||||
scope :order_on_last_activity_at, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
# attribute_model :integer default("conversation_attribute")
|
||||
# attribute_values :jsonb
|
||||
# default_value :integer
|
||||
# regex_cue :string
|
||||
# regex_pattern :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
|
||||
@@ -33,9 +33,12 @@ class DashboardApp < ApplicationRecord
|
||||
'required' => %w[url type],
|
||||
'properties' => {
|
||||
'type' => { 'enum': ['frame'] },
|
||||
'url' => { :type => 'string', 'format' => 'uri' }
|
||||
'url' => { '$ref' => '#/definitions/saneUrl' }
|
||||
}
|
||||
},
|
||||
'definitions' => {
|
||||
'saneUrl' => { 'format' => 'uri', 'pattern' => '^https?://' }
|
||||
},
|
||||
'additionalProperties' => false,
|
||||
'minItems' => 1
|
||||
}
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ class Inbox < ApplicationRecord
|
||||
has_many :inbox_members, dependent: :destroy_async
|
||||
has_many :members, through: :inbox_members, source: :user
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :messages, through: :conversations
|
||||
has_many :messages, dependent: :destroy_async
|
||||
|
||||
has_one :agent_bot_inbox, dependent: :destroy_async
|
||||
has_one :agent_bot, through: :agent_bot_inbox
|
||||
|
||||
@@ -60,6 +60,7 @@ class Notification < ApplicationRecord
|
||||
secondary_actor: secondary_actor&.push_event_data,
|
||||
user: user&.push_event_data,
|
||||
created_at: created_at.to_i,
|
||||
last_activity_at: last_activity_at.to_i,
|
||||
account_id: account_id
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
class AutomationRules::ConditionValidationService
|
||||
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
|
||||
|
||||
def initialize(rule)
|
||||
@rule = rule
|
||||
@account = rule.account
|
||||
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
end
|
||||
|
||||
def perform
|
||||
@rule.conditions.each do |condition|
|
||||
return false unless valid_condition?(condition)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_condition?(condition)
|
||||
key = condition['attribute_key']
|
||||
|
||||
conversation_filter = @conversation_filters[key]
|
||||
contact_filter = @contact_filters[key]
|
||||
message_filter = @message_filters[key]
|
||||
|
||||
if conversation_filter || contact_filter || message_filter
|
||||
operation_valid?(condition, conversation_filter || contact_filter || message_filter)
|
||||
else
|
||||
custom_attribute_present?(key, condition['custom_attribute_type'])
|
||||
end
|
||||
end
|
||||
|
||||
def operation_valid?(condition, filter)
|
||||
filter_operator = condition['filter_operator']
|
||||
|
||||
# attribute changed is a special case
|
||||
return true if filter_operator == 'attribute_changed'
|
||||
|
||||
filter['filter_operators'].include?(filter_operator)
|
||||
end
|
||||
|
||||
def custom_attribute_present?(attribute_key, attribute_model)
|
||||
attribute_model = attribute_model.presence || self.class::ATTRIBUTE_MODEL
|
||||
|
||||
@account.custom_attribute_definitions.where(
|
||||
attribute_model: attribute_model
|
||||
).find_by(attribute_key: attribute_key).present?
|
||||
end
|
||||
end
|
||||
@@ -5,19 +5,25 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
|
||||
def initialize(rule, conversation = nil, options = {})
|
||||
super([], nil)
|
||||
# assign rule, conversation and account to instance variables
|
||||
@rule = rule
|
||||
@conversation = conversation
|
||||
@account = conversation.account
|
||||
|
||||
# setup filters from json file
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
|
||||
@options = options
|
||||
@changed_attributes = options[:changed_attributes]
|
||||
end
|
||||
|
||||
def perform
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
return false unless rule_valid?
|
||||
|
||||
@attribute_changed_query_filter = []
|
||||
|
||||
@rule.conditions.each_with_index do |query_hash, current_index|
|
||||
@@ -36,6 +42,14 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
false
|
||||
end
|
||||
|
||||
def rule_valid?
|
||||
is_valid = AutomationRules::ConditionValidationService.new(@rule).perform
|
||||
|
||||
Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid
|
||||
|
||||
is_valid
|
||||
end
|
||||
|
||||
def filter_operation(query_hash, current_index)
|
||||
if query_hash[:filter_operator] == 'starts_with'
|
||||
@filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%"
|
||||
|
||||
@@ -7,7 +7,12 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
|
||||
def perform_reply
|
||||
send_message_to_facebook fb_text_message_params if message.content.present?
|
||||
send_message_to_facebook fb_attachment_message_params if message.attachments.present?
|
||||
|
||||
if message.attachments.present?
|
||||
message.attachments.each do |attachment|
|
||||
send_message_to_facebook fb_attachment_message_params(attachment)
|
||||
end
|
||||
end
|
||||
rescue Facebook::Messenger::FacebookError => e
|
||||
# TODO : handle specific errors or else page will get disconnected
|
||||
handle_facebook_error(e)
|
||||
@@ -41,8 +46,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
"#{error_code} - #{error_message}"
|
||||
end
|
||||
|
||||
def fb_attachment_message_params
|
||||
attachment = message.attachments.first
|
||||
def fb_attachment_message_params(attachment)
|
||||
{
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
@@ -64,14 +68,6 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
'file'
|
||||
end
|
||||
|
||||
def fb_message_params
|
||||
if message.attachments.blank?
|
||||
fb_text_message_params
|
||||
else
|
||||
fb_attachment_message_params
|
||||
end
|
||||
end
|
||||
|
||||
def sent_first_outgoing_message_after_24_hours?
|
||||
# we can send max 1 message after 24 hour window
|
||||
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
|
||||
|
||||
@@ -14,8 +14,13 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
send_to_facebook_page attachament_message_params if message.attachments.present?
|
||||
send_to_facebook_page message_params
|
||||
if message.attachments.present?
|
||||
message.attachments.each do |attachment|
|
||||
send_to_facebook_page attachment_message_params(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
send_to_facebook_page message_params if message.content.present?
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
|
||||
# TODO : handle specific errors or else page will get disconnected
|
||||
@@ -33,8 +38,7 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService
|
||||
merge_human_agent_tag(params)
|
||||
end
|
||||
|
||||
def attachament_message_params
|
||||
attachment = message.attachments.first
|
||||
def attachment_message_params(attachment)
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
|
||||
@@ -24,7 +24,9 @@ class Instagram::WebhooksBaseService
|
||||
def update_instagram_profile_link(user)
|
||||
return unless user['username']
|
||||
|
||||
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
|
||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
|
||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
|
||||
@contact.save
|
||||
end
|
||||
end
|
||||
|
||||
@@ -139,7 +139,14 @@ class Line::IncomingMessageService
|
||||
def contact_attributes
|
||||
{
|
||||
name: line_contact_info['displayName'],
|
||||
avatar_url: line_contact_info['pictureUrl']
|
||||
avatar_url: line_contact_info['pictureUrl'],
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
{
|
||||
social_line_user_id: line_contact_info['userId']
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -78,8 +78,11 @@ class Telegram::IncomingMessageService
|
||||
|
||||
def additional_attributes
|
||||
{
|
||||
# TODO: Remove this once we show the social_telegram_user_name in the UI instead of the username
|
||||
username: telegram_params_username,
|
||||
language_code: telegram_params_language_code
|
||||
language_code: telegram_params_language_code,
|
||||
social_telegram_user_id: telegram_params_from_id,
|
||||
social_telegram_user_name: telegram_params_username
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ class Twilio::IncomingMessageService
|
||||
|
||||
def download_attachment_file
|
||||
download_with_auth
|
||||
rescue Down::Error => e
|
||||
rescue Down::Error, Down::ClientError => e
|
||||
handle_download_attachment_error(e)
|
||||
end
|
||||
|
||||
@@ -141,12 +141,10 @@ class Twilio::IncomingMessageService
|
||||
|
||||
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
|
||||
def handle_download_attachment_error(error)
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}"
|
||||
if error.message.include?('401 Unauthorized')
|
||||
Down.download(params[:MediaUrl0])
|
||||
else
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
nil
|
||||
end
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
|
||||
Down.download(params[:MediaUrl0])
|
||||
rescue StandardError => e
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,13 @@ class Whatsapp::IncomingMessageBaseService
|
||||
private
|
||||
|
||||
def process_messages
|
||||
# message allready exists so we don't need to process
|
||||
# We don't support reactions & ephemeral message now, we need to skip processing the message
|
||||
# if the webhook event is a reaction or an ephermal message or an unsupported message.
|
||||
return if unprocessable_message_type?(message_type)
|
||||
|
||||
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
|
||||
# business manager account. While we have not found the core reason yet, the following line ensure that
|
||||
# there are no duplicate messages created.
|
||||
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
|
||||
|
||||
cache_message_source_id_in_redis
|
||||
@@ -49,8 +55,6 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def create_messages
|
||||
return if unprocessable_message_type?(message_type)
|
||||
|
||||
message = @processed_params[:messages].first
|
||||
log_error(message) && return if error_webhook_event?(message)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
end
|
||||
|
||||
def unprocessable_message_type?(message_type)
|
||||
%w[reaction ephemeral unsupported].include?(message_type)
|
||||
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
||||
end
|
||||
|
||||
def brazil_phone_number?(phone_number)
|
||||
|
||||
@@ -19,6 +19,7 @@ json.data do
|
||||
json.secondary_actor notification.secondary_actor&.push_event_data
|
||||
json.user notification.user.push_event_data
|
||||
json.created_at notification.created_at.to_i
|
||||
json.last_activity_at notification.last_activity_at.to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,8 @@ json.attribute_display_name resource.attribute_display_name
|
||||
json.attribute_display_type resource.attribute_display_type
|
||||
json.attribute_description resource.attribute_description
|
||||
json.attribute_key resource.attribute_key
|
||||
json.regex_pattern resource.regex_pattern
|
||||
json.regex_cue resource.regex_cue
|
||||
json.attribute_values resource.attribute_values
|
||||
json.attribute_model resource.attribute_model
|
||||
json.default_value resource.default_value
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="16" height="16" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>woot-log</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="woot-log" fill-rule="nonzero">
|
||||
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
|
||||
<path d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="6" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 846 B |
+3
@@ -0,0 +1,3 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Your contact import has failed. It appears that the CSV file you uploaded may not be valid. We kindly request that you review the file and ensure it complies with the required format.<p/>
|
||||
@@ -2,9 +2,17 @@
|
||||
<footer class="pt-16 pb-8 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto max-w-2xl text-center py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render partial: 'icons/chatwoot-logo' %>
|
||||
|
||||
<img
|
||||
class="w-4 h-4"
|
||||
alt="<%= @global_config['BRAND_NAME'] %>"
|
||||
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
|
||||
/>
|
||||
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
|
||||
<%= I18n.t('public_portal.footer.made_with') %> <a class="hover:underline" href="https://www.chatwoot.com" target="_blank" rel="noopener noreferrer nofoll/ow">Chatwoot</a>
|
||||
<%= I18n.t('public_portal.footer.made_with') %>
|
||||
|
||||
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,9 +30,8 @@
|
||||
</h1>
|
||||
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
|
||||
<div class="flex items-start space-x-1">
|
||||
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
|
||||
<%= render "public/api/v1/portals/thumbnail", author: article.author, size: 5 %>
|
||||
</div>
|
||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400"><%= "#{I18n.t('public_portal.common.by')} #{article.author&.available_name || article.author&.name || ''}" %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,17 +32,7 @@
|
||||
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3>
|
||||
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<% author = article.author %>
|
||||
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
|
||||
<% if author&.name.present? %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= "#{I18n.t('public_portal.common.by')} #{author&.name}" %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -34,16 +34,24 @@ as well as a link to its edit page.
|
||||
|
||||
<section class="main-content__body">
|
||||
<dl>
|
||||
<% page.attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: attribute.name.titleize,
|
||||
) %>
|
||||
</dt>
|
||||
<% page.attributes.each do |title, attributes| %>
|
||||
<fieldset class="<%= "field-unit--nested" if title.present? %>">
|
||||
<% if title.present? %>
|
||||
<legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
|
||||
<% end %>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</dt>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% end %>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -69,13 +77,15 @@ as well as a link to its edit page.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% account_user_page.attributes.each do |attribute| -%>
|
||||
<% if attribute.name == "account" %>
|
||||
<%= f.hidden_field('account_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% account_user_page.attributes.each do |title, attributes| -%>
|
||||
<% attributes.each do |attribute| %>
|
||||
<% if attribute.name == "account" %>
|
||||
<%= f.hidden_field('account_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end -%>
|
||||
|
||||
|
||||
@@ -34,16 +34,24 @@ as well as a link to its edit page.
|
||||
|
||||
<section class="main-content__body">
|
||||
<dl>
|
||||
<% page.attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: attribute.name.titleize,
|
||||
) %>
|
||||
</dt>
|
||||
<% page.attributes.each do |title, attributes| %>
|
||||
<fieldset class="<%= "field-unit--nested" if title.present? %>">
|
||||
<% if title.present? %>
|
||||
<legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
|
||||
<% end %>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</dt>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% end %>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -69,14 +77,16 @@ as well as a link to its edit page.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% account_user_page.attributes.each do |attribute| -%>
|
||||
<% if attribute.name == "user" %>
|
||||
<%= f.hidden_field('user_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% account_user_page.attributes.each do |title, attributes| -%>
|
||||
<% attributes.each do |attribute| %>
|
||||
<% if attribute.name == "user" %>
|
||||
<%= f.hidden_field('user_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end -%>
|
||||
|
||||
<div class="form-actions">
|
||||
|
||||
@@ -8,6 +8,9 @@ class EmailReplyWorker
|
||||
return unless message.email_notifiable_message?
|
||||
|
||||
# send the email
|
||||
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_later
|
||||
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
message.update!(status: :failed, external_error: e.message)
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '3.5.1'
|
||||
version: '3.5.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddRegexToCustomAttributeDefinition < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :custom_attribute_definitions, :regex_pattern, :string
|
||||
add_column :custom_attribute_definitions, :regex_cue, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class RefactorSlaPolicyColumns < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
rename_column :sla_policies, :rt_threshold, :next_response_time_threshold
|
||||
rename_column :sla_policies, :frt_threshold, :first_response_time_threshold
|
||||
add_column :sla_policies, :description, :string
|
||||
add_column :sla_policies, :resolution_time_threshold, :float
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateAppliedSlas < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :applied_slas do |t|
|
||||
t.references :account, null: false
|
||||
t.references :sla_policy, null: false
|
||||
t.references :conversation, null: false
|
||||
|
||||
t.string :sla_status
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddContactTypeToContacts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :contacts, :contact_type, :integer, default: 0
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddMiddleNameAndLastNameToContacts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :contacts, :middle_name, :string, default: ''
|
||||
add_column :contacts, :last_name, :string, default: ''
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddLocationAndCountryCodeToContacts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :contacts, :location, :string, default: ''
|
||||
add_column :contacts, :country_code, :string, default: ''
|
||||
end
|
||||
end
|
||||
+24
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_01_29_080827) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -115,6 +115,18 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
||||
end
|
||||
|
||||
create_table "applied_slas", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.string "sla_status"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_applied_slas_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
|
||||
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
|
||||
end
|
||||
|
||||
create_table "articles", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.integer "portal_id", null: false
|
||||
@@ -406,6 +418,11 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
t.string "identifier"
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
t.integer "contact_type", default: 0
|
||||
t.string "middle_name", default: ""
|
||||
t.string "last_name", default: ""
|
||||
t.string "location", default: ""
|
||||
t.string "country_code", default: ""
|
||||
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id"], name: "index_contacts_on_account_id"
|
||||
@@ -501,6 +518,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "attribute_description"
|
||||
t.jsonb "attribute_values", default: []
|
||||
t.string "regex_pattern"
|
||||
t.string "regex_cue"
|
||||
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
|
||||
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
|
||||
end
|
||||
@@ -822,12 +841,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
|
||||
create_table "sla_policies", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.float "frt_threshold"
|
||||
t.float "rt_threshold"
|
||||
t.float "first_response_time_threshold"
|
||||
t.float "next_response_time_threshold"
|
||||
t.boolean "only_during_business_hours", default: false
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "description"
|
||||
t.float "resolution_time_threshold"
|
||||
t.index ["account_id"], name: "index_sla_policies_on_account_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:sla_policy).permit(:name, :rt_threshold, :frt_threshold, :only_during_business_hours)
|
||||
params.require(:sla_policy).permit(:name, :description, :first_response_time_threshold, :next_response_time_threshold,
|
||||
:resolution_time_threshold, :only_during_business_hours)
|
||||
end
|
||||
|
||||
def fetch_sla
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user