Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e007c4c3 | ||
|
|
fa907840c7 | ||
|
|
3760f206e8 | ||
|
|
a861257f73 | ||
|
|
143299f138 | ||
|
|
232369cd5c | ||
|
|
4b40c61201 | ||
|
|
834c219b9b | ||
|
|
d0cd1c8887 | ||
|
|
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 |
@@ -114,7 +114,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'
|
||||
|
||||
|
||||
+5
-5
@@ -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
|
||||
@@ -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)
|
||||
@@ -939,7 +939,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
|
||||
|
||||
@@ -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`
|
||||
"
|
||||
>
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
/>
|
||||
<file-upload
|
||||
ref="upload"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
v-tooltip.top-end="
|
||||
disableFileUpload
|
||||
? $t('CONVERSATION.REPLYBOX.TIP_ATTACHMENT_LIMIT_FB')
|
||||
: $t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')
|
||||
"
|
||||
input-id="conversationAttachment"
|
||||
:disabled="disableFileUpload"
|
||||
:size="4096 * 4096"
|
||||
:accept="allowedFileTypes"
|
||||
:multiple="enableMultipleFileUpload"
|
||||
:drop="enableDragAndDrop"
|
||||
:drop="disableFileUpload ? false : enableDragAndDrop"
|
||||
:drop-directory="false"
|
||||
:data="{
|
||||
direct_upload_url: '/rails/active_storage/direct_uploads',
|
||||
@@ -29,7 +34,12 @@
|
||||
<woot-button
|
||||
v-if="showAttachButton"
|
||||
class-names="button--upload"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
:disabled="disableFileUpload"
|
||||
:title="
|
||||
disableFileUpload
|
||||
? $t('CONVERSATION.REPLYBOX.TIP_ATTACHMENT_LIMIT_FB')
|
||||
: $t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')
|
||||
"
|
||||
icon="attach"
|
||||
emoji="📎"
|
||||
color-scheme="secondary"
|
||||
@@ -248,6 +258,10 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
disableFileUpload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
<reply-bottom-panel
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:disable-file-upload="disableFileUpload"
|
||||
:has-whatsapp-templates="hasWhatsappTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
@@ -465,6 +466,13 @@ export default {
|
||||
showReplyHead() {
|
||||
return !this.isOnPrivateNote && this.isAnEmailChannel;
|
||||
},
|
||||
disableFileUpload() {
|
||||
if (this.isAFacebookInbox) {
|
||||
return this.hasAttachments > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
enableMultipleFileUpload() {
|
||||
return (
|
||||
this.isAnEmailChannel ||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"TIP_FORMAT_ICON": "Show rich text editor",
|
||||
"TIP_EMOJI_ICON": "Show emoji selector",
|
||||
"TIP_ATTACH_ICON": "Attach files",
|
||||
"TIP_ATTACHMENT_LIMIT_FB": "You cannot attach more files to this message",
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
@@ -323,4 +324,4 @@
|
||||
"TRANSLATED_CONTENT": "Translated Content",
|
||||
"NO_TRANSLATIONS_AVAILABLE": "No translations are available for this content"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
-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>
|
||||
|
||||
@@ -84,6 +84,10 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.typingTimer);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChange(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# additional_attributes :jsonb
|
||||
# contact_type :integer default("visitor")
|
||||
# custom_attributes :jsonb
|
||||
# email :string
|
||||
# identifier :string
|
||||
# last_activity_at :datetime
|
||||
# last_name :string default("")
|
||||
# middle_name :string default("")
|
||||
# name :string default("")
|
||||
# phone_number :string
|
||||
# created_at :datetime not null
|
||||
@@ -55,6 +58,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
|
||||
|
||||
@@ -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)}%"
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
+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
|
||||
+22
-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_24_084032) 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,9 @@ 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.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 +516,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 +839,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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: applied_slas
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# sla_status :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# sla_policy_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_applied_slas_on_account_id (account_id)
|
||||
# index_applied_slas_on_conversation_id (conversation_id)
|
||||
# index_applied_slas_on_sla_policy_id (sla_policy_id)
|
||||
#
|
||||
class AppliedSla < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :sla_policy
|
||||
belongs_to :conversation
|
||||
end
|
||||
@@ -2,14 +2,16 @@
|
||||
#
|
||||
# Table name: sla_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# frt_threshold :float
|
||||
# name :string not null
|
||||
# only_during_business_hours :boolean default(FALSE)
|
||||
# rt_threshold :float
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# description :string
|
||||
# first_response_time_threshold :float
|
||||
# name :string not null
|
||||
# next_response_time_threshold :float
|
||||
# only_during_business_hours :boolean default(FALSE)
|
||||
# resolution_time_threshold :float
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
json.id sla_policy.id
|
||||
json.name sla_policy.name
|
||||
json.frt_threshold sla_policy.frt_threshold
|
||||
json.rt_threshold sla_policy.rt_threshold
|
||||
json.description sla_policy.description
|
||||
json.first_response_time_threshold sla_policy.first_response_time_threshold
|
||||
json.next_response_time_threshold sla_policy.next_response_time_threshold
|
||||
json.resolution_time_threshold sla_policy.resolution_time_threshold
|
||||
json.only_during_business_hours sla_policy.only_during_business_hours
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"attribute_name": "Phone Number",
|
||||
"input_type": "text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"email": {
|
||||
|
||||
@@ -38,4 +38,5 @@ module Redis::RedisKeys
|
||||
FACEBOOK_MESSAGE_MUTEX = 'FB_MESSAGE_CREATE_LOCK::%<sender_id>s::%<recipient_id>s'.freeze
|
||||
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
|
||||
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
|
||||
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "3.5.1",
|
||||
"version": "3.5.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -18,6 +18,7 @@ describe ContactMergeAction do
|
||||
create(:conversation, contact: base_contact)
|
||||
create(:conversation, contact: mergee_contact)
|
||||
create(:message, sender: mergee_contact)
|
||||
create(:note, contact: mergee_contact, account: mergee_contact.account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,6 +69,17 @@ describe ContactMergeAction do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mergee contact has notes' do
|
||||
it 'moves the notes to base contact' do
|
||||
expect(base_contact.notes.count).to be 0
|
||||
expect(mergee_contact.notes.count).to be 2
|
||||
|
||||
contact_merge
|
||||
|
||||
expect(base_contact.reload.notes.count).to be 2
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contacts belong to a different account' do
|
||||
it 'throws an exception' do
|
||||
new_account = create(:account)
|
||||
|
||||
@@ -29,6 +29,25 @@ RSpec.describe 'API Base', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'request with api_access_token for a super admin' do
|
||||
before do
|
||||
user.update!(type: 'SuperAdmin')
|
||||
end
|
||||
|
||||
context 'when its a valid api_access_token' do
|
||||
it 'returns current user information' do
|
||||
get '/api/v1/profile',
|
||||
headers: { api_access_token: user.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['id']).to eq(user.id)
|
||||
expect(json_response['email']).to eq(user.email)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'request with api_access_token for bot' do
|
||||
let!(:agent_bot) { create(:agent_bot) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
@@ -36,9 +36,11 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].first['email']).to eq(contact.email)
|
||||
expect(response_body['payload'].first['contact_inboxes'].first['source_id']).to eq(contact_inbox.source_id)
|
||||
expect(response_body['payload'].first['contact_inboxes'].first['inbox']['name']).to eq(contact_inbox.inbox.name)
|
||||
contact_emails = response_body['payload'].pluck('email')
|
||||
contact_inboxes_source_ids = response_body['payload'].flat_map { |c| c['contact_inboxes'].pluck('source_id') }
|
||||
|
||||
expect(contact_emails).to include(contact.email)
|
||||
expect(contact_inboxes_source_ids).to include(contact_inbox.source_id)
|
||||
end
|
||||
|
||||
it 'returns all contacts without contact inboxes' do
|
||||
|
||||
@@ -55,8 +55,12 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/dashboard_apps' do
|
||||
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
|
||||
let(:no_ssl_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'http://link.com' }] } } }
|
||||
let(:invalid_type_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'dda', url: 'https://link.com' }] } } }
|
||||
let(:invalid_url_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'com' }] } } }
|
||||
let(:non_http_url_payload) do
|
||||
{ dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'ftp://wontwork.chatwoot.com/hello-world' }] } }
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -82,6 +86,19 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
|
||||
end
|
||||
|
||||
it 'creates the dashboard app even if the URL does not have SSL' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
|
||||
params: no_ssl_payload
|
||||
end.to change(DashboardApp, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['title']).to eq 'CRM Dashboard'
|
||||
expect(json_response['content'][0]['link']).to eq payload[:dashboard_app][:content][0][:link]
|
||||
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
|
||||
end
|
||||
|
||||
it 'does not create the dashboard app if invalid URL' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
|
||||
@@ -93,6 +110,17 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(json_response['message']).to eq 'Content : Invalid data'
|
||||
end
|
||||
|
||||
it 'does not create the dashboard app if non HTTP URL' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
|
||||
params: non_http_url_payload
|
||||
end.not_to change(DashboardApp, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['message']).to eq 'Content : Invalid data'
|
||||
end
|
||||
|
||||
it 'does not create the dashboard app if invalid type' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
|
||||
|
||||
@@ -80,8 +80,10 @@ RSpec.describe 'Enterprise SLA API', type: :request do
|
||||
describe 'POST #create' do
|
||||
let(:valid_params) do
|
||||
{ sla_policy: { name: 'SLA 2',
|
||||
frt_threshold: 1000,
|
||||
rt_threshold: 1000,
|
||||
description: 'SLA for premium customers',
|
||||
first_response_time_threshold: 1000,
|
||||
next_response_time_threshold: 2000,
|
||||
resolution_time_threshold: 3000,
|
||||
only_during_business_hours: false } }
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppliedSla, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:sla_policy) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:conversation) }
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
it 'creates valid applied sla policy object' do
|
||||
applied_sla = create(:applied_sla)
|
||||
expect(applied_sla.sla_status).to eq 'active'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,11 @@ RSpec.describe SlaPolicy, type: :model do
|
||||
it 'creates valid sla policy object' do
|
||||
sla_policy = create(:sla_policy)
|
||||
expect(sla_policy.name).to eq 'sla_1'
|
||||
expect(sla_policy.first_response_time_threshold).to eq 2000
|
||||
expect(sla_policy.description).to eq 'SLA policy for enterprise customers'
|
||||
expect(sla_policy.next_response_time_threshold).to eq 1000
|
||||
expect(sla_policy.resolution_time_threshold).to eq 3000
|
||||
expect(sla_policy.only_during_business_hours).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
FactoryBot.define do
|
||||
factory :applied_sla do
|
||||
account
|
||||
sla_policy
|
||||
conversation
|
||||
sla_status { 'active' }
|
||||
end
|
||||
end
|
||||
@@ -2,8 +2,10 @@ FactoryBot.define do
|
||||
factory :sla_policy do
|
||||
account
|
||||
name { 'sla_1' }
|
||||
rt_threshold { 1000 }
|
||||
frt_threshold { 2000 }
|
||||
first_response_time_threshold { 2000 }
|
||||
description { 'SLA policy for enterprise customers' }
|
||||
next_response_time_threshold { 1000 }
|
||||
resolution_time_threshold { 3000 }
|
||||
only_during_business_hours { false }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -151,5 +151,20 @@ RSpec.describe DataImportJob do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the CSV file is invalid' do
|
||||
let(:invalid_csv_content) do
|
||||
"id,name,email,phone_number,company\n1,\"Clarice Uzzell,\"missing_quote,918080808080,Acmecorp\n2,Marieann Creegan,,+918080808081,Acmecorp"
|
||||
end
|
||||
|
||||
before do
|
||||
allow(data_import.import_file).to receive(:download).and_return(invalid_csv_content)
|
||||
end
|
||||
|
||||
it 'does not import any data and handles the MalformedCSVError' do
|
||||
expect { described_class.perform_now(data_import) }
|
||||
.to change { data_import.reload.status }.from('pending').to('failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,9 @@ RSpec.describe Inboxes::UpdateWidgetPreChatCustomFieldsJob do
|
||||
pre_chat_message = 'Share your queries here.'
|
||||
custom_attribute = {
|
||||
'attribute_key' => 'developer_id',
|
||||
'attribute_display_name' => 'Developer Number'
|
||||
'attribute_display_name' => 'Developer Number',
|
||||
'regex_pattern' => '^[0-9]*',
|
||||
'regex_cue' => 'It should be only digits'
|
||||
}
|
||||
let!(:account) { create(:account) }
|
||||
let!(:web_widget) do
|
||||
@@ -23,7 +25,8 @@ RSpec.describe Inboxes::UpdateWidgetPreChatCustomFieldsJob do
|
||||
described_class.perform_now(account, custom_attribute)
|
||||
expect(web_widget.reload.pre_chat_form_options['pre_chat_fields']).to eq [
|
||||
{ 'label' => 'Developer Number', 'name' => 'developer_id', 'placeholder' => 'Developer Number',
|
||||
'values' => nil }, { 'label' => 'Full Name', 'name' => 'full_name' }
|
||||
'values' => nil, 'regex_pattern' => '^[0-9]*', 'regex_cue' => 'It should be only digits' },
|
||||
{ 'label' => 'Full Name', 'name' => 'full_name' }
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,45 +4,51 @@ RSpec.describe MutexApplicationJob do
|
||||
let(:lock_manager) { instance_double(Redis::LockManager) }
|
||||
let(:lock_key) { 'test_key' }
|
||||
|
||||
let(:test_mutex_job_class) do
|
||||
stub_const('TestMutexJob', Class.new(MutexApplicationJob) do
|
||||
def perform
|
||||
with_lock('test_key') do
|
||||
# Do nothing
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
allow(lock_manager).to receive(:locked?).and_return(false)
|
||||
allow(lock_manager).to receive(:lock).and_return(true)
|
||||
allow(lock_manager).to receive(:unlock).and_return(true)
|
||||
end
|
||||
|
||||
describe '#with_lock' do
|
||||
it 'acquires the lock and yields the block if lock is not acquired' do
|
||||
expect(lock_manager).to receive(:locked?).with(lock_key).and_return(false)
|
||||
expect(lock_manager).to receive(:lock).with(lock_key).and_return(true)
|
||||
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
|
||||
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
|
||||
|
||||
expect { |b| described_class.new.send(:with_lock, lock_key, &b) }.to yield_control
|
||||
end
|
||||
|
||||
it 'acquires the lock with custom timeout' do
|
||||
expect(lock_manager).to receive(:lock).with(lock_key, 5.seconds).and_return(true)
|
||||
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
|
||||
|
||||
expect { |b| described_class.new.send(:with_lock, lock_key, 5.seconds, &b) }.to yield_control
|
||||
end
|
||||
|
||||
it 'raises LockAcquisitionError if it cannot acquire the lock' do
|
||||
allow(lock_manager).to receive(:locked?).with(lock_key).and_return(true)
|
||||
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
|
||||
|
||||
expect do
|
||||
described_class.new.send(:with_lock, lock_key) do
|
||||
# Do nothing
|
||||
end
|
||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
||||
expect(lock_manager).not_to receive(:unlock)
|
||||
end
|
||||
|
||||
it 'raises StandardError if it execution raises it' do
|
||||
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
|
||||
allow(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
|
||||
|
||||
expect do
|
||||
described_class.new.send(:with_lock, lock_key) do
|
||||
raise StandardError
|
||||
end
|
||||
end.to raise_error(StandardError)
|
||||
end
|
||||
|
||||
it 'ensures that the lock is released even if there is an error during block execution' do
|
||||
expect(lock_manager).to receive(:locked?).with(lock_key).and_return(false)
|
||||
expect(lock_manager).to receive(:lock).with(lock_key).and_return(true)
|
||||
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
|
||||
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
|
||||
|
||||
expect do
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Notification::RemoveOldNotificationJob do
|
||||
let(:user) { create(:user) }
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
notification = create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation)
|
||||
expect do
|
||||
described_class.perform_later(notification)
|
||||
end.to have_enqueued_job(described_class)
|
||||
.on_queue('low')
|
||||
end
|
||||
|
||||
it 'removes old notifications which are older than 1 month' do
|
||||
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 2.months.ago)
|
||||
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.month.ago)
|
||||
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.day.ago)
|
||||
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.hour.ago)
|
||||
|
||||
described_class.perform_now
|
||||
expect(Notification.count).to eq(2)
|
||||
end
|
||||
end
|
||||
@@ -40,5 +40,10 @@ RSpec.describe TriggerScheduledItemsJob do
|
||||
expect(Channels::Whatsapp::TemplatesSyncSchedulerJob).to receive(:perform_later).once
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'triggers Notification::RemoveOldNotificationJob' do
|
||||
expect(Notification::RemoveOldNotificationJob).to receive(:perform_later).once
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -83,6 +83,7 @@ describe AutomationRuleListener do
|
||||
{
|
||||
attribute_key: 'customer_type',
|
||||
filter_operator: 'equal_to',
|
||||
custom_attribute_type: 'contact_attribute',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
@@ -154,6 +155,7 @@ describe AutomationRuleListener do
|
||||
{
|
||||
attribute_key: 'customer_type',
|
||||
filter_operator: 'equal_to',
|
||||
custom_attribute_type: 'contact_attribute',
|
||||
values: ['platinum'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
|
||||
@@ -11,7 +11,7 @@ RSpec.describe Imap::ImapMailbox do
|
||||
imap_port: 993, imap_login: 'imap@gmail.com', imap_password: 'password',
|
||||
account: account)
|
||||
end
|
||||
let(:inbox) { create(:inbox, channel: channel, account: account) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let!(:contact) { create(:contact, email: 'email@gmail.com', phone_number: '+919584546666', account: account, identifier: '123') }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel.inbox).last }
|
||||
let(:class_instance) { described_class.new }
|
||||
|
||||
@@ -3,6 +3,58 @@ require 'rails_helper'
|
||||
RSpec.describe Channel::Telegram do
|
||||
let(:telegram_channel) { create(:channel_telegram) }
|
||||
|
||||
describe '#convert_markdown_to_telegram_html' do
|
||||
subject { telegram_channel.send(:convert_markdown_to_telegram_html, text) }
|
||||
|
||||
context 'when text contains multiple newline characters' do
|
||||
let(:text) { "Line one\nLine two\n\nLine four" }
|
||||
|
||||
it 'preserves multiple newline characters' do
|
||||
expect(subject).to eq("Line one\nLine two\n\nLine four")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when text contains broken markdown' do
|
||||
let(:text) { 'This is a **broken markdown with <b>HTML</b> tags.' }
|
||||
|
||||
it 'does not break and properly converts to Telegram HTML format and escapes html tags' do
|
||||
expect(subject).to eq('This is a **broken markdown with <b>HTML</b> tags.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when text contains markdown and HTML elements' do
|
||||
let(:text) { "Hello *world*! This is <b>bold</b> and this is <i>italic</i>.\nThis is a new line." }
|
||||
|
||||
it 'converts markdown to Telegram HTML format and escapes other html' do
|
||||
expect(subject).to eq("Hello <em>world</em>! This is <b>bold</b> and this is <i>italic</i>.\nThis is a new line.")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when text contains unsupported HTML tags' do
|
||||
let(:text) { 'This is a <span>test</span> with unsupported tags.' }
|
||||
|
||||
it 'removes unsupported HTML tags' do
|
||||
expect(subject).to eq('This is a <span>test</span> with unsupported tags.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when text contains special characters' do
|
||||
let(:text) { 'Special characters: & < >' }
|
||||
|
||||
it 'escapes special characters' do
|
||||
expect(subject).to eq('Special characters: & < >')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when text contains markdown links' do
|
||||
let(:text) { 'Check this [link](http://example.com) out!' }
|
||||
|
||||
it 'converts markdown links to Telegram HTML format' do
|
||||
expect(subject).to eq('Check this <a href="http://example.com">link</a> out!')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a valid message and empty attachments' do
|
||||
it 'send message' do
|
||||
message = create(:message, message_type: :outgoing, content: 'test',
|
||||
@@ -10,7 +62,7 @@ RSpec.describe Channel::Telegram do
|
||||
|
||||
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
|
||||
.with(
|
||||
body: 'chat_id=123&text=test&reply_markup=&parse_mode=Markdown&reply_to_message_id='
|
||||
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -21,13 +73,15 @@ RSpec.describe Channel::Telegram do
|
||||
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
|
||||
end
|
||||
|
||||
it 'send message with markdown converted to telegram markdown' do
|
||||
it 'send message with markdown converted to telegram HTML' do
|
||||
message = create(:message, message_type: :outgoing, content: '**test** *test* ~test~',
|
||||
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
|
||||
|
||||
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
|
||||
.with(
|
||||
body: "chat_id=123&text=#{ERB::Util.url_encode('*test* *test* ~test~')}&reply_markup=&parse_mode=Markdown&reply_to_message_id="
|
||||
body: "chat_id=123&text=#{
|
||||
ERB::Util.url_encode('<strong>test</strong> <em>test</em> ~test~')
|
||||
}&reply_markup=&parse_mode=HTML&reply_to_message_id="
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -49,7 +103,7 @@ RSpec.describe Channel::Telegram do
|
||||
.with(
|
||||
body: 'chat_id=123&text=test' \
|
||||
'&reply_markup=%7B%22one_time_keyboard%22%3Atrue%2C%22inline_keyboard%22%3A%5B%5B%7B%22text%22%3A%22test%22%2C%22' \
|
||||
'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=Markdown&reply_to_message_id='
|
||||
'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=HTML&reply_to_message_id='
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -66,7 +120,7 @@ RSpec.describe Channel::Telegram do
|
||||
|
||||
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
|
||||
.with(
|
||||
body: 'chat_id=123&text=test&reply_markup=&parse_mode=Markdown&reply_to_message_id='
|
||||
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
|
||||
)
|
||||
.to_return(
|
||||
status: 403,
|
||||
|
||||
@@ -25,7 +25,7 @@ RSpec.describe Inbox do
|
||||
|
||||
it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
|
||||
|
||||
it { is_expected.to have_many(:messages).through(:conversations) }
|
||||
it { is_expected.to have_many(:messages).dependent(:destroy_async) }
|
||||
|
||||
it { is_expected.to have_one(:agent_bot_inbox) }
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::ConditionValidationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:rule) { create(:automation_rule, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'with standard attributes' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' },
|
||||
{ 'values': ['+918484'], 'attribute_key': 'phone_number', 'query_operator': 'OR', 'filter_operator': 'contains' },
|
||||
{ 'values': ['test'], 'attribute_key': 'email', 'query_operator': nil, 'filter_operator': 'contains' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.new(rule).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with wrong attribute' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'not-a-standard-attribute-for-sure', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.new(rule).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with wrong filter operator' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'not-a-filter-operator' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.new(rule).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with "attribute_changed" filter operator' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'attribute_changed' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.new(rule).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with correct custom attribute' do
|
||||
before do
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'custom_attr_priority',
|
||||
account: account,
|
||||
attribute_model: 'conversation_attribute',
|
||||
attribute_display_type: 'list',
|
||||
attribute_values: %w[P0 P1 P2])
|
||||
|
||||
rule.conditions = [
|
||||
{
|
||||
'values': ['true'],
|
||||
'attribute_key': 'custom_attr_priority',
|
||||
'filter_operator': 'equal_to',
|
||||
'custom_attribute_type': 'conversation_attribute'
|
||||
}
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.new(rule).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing custom attribute' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{
|
||||
'values': ['true'],
|
||||
'attribute_key': 'attribute_is_not_present', # the attribute is not present
|
||||
'filter_operator': 'equal_to',
|
||||
'custom_attribute_type': 'conversation_attribute'
|
||||
}
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns false for missing custom attribute' do
|
||||
expect(described_class.new(rule).perform).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user