Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54308a4145 | ||
|
|
2eeec22868 | ||
|
|
ef50edb9e2 | ||
|
|
3ed80fa867 | ||
|
|
88f44c21f2 | ||
|
|
cf664ca2a0 | ||
|
|
485c561b18 | ||
|
|
766698cb3a | ||
|
|
feb8ec7879 | ||
|
|
082793290a | ||
|
|
12916ceca6 | ||
|
|
b5e81ccd3b | ||
|
|
b31040901e | ||
|
|
3c952e6a4a | ||
|
|
59184122f7 | ||
|
|
381423b1ae | ||
|
|
b7c9f779ad | ||
|
|
904d76420d | ||
|
|
fa907840c7 | ||
|
|
3760f206e8 | ||
|
|
a861257f73 | ||
|
|
143299f138 | ||
|
|
1f610c7fc5 | ||
|
|
232369cd5c | ||
|
|
4b40c61201 | ||
|
|
834c219b9b | ||
|
|
d0cd1c8887 | ||
|
|
188a8a67c0 | ||
|
|
682a2aea1c | ||
|
|
1dc66db516 | ||
|
|
bc04d81a5a | ||
|
|
d2c5c2f9a3 | ||
|
|
381fda270a | ||
|
|
b3c9d1f1a5 | ||
|
|
fd4376d062 | ||
|
|
a8f053921b | ||
|
|
8e15ada164 | ||
|
|
51093fd543 |
@@ -39,6 +39,8 @@ gem 'rack-attack', '>= 6.7.0'
|
||||
gem 'down'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||
gem 'net-smtp', '~> 0.3.4'
|
||||
# Prevent CSV injection
|
||||
gem 'csv-safe'
|
||||
|
||||
@@ -74,7 +76,7 @@ gem 'devise_token_auth'
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.19.0'
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.1'
|
||||
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
|
||||
|
||||
@@ -114,7 +116,7 @@ gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.14.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.1.3'
|
||||
gem 'sidekiq', '>= 7.2.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
|
||||
|
||||
+15
-14
@@ -105,12 +105,12 @@ GEM
|
||||
activerecord (>= 6.0, < 7.1)
|
||||
addressable (2.8.4)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
administrate (0.19.0)
|
||||
actionpack (>= 5.0)
|
||||
actionview (>= 5.0)
|
||||
activerecord (>= 5.0)
|
||||
jquery-rails (>= 4.0)
|
||||
kaminari (>= 1.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
activerecord (>= 6.0, < 8.0)
|
||||
jquery-rails (~> 4.6.0)
|
||||
kaminari (~> 1.2.2)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (1.0.1)
|
||||
@@ -169,7 +169,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.2.2)
|
||||
concurrent-ruby (1.2.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
@@ -461,7 +461,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.20.0)
|
||||
minitest (5.21.2)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
@@ -479,7 +479,7 @@ GEM
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.4.0)
|
||||
net-smtp (0.3.4)
|
||||
net-protocol
|
||||
netrc (0.11.0)
|
||||
newrelic-sidekiq-metrics (1.6.2)
|
||||
@@ -610,7 +610,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.19.0)
|
||||
redis-client (0.19.1)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
@@ -720,11 +720,11 @@ GEM
|
||||
sexp_processor (4.17.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.2.0)
|
||||
sidekiq (7.2.1)
|
||||
concurrent-ruby (< 2)
|
||||
connection_pool (>= 2.3.0)
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.14.0)
|
||||
redis-client (>= 0.19.0)
|
||||
sidekiq-cron (1.12.0)
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
@@ -840,7 +840,7 @@ DEPENDENCIES
|
||||
active_record_query_trace
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.19.0)
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.1)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
annotate
|
||||
@@ -903,6 +903,7 @@ DEPENDENCIES
|
||||
meta_request
|
||||
mock_redis
|
||||
neighbor
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth (>= 2.1.2)
|
||||
@@ -939,7 +940,7 @@ DEPENDENCIES
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.14.0)
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.1.3)
|
||||
sidekiq (>= 7.2.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.2.0)
|
||||
|
||||
@@ -12,6 +12,7 @@ class ContactMergeAction
|
||||
merge_conversations
|
||||
merge_messages
|
||||
merge_contact_inboxes
|
||||
merge_contact_notes
|
||||
merge_and_remove_mergee_contact
|
||||
end
|
||||
@base_contact
|
||||
@@ -33,6 +34,10 @@ class ContactMergeAction
|
||||
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
|
||||
end
|
||||
|
||||
def merge_contact_notes
|
||||
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id)
|
||||
end
|
||||
|
||||
def merge_messages
|
||||
Message.where(sender: @mergee_contact).update(sender: @base_contact)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,11 +29,12 @@ export default {
|
||||
return fetchPromise;
|
||||
},
|
||||
hasAuthCookie() {
|
||||
return !!Cookies.getJSON('cw_d_session_info');
|
||||
return !!Cookies.get('cw_d_session_info');
|
||||
},
|
||||
getAuthData() {
|
||||
if (this.hasAuthCookie()) {
|
||||
return Cookies.getJSON('cw_d_session_info');
|
||||
const savedAuthInfo = Cookies.get('cw_d_session_info');
|
||||
return JSON.parse(savedAuthInfo || '{}');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -126,18 +126,26 @@ import { required, url } from 'vuelidate/lib/validators';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
|
||||
import { isValidURL } from '../helper/URLHelper';
|
||||
import customAttributeMixin from '../mixins/customAttributeMixin';
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MultiselectDropdown,
|
||||
},
|
||||
mixins: [customAttributeMixin],
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
values: { type: Array, default: () => [] },
|
||||
value: { type: [String, Number, Boolean], default: '' },
|
||||
showActions: { type: Boolean, default: false },
|
||||
attributeType: { type: String, default: 'text' },
|
||||
attributeRegex: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
regexCue: { type: String, default: null },
|
||||
regexEnabled: { type: Boolean, default: false },
|
||||
attributeKey: { type: String, required: true },
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
@@ -204,6 +212,11 @@ export default {
|
||||
if (this.$v.editedValue.url) {
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
|
||||
}
|
||||
if (!this.$v.editedValue.regexValidation) {
|
||||
return this.regexCue
|
||||
? this.regexCue
|
||||
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
|
||||
}
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
},
|
||||
},
|
||||
@@ -221,7 +234,15 @@ export default {
|
||||
};
|
||||
}
|
||||
return {
|
||||
editedValue: { required },
|
||||
editedValue: {
|
||||
required,
|
||||
regexValidation: value => {
|
||||
return !(
|
||||
this.attributeRegex &&
|
||||
!this.getRegexp(this.attributeRegex).test(value)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@
|
||||
class="bg-slate-50 dark:bg-slate-700 rounded text-xxs font-medium mx-1 py-0 px-1"
|
||||
:class="
|
||||
isCountZero
|
||||
? `text-slate-300 dark:text-slate-700`
|
||||
? `text-slate-300 dark:text-slate-500`
|
||||
: `text-slate-700 dark:text-slate-50`
|
||||
"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
|
||||
<div :class="wrapClass">
|
||||
<div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert">
|
||||
<div
|
||||
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
|
||||
class="message-failed--alert"
|
||||
>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
|
||||
size="tiny"
|
||||
@@ -203,6 +206,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAnEmailInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-an-email-inbox="isAnEmailChannel"
|
||||
:is-instagram="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
|
||||
@click="toggleCountryDropdown"
|
||||
>
|
||||
<h5 v-if="activeCountry.emoji" class="mb-0">
|
||||
<h5 v-if="activeCountry" class="mb-0">
|
||||
{{ activeCountry.emoji }}
|
||||
</h5>
|
||||
<fluent-icon v-else icon="globe" class="fluent-icon" size="16" />
|
||||
|
||||
@@ -47,6 +47,8 @@ export const getCustomFields = ({ standardFields, customAttributes }) => {
|
||||
type: attribute.attribute_display_type,
|
||||
values: attribute.attribute_values,
|
||||
field_type: attribute.attribute_model,
|
||||
regex_pattern: attribute.regex_pattern,
|
||||
regex_cue: attribute.regex_cue,
|
||||
required: false,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
@@ -44,4 +44,18 @@ export default {
|
||||
created_at: '2021-11-29T10:20:04.563Z',
|
||||
},
|
||||
],
|
||||
customAttributesWithRegex: [
|
||||
{
|
||||
id: 2,
|
||||
attribute_description: 'Test contact Attribute',
|
||||
attribute_display_name: 'Test contact Attribute',
|
||||
attribute_display_type: 'text',
|
||||
attribute_key: 'test_contact_attribute',
|
||||
attribute_model: 'contact_attribute',
|
||||
attribute_values: Array(0),
|
||||
created_at: '2023-09-20T10:20:04.563Z',
|
||||
regex_pattern: '^w+$',
|
||||
regex_cue: 'It should be a combination of alphabets and numbers',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
} from '../preChat';
|
||||
import inboxFixture from './inboxFixture';
|
||||
|
||||
const { customFields, customAttributes } = inboxFixture;
|
||||
const { customFields, customAttributes, customAttributesWithRegex } =
|
||||
inboxFixture;
|
||||
describe('#Pre chat Helpers', () => {
|
||||
describe('getPreChatFields', () => {
|
||||
it('should return correct pre-chat fields form options passed', () => {
|
||||
@@ -27,7 +28,6 @@ describe('#Pre chat Helpers', () => {
|
||||
placeholder: 'Please enter your email address',
|
||||
type: 'email',
|
||||
field_type: 'standard',
|
||||
|
||||
required: false,
|
||||
enabled: false,
|
||||
},
|
||||
@@ -71,6 +71,26 @@ describe('#Pre chat Helpers', () => {
|
||||
values: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getCustomFields({
|
||||
standardFields: { pre_chat_fields: customFields.pre_chat_fields },
|
||||
customAttributes: customAttributesWithRegex,
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
enabled: false,
|
||||
label: 'Test contact Attribute',
|
||||
placeholder: 'Test contact Attribute',
|
||||
name: 'test_contact_attribute',
|
||||
required: false,
|
||||
field_type: 'contact_attribute',
|
||||
type: 'text',
|
||||
values: [],
|
||||
regex_pattern: '^w+$',
|
||||
regex_cue: 'It should be a combination of alphabets and numbers',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,17 @@
|
||||
"PLACEHOLDER": "Enter custom attribute key",
|
||||
"ERROR": "Key is required",
|
||||
"IN_VALID": "Invalid key"
|
||||
},
|
||||
"REGEX_PATTERN": {
|
||||
"LABEL": "Regex Pattern",
|
||||
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
|
||||
},
|
||||
"REGEX_CUE": {
|
||||
"LABEL": "Regex Cue",
|
||||
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
@@ -88,6 +99,17 @@
|
||||
"EMPTY_RESULT": {
|
||||
"404": "There are no custom attributes created",
|
||||
"NOT_FOUND": "There are no custom attributes configured"
|
||||
},
|
||||
"REGEX_PATTERN": {
|
||||
"LABEL": "Regex Pattern",
|
||||
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
|
||||
},
|
||||
"REGEX_CUE": {
|
||||
"LABEL": "Regex Cue",
|
||||
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +339,8 @@
|
||||
},
|
||||
"VALIDATIONS": {
|
||||
"REQUIRED": "Valid value is required",
|
||||
"INVALID_URL": "Invalid URL"
|
||||
"INVALID_URL": "Invalid URL",
|
||||
"INVALID_INPUT": "Invalid Input"
|
||||
}
|
||||
},
|
||||
"MERGE_CONTACTS": {
|
||||
|
||||
@@ -324,7 +324,8 @@
|
||||
"LAST_EDITED": "Last edited"
|
||||
},
|
||||
"COLUMNS": {
|
||||
"BY": "by"
|
||||
"BY": "by",
|
||||
"AUTHOR_NOT_AVAILABLE": "Author is not available"
|
||||
}
|
||||
},
|
||||
"EDIT_ARTICLE": {
|
||||
|
||||
@@ -569,7 +569,7 @@
|
||||
"UPDATE": "Update business hours settings",
|
||||
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
|
||||
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
|
||||
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
|
||||
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.",
|
||||
"DAY": {
|
||||
"ENABLE": "Enable availability for this day",
|
||||
"UNAVAILABLE": "Unavailable",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
methods: {
|
||||
getRegexp(regexPatternValue) {
|
||||
let lastSlash = regexPatternValue.lastIndexOf('/');
|
||||
return new RegExp(
|
||||
regexPatternValue.slice(1, lastSlash),
|
||||
regexPatternValue.slice(lastSlash + 1)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
+2
@@ -11,6 +11,8 @@
|
||||
emoji=""
|
||||
:value="attribute.value"
|
||||
:show-actions="true"
|
||||
:attribute-regex="attribute.regex_pattern"
|
||||
:regex-cue="attribute.regex_cue"
|
||||
:class="attributeClass"
|
||||
@update="onUpdate"
|
||||
@delete="onDelete"
|
||||
|
||||
@@ -11,9 +11,30 @@
|
||||
{{ title }}
|
||||
</h6>
|
||||
</router-link>
|
||||
<div class="author">
|
||||
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span>
|
||||
<span class="name">{{ articleAuthorName }}</span>
|
||||
<div class="flex gap-1 items-center">
|
||||
<Thumbnail
|
||||
v-if="author"
|
||||
:src="author.thumbnail"
|
||||
:username="author.name"
|
||||
size="16px"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip.right="
|
||||
$t('HELP_CENTER.TABLE.COLUMNS.AUTHOR_NOT_AVAILABLE')
|
||||
"
|
||||
class="flex items-center justify-center rounded w-4 h-4 bg-woot-100 dark:bg-woot-700"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="person"
|
||||
type="filled"
|
||||
size="10"
|
||||
class="text-woot-300 dark:text-woot-300"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-normal text-slate-500 dark:text-slate-200 text-sm">
|
||||
{{ articleAuthorName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
@@ -57,10 +78,12 @@ import timeMixin from 'dashboard/mixins/time';
|
||||
import portalMixin from '../mixins/portalMixin';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import EmojiOrIcon from '../../../../../shared/components/EmojiOrIcon.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmojiOrIcon,
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [timeMixin, portalMixin],
|
||||
props: {
|
||||
@@ -110,7 +133,7 @@ export default {
|
||||
}).format(this.views || 0);
|
||||
},
|
||||
articleAuthorName() {
|
||||
return this.author.name;
|
||||
return this.author?.name || '-';
|
||||
},
|
||||
labelColor() {
|
||||
switch (this.status) {
|
||||
@@ -189,15 +212,6 @@ export default {
|
||||
.article-block {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.author {
|
||||
.by {
|
||||
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
|
||||
}
|
||||
.name {
|
||||
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="my-0 py-0 px-4 grid grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900"
|
||||
class="my-0 py-0 px-4 grid grid-cols-6 z-10 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900"
|
||||
:class="{ draggable: onCategoryPage }"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
// import { defineProps } from 'vue';
|
||||
|
||||
import PriorityIcon from './components/PriorityIcon.vue';
|
||||
import StatusIcon from './components/StatusIcon.vue';
|
||||
import InboxNameAndId from './components/InboxNameAndId.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
|
||||
// const props = defineProps({
|
||||
// notificationItem: {
|
||||
// type: Object,
|
||||
// default: () => {},
|
||||
// },
|
||||
// });
|
||||
|
||||
const assigneeMeta = {
|
||||
thumbnail: '',
|
||||
name: 'Michael Johnson',
|
||||
};
|
||||
const { thumbnail, name } = assigneeMeta || {};
|
||||
const status = 'open';
|
||||
const priority = 'high';
|
||||
const inboxTypeMessage = 'Mentioned by Michael';
|
||||
const inboxMessage = 'What is the best way to get started?';
|
||||
const inbox = {
|
||||
inbox_id: 16787,
|
||||
inbox_name: 'Chatwoot Support',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex max-w-[360px] flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-500 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
|
||||
>
|
||||
<div class="flex relative items-center justify-between w-full">
|
||||
<div
|
||||
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
|
||||
/>
|
||||
<InboxNameAndId :inbox="inbox" />
|
||||
<div class="flex gap-2">
|
||||
<PriorityIcon :priority="priority" />
|
||||
<StatusIcon :status="status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between items-center w-full">
|
||||
<div class="flex gap-1.5 items-center max-w-[80%]">
|
||||
<Thumbnail
|
||||
v-if="assigneeMeta"
|
||||
:src="thumbnail"
|
||||
:username="name"
|
||||
size="20px"
|
||||
/>
|
||||
<div class="flex min-w-0">
|
||||
<span
|
||||
class="font-medium text-slate-800 dark:text-slate-100 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ inboxTypeMessage }}<span v-if="inboxTypeMessage">:</span>
|
||||
<span class="font-normal">{{ inboxMessage }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
|
||||
>
|
||||
10h ago
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const { inbox } = props;
|
||||
const { inbox_id: inboxId, inbox_name: inboxName } = inbox;
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-600 divide-x divide-slate-100 dark:divide-slate-600 bg-none"
|
||||
>
|
||||
<div class="flex items-center gap-0.5 py-0.5 px-1.5">
|
||||
<fluent-icon
|
||||
class="text-slate-600 dark:text-slate-300"
|
||||
icon="globe-desktop"
|
||||
size="14"
|
||||
/>
|
||||
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center py-0.5 px-1.5">
|
||||
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
|
||||
{{ inboxId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.HIGH"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#FFC291" />
|
||||
</svg>
|
||||
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.LOW"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#DDDDDD" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="props.priority === CONVERSATION_PRIORITY.URGENT"
|
||||
class="h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="4" y="12" width="4" height="8" rx="1" fill="#E5484D" />
|
||||
<rect x="10" y="8" width="4" height="12" rx="1" fill="#E5484D" />
|
||||
<rect x="16" y="4" width="4" height="16" rx="1" fill="#E5484D" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Pending -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.PENDING"
|
||||
class="h-3.5 w-3.5"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.1 0.0449978V1.8558C4.5486 2.2986 1.8 5.328 1.8 9C1.8 12.9762 5.0238 16.2 9 16.2C10.6641 16.2 12.195 15.6357 13.4154 14.688L14.6961 15.9687C13.1445 17.2377 11.16 18 9 18C4.0293 18 0 13.9707 0 9C0 4.3335 3.5523 0.495898 8.1 0.0449978ZM17.955 9.9C17.775 11.7099 17.0604 13.3623 15.9687 14.6952L14.688 13.4154C15.462 12.4191 15.9804 11.2149 16.1442 9.9H17.9559H17.955ZM9.9018 0.0449978C14.1534 0.467098 17.5338 3.8484 17.9568 8.1H16.1451C15.7392 4.8438 13.158 2.2626 9.9018 1.8558V0.0440979V0.0449978Z"
|
||||
class="fill-[#B9BBC6]"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Open -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.OPEN"
|
||||
class="h-3.5 w-3.5"
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.375 18.875C4.19733 18.875 0 14.6776 0 9.5C0 4.32233 4.19733 0.125 9.375 0.125C14.5526 0.125 18.75 4.32233 18.75 9.5C18.75 14.6776 14.5526 18.875 9.375 18.875ZM9.375 17C13.5172 17 16.875 13.6422 16.875 9.5C16.875 5.35786 13.5172 2 9.375 2C5.23286 2 1.875 5.35786 1.875 9.5C1.875 13.6422 5.23286 17 9.375 17Z"
|
||||
class="fill-[#ED8A5C]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Snoozed -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.SNOOZED"
|
||||
class="h-3.5 w-3.5"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 18C4.0293 18 0 13.9707 0 9C0 4.0293 4.0293 0 9 0C13.9707 0 18 4.0293 18 9C18 13.9707 13.9707 18 9 18ZM2.9961 12.9825C3.58766 13.8676 4.36812 14.6105 5.28129 15.1577C6.19446 15.7049 7.21761 16.0428 8.27707 16.147C9.33652 16.2513 10.4059 16.1193 11.4082 15.7606C12.4105 15.4019 13.3208 14.8254 14.0736 14.0726C14.8263 13.3198 15.4027 12.4094 15.7613 11.4071C16.12 10.4047 16.2518 9.33532 16.1475 8.27588C16.0431 7.21644 15.7052 6.19332 15.1579 5.2802C14.6106 4.36707 13.8676 3.58668 12.9825 2.9952C13.3706 4.3796 13.383 5.84237 13.0186 7.23318C12.6542 8.62399 11.926 9.89269 10.9089 10.9089C9.89277 11.9258 8.62423 12.6539 7.23359 13.0183C5.84296 13.3828 4.38037 13.3704 2.9961 12.9825Z"
|
||||
class="fill-[#0B68CB]"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Resolved -->
|
||||
<svg
|
||||
v-if="props.status === CONVERSATION_STATUS.RESOLVED"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="3 3 17.92 17.92"
|
||||
>
|
||||
<path
|
||||
d="M11.96 20.92C7.01152 20.92 3 16.9084 3 11.96C3 7.01152 7.01152 3 11.96 3C16.9084 3 20.92 7.01152 20.92 11.96C20.92 16.9084 16.9084 20.92 11.96 20.92ZM11.96 19.128C15.9188 19.128 19.128 15.9188 19.128 11.96C19.128 8.00122 15.9188 4.792 11.96 4.792C8.00122 4.792 4.792 8.00122 4.792 11.96C4.792 15.9188 8.00122 19.128 11.96 19.128Z"
|
||||
class="fill-[#5BB98C]"
|
||||
/>
|
||||
<path
|
||||
d="M11.9599 17.9333C15.2589 17.9333 17.9332 15.2589 17.9332 11.96C17.9332 8.66098 15.2589 5.98663 11.9599 5.98663C8.66092 5.98663 5.98657 8.66098 5.98657 11.96C5.98657 15.2589 8.66092 17.9333 11.9599 17.9333Z"
|
||||
class="fill-[#5BB98C]"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,6 +86,30 @@
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isAttributeTypeText">
|
||||
<input
|
||||
v-model="regexEnabled"
|
||||
type="checkbox"
|
||||
@input="toggleRegexEnabled"
|
||||
/>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
|
||||
</div>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexPattern"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexCue"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
|
||||
type="text"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
|
||||
<woot-submit-button
|
||||
:disabled="isButtonDisabled"
|
||||
@@ -124,6 +148,9 @@ export default {
|
||||
attributeModel: 0,
|
||||
attributeType: 0,
|
||||
attributeKey: '',
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
models: ATTRIBUTE_MODELS,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
values: [],
|
||||
@@ -163,6 +190,12 @@ export default {
|
||||
isAttributeTypeList() {
|
||||
return this.attributeType === 6;
|
||||
},
|
||||
isAttributeTypeText() {
|
||||
return this.attributeType === 0;
|
||||
},
|
||||
isRegexEnabled() {
|
||||
return this.regexEnabled;
|
||||
},
|
||||
},
|
||||
|
||||
validations: {
|
||||
@@ -201,11 +234,18 @@ export default {
|
||||
onDisplayNameChange() {
|
||||
this.attributeKey = convertToAttributeSlug(this.displayName);
|
||||
},
|
||||
toggleRegexEnabled() {
|
||||
this.regexEnabled = !this.regexEnabled;
|
||||
},
|
||||
async addAttributes() {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
if (!this.regexEnabled) {
|
||||
this.regexPattern = null;
|
||||
this.regexCue = null;
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('attributes/create', {
|
||||
attribute_display_name: this.displayName,
|
||||
@@ -214,6 +254,10 @@ export default {
|
||||
attribute_display_type: this.attributeType,
|
||||
attribute_key: this.attributeKey,
|
||||
attribute_values: this.attributeListValues,
|
||||
regex_pattern: this.regexPattern
|
||||
? new RegExp(this.regexPattern).toString()
|
||||
: null,
|
||||
regex_cue: this.regexCue,
|
||||
});
|
||||
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
|
||||
this.onClose();
|
||||
|
||||
@@ -70,6 +70,30 @@
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isAttributeTypeText">
|
||||
<input
|
||||
v-model="regexEnabled"
|
||||
type="checkbox"
|
||||
@input="toggleRegexEnabled"
|
||||
/>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
|
||||
</div>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexPattern"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<woot-input
|
||||
v-if="isAttributeTypeText && isRegexEnabled"
|
||||
v-model="regexCue"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
|
||||
type="text"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
|
||||
<woot-button :is-loading="isUpdating" :disabled="isButtonDisabled">
|
||||
@@ -88,9 +112,10 @@ import { mapGetters } from 'vuex';
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import customAttributeMixin from '../../../../mixins/customAttributeMixin';
|
||||
export default {
|
||||
components: {},
|
||||
mixins: [alertMixin],
|
||||
mixins: [alertMixin, customAttributeMixin],
|
||||
props: {
|
||||
selectedAttribute: {
|
||||
type: Object,
|
||||
@@ -106,6 +131,9 @@ export default {
|
||||
displayName: '',
|
||||
description: '',
|
||||
attributeType: 0,
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
@@ -152,6 +180,7 @@ export default {
|
||||
this.isAttributeTypeList && this.isTouched && this.values.length === 0
|
||||
);
|
||||
},
|
||||
|
||||
pageTitle() {
|
||||
return `${this.$t('ATTRIBUTES_MGMT.EDIT.TITLE')} - ${
|
||||
this.selectedAttribute.attribute_display_name
|
||||
@@ -173,6 +202,12 @@ export default {
|
||||
isAttributeTypeList() {
|
||||
return this.attributeType === 6;
|
||||
},
|
||||
isAttributeTypeText() {
|
||||
return this.attributeType === 0;
|
||||
},
|
||||
isRegexEnabled() {
|
||||
return this.regexEnabled;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setFormValues();
|
||||
@@ -189,10 +224,16 @@ export default {
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
setFormValues() {
|
||||
const regexPattern = this.selectedAttribute.regex_pattern
|
||||
? this.getRegexp(this.selectedAttribute.regex_pattern).source
|
||||
: null;
|
||||
this.displayName = this.selectedAttribute.attribute_display_name;
|
||||
this.description = this.selectedAttribute.attribute_description;
|
||||
this.attributeType = this.selectedAttributeType;
|
||||
this.attributeKey = this.selectedAttribute.attribute_key;
|
||||
this.regexPattern = regexPattern;
|
||||
this.regexCue = this.selectedAttribute.regex_cue;
|
||||
this.regexEnabled = regexPattern != null;
|
||||
this.values = this.setAttributeListValue;
|
||||
},
|
||||
async editAttributes() {
|
||||
@@ -200,14 +241,21 @@ export default {
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
if (!this.regexEnabled) {
|
||||
this.regexPattern = null;
|
||||
this.regexCue = null;
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('attributes/update', {
|
||||
id: this.selectedAttribute.id,
|
||||
attribute_description: this.description,
|
||||
attribute_display_name: this.displayName,
|
||||
attribute_values: this.updatedAttributeListValues,
|
||||
regex_pattern: this.regexPattern
|
||||
? new RegExp(this.regexPattern).toString()
|
||||
: null,
|
||||
regex_cue: this.regexCue,
|
||||
});
|
||||
|
||||
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
@@ -218,6 +266,9 @@ export default {
|
||||
this.showAlert(this.alertMessage);
|
||||
}
|
||||
},
|
||||
toggleRegexEnabled() {
|
||||
this.regexEnabled = !this.regexEnabled;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@ jest.mock('../../../utils/api', () => ({
|
||||
getHeaderExpiry: jest.fn(),
|
||||
}));
|
||||
jest.mock('js-cookie', () => ({
|
||||
getJSON: jest.fn(),
|
||||
get: jest.fn(),
|
||||
}));
|
||||
|
||||
const commit = jest.fn();
|
||||
@@ -155,14 +155,14 @@ describe('#actions', () => {
|
||||
|
||||
describe('#setUser', () => {
|
||||
it('sends correct actions if user is logged in', async () => {
|
||||
Cookies.getJSON.mockImplementation(() => true);
|
||||
Cookies.get.mockImplementation(() => true);
|
||||
actions.setUser({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
expect(dispatch.mock.calls).toEqual([['validityCheck']]);
|
||||
});
|
||||
|
||||
it('sends correct actions if user is not logged in', async () => {
|
||||
Cookies.getJSON.mockImplementation(() => false);
|
||||
Cookies.get.mockImplementation(() => false);
|
||||
actions.setUser({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.CLEAR_USER],
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getHeaderExpiry = response =>
|
||||
|
||||
export const setAuthCredentials = response => {
|
||||
const expiryDate = getHeaderExpiry(response);
|
||||
Cookies.set('cw_d_session_info', response.headers, {
|
||||
Cookies.set('cw_d_session_info', JSON.stringify(response.headers), {
|
||||
expires: differenceInDays(expiryDate, new Date()),
|
||||
});
|
||||
setUser(response.data.data, expiryDate);
|
||||
|
||||
@@ -84,6 +84,10 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.typingTimer);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChange(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
|
||||
@@ -34,5 +34,12 @@ export const setCookieWithDomain = (
|
||||
domain: baseDomain,
|
||||
};
|
||||
|
||||
// if type of value is object, stringify it
|
||||
// this is because js-cookies 3.0 removed builtin json support
|
||||
// ref: https://github.com/js-cookie/js-cookie/releases/tag/v3.0.0
|
||||
if (typeof value === 'object') {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
|
||||
Cookies.set(name, value, cookieOptions);
|
||||
};
|
||||
|
||||
@@ -107,6 +107,26 @@ describe('setCookieWithDomain', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify the cookie value when setting', () => {
|
||||
setCookieWithDomain(
|
||||
'myCookie',
|
||||
{ value: 'cookieValue' },
|
||||
{
|
||||
baseDomain: 'example.com',
|
||||
}
|
||||
);
|
||||
|
||||
expect(Cookies.set).toHaveBeenCalledWith(
|
||||
'myCookie',
|
||||
JSON.stringify({ value: 'cookieValue' }),
|
||||
expect.objectContaining({
|
||||
expires: 365,
|
||||
sameSite: 'Lax',
|
||||
domain: 'example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => {
|
||||
setCookieWithDomain('myCookie', 'cookieValue', {
|
||||
expires: 7,
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"person-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11zm-5.477 2a6.47 6.47 0 0 0-.709 1.5H4.253a.749.749 0 0 0-.75.75v.577c0 .535.192 1.053.54 1.46c1.253 1.469 3.22 2.214 5.957 2.214c.597 0 1.157-.035 1.68-.106c.246.495.553.954.912 1.367c-.795.16-1.66.24-2.592.24c-3.146 0-5.532-.906-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.578A2.249 2.249 0 0 1 4.253 14h7.77zm5.477 0l-.09.008a.5.5 0 0 0-.402.402L17 14.5V17h-2.496l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17L17 20.5l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402L18 20.5V18h2.504l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.402-.402l-.09-.008H18L18 14.5l-.008-.09a.5.5 0 0 0-.402-.402L17.5 14zM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z",
|
||||
"person-assign-outline": "M11.313 15.5a6.471 6.471 0 0 1 .709-1.5h-7.77a2.249 2.249 0 0 0-2.249 2.25v.577c0 .892.319 1.756.899 2.435c1.566 1.834 3.952 2.74 7.098 2.74c.931 0 1.796-.08 2.592-.24a6.51 6.51 0 0 1-.913-1.366c-.524.07-1.083.105-1.68.105c-2.737 0-4.703-.745-5.957-2.213a2.25 2.25 0 0 1-.539-1.461v-.578a.75.75 0 0 1 .75-.749h7.06ZM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7ZM23 17.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Zm-4.647-2.853a.5.5 0 0 0-.707.707L19.293 17H15a.5.5 0 1 0 0 1h4.293l-1.647 1.647a.5.5 0 0 0 .707.707l2.5-2.5a.497.497 0 0 0 .147-.345V17.5a.498.498 0 0 0-.15-.357l-2.497-2.496Z",
|
||||
"person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",
|
||||
"person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z",
|
||||
"play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z",
|
||||
"power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z",
|
||||
"quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export const hasAuthCookie = () => {
|
||||
return !!Cookies.getJSON('cw_d_session_info');
|
||||
return !!Cookies.get('cw_d_session_info');
|
||||
};
|
||||
|
||||
const getSSOAccountPath = ({ ssoAccountId, user }) => {
|
||||
|
||||
@@ -10,10 +10,10 @@ jest.mock('dashboard/store/utils/api', () => ({
|
||||
jest.mock('../CommonHelper', () => ({ replaceRouteWithReload: jest.fn() }));
|
||||
|
||||
jest.mock('js-cookie', () => ({
|
||||
getJSON: jest.fn(),
|
||||
get: jest.fn(),
|
||||
}));
|
||||
|
||||
Cookies.getJSON.mockReturnValueOnce(true).mockReturnValue(false);
|
||||
Cookies.get.mockReturnValueOnce(true).mockReturnValue(false);
|
||||
describe('#validateRouteAccess', () => {
|
||||
it('reset cookies and continues to the login page if the SSO parameters are present', () => {
|
||||
validateRouteAccess(
|
||||
@@ -40,7 +40,7 @@ describe('#validateRouteAccess', () => {
|
||||
});
|
||||
|
||||
it('redirects to dashboard if auth cookie is present', () => {
|
||||
Cookies.getJSON.mockImplementation(() => true);
|
||||
Cookies.get.mockImplementation(() => true);
|
||||
validateRouteAccess({ name: 'login' }, next);
|
||||
expect(clearBrowserSessionCookies).not.toHaveBeenCalled();
|
||||
expect(replaceRouteWithReload).toHaveBeenCalledWith('/app/');
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
isValidPhoneNumber: $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR'),
|
||||
email: $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'),
|
||||
required: $t('PRE_CHAT_FORM.REQUIRED'),
|
||||
matches: item.regex_cue
|
||||
? item.regex_cue
|
||||
: $t('PRE_CHAT_FORM.REGEX_ERROR'),
|
||||
}"
|
||||
:has-error-in-phone-input="hasErrorInPhoneInput"
|
||||
/>
|
||||
@@ -68,13 +71,20 @@ import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import darkModeMixin from 'widget/mixins/darkModeMixin';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import customAttributeMixin from '../../../dashboard/mixins/customAttributeMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CustomButton,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin],
|
||||
mixins: [
|
||||
routerMixin,
|
||||
darkModeMixin,
|
||||
messageFormatterMixin,
|
||||
configMixin,
|
||||
customAttributeMixin,
|
||||
],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
@@ -235,30 +245,37 @@ export default {
|
||||
}
|
||||
return this.formValues[name] || null;
|
||||
},
|
||||
getValidation({ type, name }) {
|
||||
getValidation({ type, name, field_type, regex_pattern }) {
|
||||
let regex = regex_pattern ? this.getRegexp(regex_pattern) : null;
|
||||
const validations = {
|
||||
emailAddress: 'email',
|
||||
phoneNumber: 'startsWithPlus|isValidPhoneNumber',
|
||||
phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],
|
||||
url: 'url',
|
||||
date: 'date',
|
||||
text: null,
|
||||
select: null,
|
||||
number: null,
|
||||
checkbox: false,
|
||||
contact_attribute: regex ? [['matches', regex]] : null,
|
||||
conversation_attribute: regex ? [['matches', regex]] : null,
|
||||
};
|
||||
const validationKeys = Object.keys(validations);
|
||||
const isRequired = this.isContactFieldRequired(name);
|
||||
const validation = isRequired ? 'bail|required' : 'bail|optional';
|
||||
const validation = isRequired
|
||||
? ['bail', 'required']
|
||||
: ['bail', 'optional'];
|
||||
|
||||
if (validationKeys.includes(name) || validationKeys.includes(type)) {
|
||||
const validationType = validations[type] || validations[name];
|
||||
const validationString = validationType
|
||||
? `${validation}|${validationType}`
|
||||
: validation;
|
||||
return validationString;
|
||||
if (
|
||||
validationKeys.includes(name) ||
|
||||
validationKeys.includes(type) ||
|
||||
validationKeys.includes(field_type)
|
||||
) {
|
||||
const validationType =
|
||||
validations[type] || validations[name] || validations[field_type];
|
||||
return validationType ? validation.concat(validationType) : validation;
|
||||
}
|
||||
|
||||
return '';
|
||||
return [];
|
||||
},
|
||||
findFieldType(type) {
|
||||
if (type === 'link') {
|
||||
|
||||
@@ -24,6 +24,11 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timeOutID: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
replyToAttachment() {
|
||||
if (!this.replyTo?.attachments?.length) {
|
||||
@@ -34,6 +39,10 @@ export default {
|
||||
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.timeOutID);
|
||||
},
|
||||
methods: {
|
||||
navigateTo(id) {
|
||||
const elementId = `cwmsg-${id}`;
|
||||
@@ -41,7 +50,7 @@ export default {
|
||||
const el = document.getElementById(elementId);
|
||||
el.scrollIntoView();
|
||||
el.classList.add('bg-slate-100', 'dark:bg-slate-900');
|
||||
setTimeout(() => {
|
||||
this.timeOutID = setTimeout(() => {
|
||||
el.classList.remove('bg-slate-100', 'dark:bg-slate-900');
|
||||
}, 500);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ export default {
|
||||
scrollPosition: 0,
|
||||
ticking: true,
|
||||
disableBranding: window.chatwootWebChannel.disableBranding || false,
|
||||
requestID: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -120,6 +121,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$el.removeEventListener('scroll', this.updateScrollPosition);
|
||||
cancelAnimationFrame(this.requestID);
|
||||
},
|
||||
methods: {
|
||||
closeWindow() {
|
||||
@@ -128,7 +130,7 @@ export default {
|
||||
updateScrollPosition(event) {
|
||||
this.scrollPosition = event.target.scrollTop;
|
||||
if (!this.ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.requestID = window.requestAnimationFrame(() => {
|
||||
this.ticking = false;
|
||||
});
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required"
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"CHAT_FORM": {
|
||||
|
||||
@@ -6,13 +6,14 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
def perform(channel)
|
||||
return unless should_fetch_email?(channel)
|
||||
|
||||
with_lock(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) do
|
||||
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}"
|
||||
|
||||
@@ -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,20 +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
|
||||
|
||||
begin
|
||||
if lock_manager.lock(lock_key)
|
||||
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
|
||||
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
|
||||
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}"
|
||||
handle_failed_lock_acquisition(lock_key)
|
||||
end
|
||||
ensure
|
||||
lock_manager.unlock(lock_key)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,8 @@ class Webhooks::FacebookEventsJob < MutexApplicationJob
|
||||
def perform(message)
|
||||
response = ::Integrations::Facebook::MessageParser.new(message)
|
||||
|
||||
with_lock(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id) do
|
||||
key = format(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id)
|
||||
with_lock(key) do
|
||||
process_message(response)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,8 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
def perform(entries)
|
||||
@entries = entries
|
||||
|
||||
with_lock(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id) do
|
||||
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
|
||||
with_lock(key) do
|
||||
process_entries(entries)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,11 +6,12 @@ class ReplyMailbox < ApplicationMailbox
|
||||
EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
|
||||
before_processing :conversation_uuid_from_to_address,
|
||||
:find_relative_conversation,
|
||||
:verify_decoded_params,
|
||||
:decorate_mail
|
||||
:find_relative_conversation
|
||||
|
||||
def process
|
||||
return if @conversation.blank?
|
||||
|
||||
decorate_mail
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
end
|
||||
@@ -78,12 +79,8 @@ class ReplyMailbox < ApplicationMailbox
|
||||
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
|
||||
end
|
||||
|
||||
def verify_decoded_params
|
||||
raise 'Conversation uuid not found' if conversation_uuid.nil?
|
||||
end
|
||||
|
||||
def validate_resource(resource)
|
||||
raise "Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
|
||||
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
|
||||
|
||||
resource
|
||||
end
|
||||
|
||||
@@ -35,7 +35,7 @@ class Channel::WebWidget < ApplicationRecord
|
||||
{ pre_chat_form_options: [:pre_chat_message, :require_email,
|
||||
{ pre_chat_fields:
|
||||
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
|
||||
:locale, { values: [] }] }] },
|
||||
:locale, { values: [] }, :regex_pattern, :regex_cue] }] },
|
||||
{ selected_feature_flags: [] }].freeze
|
||||
|
||||
before_validation :validate_pre_chat_options
|
||||
|
||||
@@ -2,7 +2,7 @@ module AccountCacheRevalidator
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :update_account_cache, on: [:create, :update]
|
||||
after_commit :update_account_cache, on: [:create, :update, :destroy]
|
||||
end
|
||||
|
||||
def update_account_cache
|
||||
|
||||
@@ -4,6 +4,8 @@ module CacheKeys
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
CACHE_KEYS_EXPIRY = 72.hours
|
||||
|
||||
included do
|
||||
class_attribute :cacheable_models
|
||||
self.cacheable_models = [Label, Inbox, Team]
|
||||
@@ -18,26 +20,26 @@ module CacheKeys
|
||||
keys
|
||||
end
|
||||
|
||||
def invalidate_cache_key_for(key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(id, key)
|
||||
Redis::Alfred.delete(prefixed_cache_key)
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
def update_cache_key(key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(id, key)
|
||||
Redis::Alfred.set(prefixed_cache_key, Time.now.utc.to_i)
|
||||
update_cache_key_for_account(id, key)
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
def reset_cache_keys
|
||||
self.class.cacheable_models.each do |model|
|
||||
invalidate_cache_key_for(model.name.underscore)
|
||||
update_cache_key_for_account(id, model.name.underscore)
|
||||
end
|
||||
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_cache_key_for_account(account_id, key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
|
||||
Redis::Alfred.setex(prefixed_cache_key, Time.now.utc.to_i, CACHE_KEYS_EXPIRY)
|
||||
end
|
||||
|
||||
def dispatch_cache_update_event
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
|
||||
end
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# additional_attributes :jsonb
|
||||
# contact_type :integer default("visitor")
|
||||
# country_code :string default("")
|
||||
# custom_attributes :jsonb
|
||||
# email :string
|
||||
# identifier :string
|
||||
# last_activity_at :datetime
|
||||
# last_name :string default("")
|
||||
# location :string default("")
|
||||
# middle_name :string default("")
|
||||
# name :string default("")
|
||||
# phone_number :string
|
||||
# created_at :datetime not null
|
||||
@@ -55,6 +60,8 @@ class Contact < ApplicationRecord
|
||||
after_update_commit :dispatch_update_event
|
||||
after_destroy_commit :dispatch_destroy_event
|
||||
|
||||
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
|
||||
|
||||
scope :order_on_last_activity_at, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
# attribute_model :integer default("conversation_attribute")
|
||||
# attribute_values :jsonb
|
||||
# default_value :integer
|
||||
# regex_cue :string
|
||||
# regex_pattern :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ class Inbox < ApplicationRecord
|
||||
has_many :inbox_members, dependent: :destroy_async
|
||||
has_many :members, through: :inbox_members, source: :user
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :messages, through: :conversations
|
||||
has_many :messages, dependent: :destroy_async
|
||||
|
||||
has_one :agent_bot_inbox, dependent: :destroy_async
|
||||
has_one :agent_bot, through: :agent_bot_inbox
|
||||
|
||||
@@ -60,6 +60,7 @@ class Notification < ApplicationRecord
|
||||
secondary_actor: secondary_actor&.push_event_data,
|
||||
user: user&.push_event_data,
|
||||
created_at: created_at.to_i,
|
||||
last_activity_at: last_activity_at.to_i,
|
||||
account_id: account_id
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
class AutomationRules::ConditionValidationService
|
||||
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
|
||||
|
||||
def initialize(rule)
|
||||
@rule = rule
|
||||
@account = rule.account
|
||||
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
end
|
||||
|
||||
def perform
|
||||
@rule.conditions.each do |condition|
|
||||
return false unless valid_condition?(condition)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_condition?(condition)
|
||||
key = condition['attribute_key']
|
||||
|
||||
conversation_filter = @conversation_filters[key]
|
||||
contact_filter = @contact_filters[key]
|
||||
message_filter = @message_filters[key]
|
||||
|
||||
if conversation_filter || contact_filter || message_filter
|
||||
operation_valid?(condition, conversation_filter || contact_filter || message_filter)
|
||||
else
|
||||
custom_attribute_present?(key, condition['custom_attribute_type'])
|
||||
end
|
||||
end
|
||||
|
||||
def operation_valid?(condition, filter)
|
||||
filter_operator = condition['filter_operator']
|
||||
|
||||
# attribute changed is a special case
|
||||
return true if filter_operator == 'attribute_changed'
|
||||
|
||||
filter['filter_operators'].include?(filter_operator)
|
||||
end
|
||||
|
||||
def custom_attribute_present?(attribute_key, attribute_model)
|
||||
attribute_model = attribute_model.presence || self.class::ATTRIBUTE_MODEL
|
||||
|
||||
@account.custom_attribute_definitions.where(
|
||||
attribute_model: attribute_model
|
||||
).find_by(attribute_key: attribute_key).present?
|
||||
end
|
||||
end
|
||||
@@ -5,19 +5,25 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
|
||||
def initialize(rule, conversation = nil, options = {})
|
||||
super([], nil)
|
||||
# assign rule, conversation and account to instance variables
|
||||
@rule = rule
|
||||
@conversation = conversation
|
||||
@account = conversation.account
|
||||
|
||||
# setup filters from json file
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
|
||||
@options = options
|
||||
@changed_attributes = options[:changed_attributes]
|
||||
end
|
||||
|
||||
def perform
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
return false unless rule_valid?
|
||||
|
||||
@attribute_changed_query_filter = []
|
||||
|
||||
@rule.conditions.each_with_index do |query_hash, current_index|
|
||||
@@ -36,6 +42,14 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
false
|
||||
end
|
||||
|
||||
def rule_valid?
|
||||
is_valid = AutomationRules::ConditionValidationService.new(@rule).perform
|
||||
|
||||
Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid
|
||||
|
||||
is_valid
|
||||
end
|
||||
|
||||
def filter_operation(query_hash, current_index)
|
||||
if query_hash[:filter_operator] == 'starts_with'
|
||||
@filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%"
|
||||
|
||||
@@ -7,7 +7,12 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
|
||||
def perform_reply
|
||||
send_message_to_facebook fb_text_message_params if message.content.present?
|
||||
send_message_to_facebook fb_attachment_message_params if message.attachments.present?
|
||||
|
||||
if message.attachments.present?
|
||||
message.attachments.each do |attachment|
|
||||
send_message_to_facebook fb_attachment_message_params(attachment)
|
||||
end
|
||||
end
|
||||
rescue Facebook::Messenger::FacebookError => e
|
||||
# TODO : handle specific errors or else page will get disconnected
|
||||
handle_facebook_error(e)
|
||||
@@ -41,8 +46,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
"#{error_code} - #{error_message}"
|
||||
end
|
||||
|
||||
def fb_attachment_message_params
|
||||
attachment = message.attachments.first
|
||||
def fb_attachment_message_params(attachment)
|
||||
{
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
@@ -64,14 +68,6 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
'file'
|
||||
end
|
||||
|
||||
def fb_message_params
|
||||
if message.attachments.blank?
|
||||
fb_text_message_params
|
||||
else
|
||||
fb_attachment_message_params
|
||||
end
|
||||
end
|
||||
|
||||
def sent_first_outgoing_message_after_24_hours?
|
||||
# we can send max 1 message after 24 hour window
|
||||
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
|
||||
|
||||
@@ -14,8 +14,13 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
send_to_facebook_page attachament_message_params if message.attachments.present?
|
||||
send_to_facebook_page message_params
|
||||
if message.attachments.present?
|
||||
message.attachments.each do |attachment|
|
||||
send_to_facebook_page attachment_message_params(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
send_to_facebook_page message_params if message.content.present?
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
|
||||
# TODO : handle specific errors or else page will get disconnected
|
||||
@@ -33,8 +38,7 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService
|
||||
merge_human_agent_tag(params)
|
||||
end
|
||||
|
||||
def attachament_message_params
|
||||
attachment = message.attachments.first
|
||||
def attachment_message_params(attachment)
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
|
||||
@@ -24,7 +24,9 @@ class Instagram::WebhooksBaseService
|
||||
def update_instagram_profile_link(user)
|
||||
return unless user['username']
|
||||
|
||||
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
|
||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
|
||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
|
||||
@contact.save
|
||||
end
|
||||
end
|
||||
|
||||
@@ -139,7 +139,14 @@ class Line::IncomingMessageService
|
||||
def contact_attributes
|
||||
{
|
||||
name: line_contact_info['displayName'],
|
||||
avatar_url: line_contact_info['pictureUrl']
|
||||
avatar_url: line_contact_info['pictureUrl'],
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
{
|
||||
social_line_user_id: line_contact_info['userId']
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -78,8 +78,11 @@ class Telegram::IncomingMessageService
|
||||
|
||||
def additional_attributes
|
||||
{
|
||||
# TODO: Remove this once we show the social_telegram_user_name in the UI instead of the username
|
||||
username: telegram_params_username,
|
||||
language_code: telegram_params_language_code
|
||||
language_code: telegram_params_language_code,
|
||||
social_telegram_user_id: telegram_params_from_id,
|
||||
social_telegram_user_name: telegram_params_username
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ class Twilio::IncomingMessageService
|
||||
|
||||
def download_attachment_file
|
||||
download_with_auth
|
||||
rescue Down::Error => e
|
||||
rescue Down::Error, Down::ClientError => e
|
||||
handle_download_attachment_error(e)
|
||||
end
|
||||
|
||||
@@ -141,12 +141,10 @@ class Twilio::IncomingMessageService
|
||||
|
||||
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
|
||||
def handle_download_attachment_error(error)
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}"
|
||||
if error.message.include?('401 Unauthorized')
|
||||
Down.download(params[:MediaUrl0])
|
||||
else
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
nil
|
||||
end
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
|
||||
Down.download(params[:MediaUrl0])
|
||||
rescue StandardError => e
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,13 @@ class Whatsapp::IncomingMessageBaseService
|
||||
private
|
||||
|
||||
def process_messages
|
||||
# message allready exists so we don't need to process
|
||||
# We don't support reactions & ephemeral message now, we need to skip processing the message
|
||||
# if the webhook event is a reaction or an ephermal message or an unsupported message.
|
||||
return if unprocessable_message_type?(message_type)
|
||||
|
||||
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
|
||||
# business manager account. While we have not found the core reason yet, the following line ensure that
|
||||
# there are no duplicate messages created.
|
||||
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
|
||||
|
||||
cache_message_source_id_in_redis
|
||||
@@ -49,8 +55,6 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def create_messages
|
||||
return if unprocessable_message_type?(message_type)
|
||||
|
||||
message = @processed_params[:messages].first
|
||||
log_error(message) && return if error_webhook_event?(message)
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
end
|
||||
|
||||
def unprocessable_message_type?(message_type)
|
||||
%w[reaction ephemeral unsupported].include?(message_type)
|
||||
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
||||
end
|
||||
|
||||
def brazil_phone_number?(phone_number)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,9 +30,8 @@
|
||||
</h1>
|
||||
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
|
||||
<div class="flex items-start space-x-1">
|
||||
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
|
||||
<%= render "public/api/v1/portals/thumbnail", author: article.author, size: 5 %>
|
||||
</div>
|
||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400"><%= "#{I18n.t('public_portal.common.by')} #{article.author&.available_name || article.author&.name || ''}" %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,17 +32,7 @@
|
||||
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3>
|
||||
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<% author = article.author %>
|
||||
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
|
||||
<% if author&.name.present? %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= "#{I18n.t('public_portal.common.by')} #{author&.name}" %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -34,16 +34,24 @@ as well as a link to its edit page.
|
||||
|
||||
<section class="main-content__body">
|
||||
<dl>
|
||||
<% page.attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: attribute.name.titleize,
|
||||
) %>
|
||||
</dt>
|
||||
<% page.attributes.each do |title, attributes| %>
|
||||
<fieldset class="<%= "field-unit--nested" if title.present? %>">
|
||||
<% if title.present? %>
|
||||
<legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
|
||||
<% end %>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</dt>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% end %>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -69,13 +77,15 @@ as well as a link to its edit page.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% account_user_page.attributes.each do |attribute| -%>
|
||||
<% if attribute.name == "account" %>
|
||||
<%= f.hidden_field('account_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% account_user_page.attributes.each do |title, attributes| -%>
|
||||
<% attributes.each do |attribute| %>
|
||||
<% if attribute.name == "account" %>
|
||||
<%= f.hidden_field('account_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end -%>
|
||||
|
||||
|
||||
@@ -34,16 +34,24 @@ as well as a link to its edit page.
|
||||
|
||||
<section class="main-content__body">
|
||||
<dl>
|
||||
<% page.attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: attribute.name.titleize,
|
||||
) %>
|
||||
</dt>
|
||||
<% page.attributes.each do |title, attributes| %>
|
||||
<fieldset class="<%= "field-unit--nested" if title.present? %>">
|
||||
<% if title.present? %>
|
||||
<legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
|
||||
<% end %>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</dt>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% end %>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -69,14 +77,16 @@ as well as a link to its edit page.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% account_user_page.attributes.each do |attribute| -%>
|
||||
<% if attribute.name == "user" %>
|
||||
<%= f.hidden_field('user_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% account_user_page.attributes.each do |title, attributes| -%>
|
||||
<% attributes.each do |attribute| %>
|
||||
<% if attribute.name == "user" %>
|
||||
<%= f.hidden_field('user_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end -%>
|
||||
|
||||
<div class="form-actions">
|
||||
|
||||
@@ -8,6 +8,9 @@ class EmailReplyWorker
|
||||
return unless message.email_notifiable_message?
|
||||
|
||||
# send the email
|
||||
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_later
|
||||
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
message.update!(status: :failed, external_error: e.message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddRegexToCustomAttributeDefinition < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :custom_attribute_definitions, :regex_pattern, :string
|
||||
add_column :custom_attribute_definitions, :regex_cue, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class RefactorSlaPolicyColumns < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
rename_column :sla_policies, :rt_threshold, :next_response_time_threshold
|
||||
rename_column :sla_policies, :frt_threshold, :first_response_time_threshold
|
||||
add_column :sla_policies, :description, :string
|
||||
add_column :sla_policies, :resolution_time_threshold, :float
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateAppliedSlas < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :applied_slas do |t|
|
||||
t.references :account, null: false
|
||||
t.references :sla_policy, null: false
|
||||
t.references :conversation, null: false
|
||||
|
||||
t.string :sla_status
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddContactTypeToContacts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :contacts, :contact_type, :integer, default: 0
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddMiddleNameAndLastNameToContacts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :contacts, :middle_name, :string, default: ''
|
||||
add_column :contacts, :last_name, :string, default: ''
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddLocationAndCountryCodeToContacts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :contacts, :location, :string, default: ''
|
||||
add_column :contacts, :country_code, :string, default: ''
|
||||
end
|
||||
end
|
||||
+24
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_01_29_080827) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -115,6 +115,18 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
||||
end
|
||||
|
||||
create_table "applied_slas", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.string "sla_status"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_applied_slas_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
|
||||
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
|
||||
end
|
||||
|
||||
create_table "articles", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.integer "portal_id", null: false
|
||||
@@ -406,6 +418,11 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
t.string "identifier"
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
t.integer "contact_type", default: 0
|
||||
t.string "middle_name", default: ""
|
||||
t.string "last_name", default: ""
|
||||
t.string "location", default: ""
|
||||
t.string "country_code", default: ""
|
||||
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id"], name: "index_contacts_on_account_id"
|
||||
@@ -501,6 +518,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "attribute_description"
|
||||
t.jsonb "attribute_values", default: []
|
||||
t.string "regex_pattern"
|
||||
t.string "regex_cue"
|
||||
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
|
||||
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
|
||||
end
|
||||
@@ -822,12 +841,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
|
||||
|
||||
create_table "sla_policies", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.float "frt_threshold"
|
||||
t.float "rt_threshold"
|
||||
t.float "first_response_time_threshold"
|
||||
t.float "next_response_time_threshold"
|
||||
t.boolean "only_during_business_hours", default: false
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "description"
|
||||
t.float "resolution_time_threshold"
|
||||
t.index ["account_id"], name: "index_sla_policies_on_account_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:sla_policy).permit(:name, :rt_threshold, :frt_threshold, :only_during_business_hours)
|
||||
params.require(:sla_policy).permit(:name, :description, :first_response_time_threshold, :next_response_time_threshold,
|
||||
:resolution_time_threshold, :only_during_business_hours)
|
||||
end
|
||||
|
||||
def fetch_sla
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+3
-3
@@ -31,10 +31,10 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@braid/vue-formulate": "^2.5.2",
|
||||
"@chatwoot/prosemirror-schema": "1.0.5",
|
||||
"@chatwoot/prosemirror-schema": "1.0.7",
|
||||
"@chatwoot/utils": "^0.0.21",
|
||||
"@hcaptcha/vue-hcaptcha": "^0.3.2",
|
||||
"@june-so/analytics-next": "^1.36.5",
|
||||
"@june-so/analytics-next": "^2.0.0",
|
||||
"@radix-ui/colors": "^1.0.1",
|
||||
"@rails/actioncable": "6.1.3",
|
||||
"@rails/ujs": "^7.0.3-1",
|
||||
@@ -60,7 +60,7 @@
|
||||
"highlight.js": "~10.4.1",
|
||||
"idb": "^7.1.1",
|
||||
"ionicons": "~2.0.1",
|
||||
"js-cookie": "^2.2.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"libphonenumber-js": "^1.10.24",
|
||||
"logrocket": "^3.0.1",
|
||||
"logrocket-vuex": "^0.0.3",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,10 +43,13 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
it 'shows the list of accounts' do
|
||||
expect(account.cache_keys.keys).to contain_exactly(:inbox, :label, :team)
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
now_timestamp = Time.now.utc.to_i
|
||||
post "/super_admin/accounts/#{account.id}/reset_cache"
|
||||
expect(response).to have_http_status(:redirect)
|
||||
expect(flash[:notice]).to eq('Cache keys cleared')
|
||||
expect(account.reload.cache_keys.values.map(&:to_i)).to eq([0, 0, 0])
|
||||
|
||||
expect(account.reload.cache_keys.values.all? { |v| v.to_i == now_timestamp }).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,16 +4,6 @@ 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(:lock).and_return(true)
|
||||
@@ -22,24 +12,43 @@ RSpec.describe MutexApplicationJob do
|
||||
|
||||
describe '#with_lock' do
|
||||
it 'acquires the lock and yields the block if lock is not acquired' do
|
||||
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(:lock).with(lock_key).and_return(false)
|
||||
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(: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
|
||||
|
||||
@@ -43,7 +43,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
instagram_inbox.reload
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
|
||||
@@ -59,7 +59,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
instagram_inbox.reload
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
|
||||
@@ -170,7 +170,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
instagram_inbox.reload
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
|
||||
|
||||
@@ -146,6 +146,61 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'ignore reaction type message, would not create contact if the reaction is the first event' do
|
||||
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
wb_params = {
|
||||
phone_number: channel.phone_number,
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
contacts: [{ profile: { name: 'Test Test' }, wa_id: '1111981136571' }],
|
||||
messages: [{
|
||||
from: '1111981136571', reaction: { emoji: '👍' }, timestamp: '1664799904', type: 'reaction'
|
||||
}],
|
||||
metadata: {
|
||||
phone_number_id: other_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: other_channel.phone_number.delete('+')
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}.with_indifferent_access
|
||||
expect do
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform
|
||||
end.not_to change(Contact, :count)
|
||||
end
|
||||
|
||||
it 'ignore request_welcome type message, would not create contact or conversation' do
|
||||
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
wb_params = {
|
||||
phone_number: channel.phone_number,
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
messages: [{
|
||||
from: '1111981136571', timestamp: '1664799904', type: 'request_welcome'
|
||||
}],
|
||||
metadata: {
|
||||
phone_number_id: other_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: other_channel.phone_number.delete('+')
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}.with_indifferent_access
|
||||
expect do
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform
|
||||
end.not_to change(Contact, :count)
|
||||
|
||||
expect do
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform
|
||||
end.not_to change(Conversation, :count)
|
||||
end
|
||||
|
||||
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
|
||||
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -14,6 +14,7 @@ RSpec.describe CacheKeys do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:delete)
|
||||
allow(Redis::Alfred).to receive(:set)
|
||||
allow(Redis::Alfred).to receive(:setex)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
end
|
||||
|
||||
@@ -27,28 +28,11 @@ RSpec.describe CacheKeys do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#invalidate_cache_key_for' do
|
||||
it 'deletes the cache key' do
|
||||
test_model.invalidate_cache_key_for('label')
|
||||
expect(Redis::Alfred).to have_received(:delete).with('idb-cache-key-account-1-label')
|
||||
end
|
||||
|
||||
it 'dispatches a cache update event' do
|
||||
test_model.invalidate_cache_key_for('label')
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
CacheKeys::ACCOUNT_CACHE_INVALIDATED,
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
cache_keys: test_model.cache_keys,
|
||||
account: test_model
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_cache_key' do
|
||||
it 'updates the cache key' do
|
||||
allow(Time).to receive(:now).and_return(Time.parse('2023-05-29 00:00:00 UTC'))
|
||||
test_model.update_cache_key('label')
|
||||
expect(Redis::Alfred).to have_received(:set).with('idb-cache-key-account-1-label', Time.now.utc.to_i)
|
||||
expect(Redis::Alfred).to have_received(:setex).with('idb-cache-key-account-1-label', kind_of(Integer), CacheKeys::CACHE_KEYS_EXPIRY)
|
||||
end
|
||||
|
||||
it 'dispatches a cache update event' do
|
||||
@@ -66,8 +50,20 @@ RSpec.describe CacheKeys do
|
||||
it 'invalidates all cache keys for cacheable models' do
|
||||
test_model.reset_cache_keys
|
||||
test_model.class.cacheable_models.each do |model|
|
||||
expect(Redis::Alfred).to have_received(:delete).with("idb-cache-key-account-1-#{model.name.underscore}")
|
||||
expect(Redis::Alfred).to have_received(:setex).with("idb-cache-key-account-1-#{model.name.underscore}", kind_of(Integer),
|
||||
CacheKeys::CACHE_KEYS_EXPIRY)
|
||||
end
|
||||
end
|
||||
|
||||
it 'dispatches a cache update event' do
|
||||
test_model.reset_cache_keys
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
CacheKeys::ACCOUNT_CACHE_INVALIDATED,
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
cache_keys: test_model.cache_keys,
|
||||
account: test_model
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -250,5 +250,15 @@ RSpec.describe Inbox do
|
||||
cache_keys: inbox.account.cache_keys
|
||||
)
|
||||
end
|
||||
|
||||
it 'updates the cache key after update' do
|
||||
expect(inbox.account).to receive(:update_cache_key).with('inbox')
|
||||
inbox.update(name: 'New Name')
|
||||
end
|
||||
|
||||
it 'updates the cache key after touch' do
|
||||
expect(inbox.account).to receive(:update_cache_key).with('inbox')
|
||||
inbox.touch # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -90,6 +90,24 @@ describe Facebook::SendOnFacebookService do
|
||||
}, { page_id: facebook_channel.page_id })
|
||||
end
|
||||
|
||||
it 'if message is sent with multiple attachments' do
|
||||
message = build(:message, content: nil, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
sample.file.attach(io: Rails.root.join('spec/assets/sample.png').open, filename: 'sample.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
service = described_class.new(message: message)
|
||||
|
||||
# Stub the send_to_facebook_page method on the service instance
|
||||
allow(service).to receive(:send_message_to_facebook)
|
||||
service.perform
|
||||
|
||||
# Now you can set expectations on the stubbed method for each attachment
|
||||
expect(service).to have_received(:send_message_to_facebook).exactly(:twice)
|
||||
end
|
||||
|
||||
it 'if message sent from chatwoot is failed' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
allow(bot).to receive(:deliver).and_return({ error: { message: 'Invalid OAuth access token.', type: 'OAuthException', code: 190,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user