Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d708eedea | ||
|
|
373f5f5b64 | ||
|
|
ae7df60a75 | ||
|
|
412d750b6a | ||
|
|
f1a77ba934 | ||
|
|
ffc6364690 | ||
|
|
3a7633b564 | ||
|
|
91d1d44aa9 | ||
|
|
c3e90d4cbc | ||
|
|
afc97faa8b | ||
|
|
1d0930ef63 | ||
|
|
776ea6d39a | ||
|
|
2c85098698 | ||
|
|
6bd0e074dc | ||
|
|
123fc73394 | ||
|
|
88cef88d80 | ||
|
|
8e79cf72e1 | ||
|
|
bfaca851f1 | ||
|
|
5618b5ebd3 | ||
|
|
4807052c37 | ||
|
|
44b1047b90 | ||
|
|
9c6c19c3e5 | ||
|
|
2764338453 | ||
|
|
4f0fcfcbcd | ||
|
|
fdbaf98c3b | ||
|
|
03bbd048a7 | ||
|
|
d481b9fbcf | ||
|
|
68dedc37ba | ||
|
|
958c0d7946 | ||
|
|
9553329092 | ||
|
|
d01f2063d0 | ||
|
|
590e4e9c1c | ||
|
|
0e903d2365 | ||
|
|
d6ce1ceeeb | ||
|
|
e8f56d0e56 | ||
|
|
b8dae07c0f | ||
|
|
e3f4be97c0 | ||
|
|
1f4d096804 | ||
|
|
1bcc438b21 | ||
|
|
8b3d13a920 | ||
|
|
dcdd58bae5 | ||
|
|
de1fb6ba4e | ||
|
|
18c5c47836 |
@@ -157,7 +157,7 @@ jobs:
|
||||
- run:
|
||||
name: Code Climate Test Coverage
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
- persist_to_workspace:
|
||||
root: coverage
|
||||
|
||||
@@ -173,6 +173,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## LogRocket
|
||||
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
|
||||
|
||||
# MICROSOFT CLARITY
|
||||
# MS_CLARITY_TOKEN=xxxxxxxxx
|
||||
|
||||
## Scout
|
||||
## https://scoutapm.com/docs/ruby/configuration
|
||||
# SCOUT_KEY=YOURKEY
|
||||
|
||||
+1
-1
@@ -784,8 +784,8 @@ GEM
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
arm64-darwin-22
|
||||
arm64-darwin-21
|
||||
arm64-darwin-22
|
||||
ruby
|
||||
x86_64-darwin-18
|
||||
x86_64-darwin-20
|
||||
|
||||
@@ -63,9 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.where(
|
||||
@conversation ||= Conversation.where(conversation_params).find_by(
|
||||
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||
).find_by(conversation_params) || build_conversation
|
||||
) || build_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@@ -96,6 +96,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message' }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
|
||||
include HmacConcern
|
||||
before_action :ensure_inbox, only: [:create]
|
||||
|
||||
def create
|
||||
@contact_inbox = ContactInboxBuilder.new(
|
||||
contact: @contact,
|
||||
inbox: @inbox,
|
||||
source_id: params[:source_id]
|
||||
source_id: params[:source_id],
|
||||
hmac_verified: hmac_verified?
|
||||
).perform
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
|
||||
include Events::Types
|
||||
include DateRangeHelper
|
||||
include HmacConcern
|
||||
|
||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
||||
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
||||
@@ -104,9 +105,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def set_conversation_status
|
||||
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
|
||||
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
|
||||
# status = params[:status] == 'bot' ? 'pending' : params[:status]
|
||||
@conversation.status = params[:status]
|
||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||
end
|
||||
@@ -152,7 +150,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
ContactInboxBuilder.new(
|
||||
contact: @contact,
|
||||
inbox: @inbox,
|
||||
source_id: params[:source_id]
|
||||
source_id: params[:source_id],
|
||||
hmac_verified: hmac_verified?
|
||||
).perform
|
||||
end
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
|
||||
@custom_filter = current_user.custom_filters.create!(
|
||||
permitted_payload.merge(account_id: Current.account.id)
|
||||
)
|
||||
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def update
|
||||
@inbox.update!(permitted_params.except(:channel))
|
||||
update_inbox_working_hours
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
|
||||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
if @inbox.inbox_type == 'Email'
|
||||
begin
|
||||
validate_email_channel(channel_attributes)
|
||||
rescue StandardError => e
|
||||
render json: { message: e }, status: :unprocessable_entity and return
|
||||
end
|
||||
@inbox.channel.reauthorized!
|
||||
end
|
||||
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@@ -103,6 +84,35 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||
end
|
||||
|
||||
def update_channel
|
||||
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
||||
|
||||
reauthorize_and_update_channel(channel_attributes)
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def channel_update_required?
|
||||
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
|
||||
end
|
||||
|
||||
def validate_and_update_email_channel(channel_attributes)
|
||||
validate_email_channel(channel_attributes)
|
||||
rescue StandardError => e
|
||||
render json: { message: e }, status: :unprocessable_entity and return
|
||||
end
|
||||
|
||||
def reauthorize_and_update_channel(channel_attributes)
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
end
|
||||
|
||||
def update_channel_feature_flags
|
||||
return unless @inbox.web_widget?
|
||||
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module HmacConcern
|
||||
def hmac_verified?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
|
||||
end
|
||||
end
|
||||
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -50,6 +50,13 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
end
|
||||
|
||||
def reset_cache
|
||||
requested_resource.reset_cache_keys
|
||||
# rubocop:disable Rails/I18nLocaleTexts
|
||||
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Cache keys cleared')
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
end
|
||||
|
||||
def destroy
|
||||
account = Account.find(params[:id])
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@ class CSATReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ from, to, user_ids, inbox_id, team_id } = {}) {
|
||||
getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
|
||||
// no ratings for metrics
|
||||
return axios.get(`${this.url}/metrics`, {
|
||||
params: { since: from, until: to, user_ids, inbox_id, team_id },
|
||||
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +115,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import format from 'date-fns/format';
|
||||
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';
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -144,9 +142,6 @@ export default {
|
||||
|
||||
computed: {
|
||||
formattedValue() {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return format(new Date(this.value || new Date()), DATE_FORMAT);
|
||||
}
|
||||
if (this.isAttributeTypeCheckbox) {
|
||||
return this.value === 'false' ? false : this.value;
|
||||
}
|
||||
@@ -193,8 +188,11 @@ export default {
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
},
|
||||
displayValue() {
|
||||
if (this.attributeType === 'date') {
|
||||
return format(new Date(this.editedValue), 'dd-MM-yyyy');
|
||||
if (this.attributeType === 'date' && this.editedValue) {
|
||||
return this.editedValue
|
||||
.split('-')
|
||||
.reverse()
|
||||
.join('-');
|
||||
}
|
||||
return this.editedValue;
|
||||
},
|
||||
@@ -247,17 +245,12 @@ export default {
|
||||
}
|
||||
},
|
||||
onUpdate() {
|
||||
const updatedValue =
|
||||
this.attributeType === 'date'
|
||||
? format(new Date(this.editedValue), DATE_FORMAT)
|
||||
: this.editedValue;
|
||||
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.isEditing = false;
|
||||
this.$emit('update', this.attributeKey, updatedValue);
|
||||
this.$emit('update', this.attributeKey, this.editedValue);
|
||||
},
|
||||
onDelete() {
|
||||
this.isEditing = false;
|
||||
|
||||
@@ -45,6 +45,14 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
collection() {
|
||||
this.renderChart(this.collection, {
|
||||
...chartOptions,
|
||||
...this.chartOptions,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.renderChart(this.collection, {
|
||||
...chartOptions,
|
||||
|
||||
@@ -25,10 +25,14 @@
|
||||
<blockquote v-if="storyReply" class="story-reply-quote">
|
||||
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
|
||||
<bubble-image
|
||||
v-if="!hasStoryError"
|
||||
v-if="!hasImgStoryError && storyUrl"
|
||||
:url="storyUrl"
|
||||
@error="onStoryLoadError"
|
||||
/>
|
||||
<bubble-video
|
||||
v-else-if="hasImgStoryError && storyUrl"
|
||||
:url="storyUrl"
|
||||
/>
|
||||
</blockquote>
|
||||
<bubble-text
|
||||
v-if="data.content"
|
||||
@@ -200,7 +204,7 @@ export default {
|
||||
hasImageError: false,
|
||||
contextMenuPosition: {},
|
||||
showBackgroundHighlight: false,
|
||||
hasStoryError: false,
|
||||
hasImgStoryError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -429,12 +433,12 @@ export default {
|
||||
watch: {
|
||||
data() {
|
||||
this.hasImageError = false;
|
||||
this.hasStoryError = false;
|
||||
this.hasImgStoryError = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.hasImageError = false;
|
||||
this.hasStoryError = false;
|
||||
this.hasImgStoryError = false;
|
||||
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
|
||||
this.setupHighlightTimer();
|
||||
},
|
||||
@@ -464,7 +468,7 @@ export default {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
onStoryLoadError() {
|
||||
this.hasStoryError = true;
|
||||
this.hasImgStoryError = true;
|
||||
},
|
||||
openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
v-for="message in getReadMessages"
|
||||
:key="message.id"
|
||||
class="message--read ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
@@ -56,6 +57,7 @@
|
||||
v-for="message in getUnReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
|
||||
@@ -501,7 +501,7 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
return AUDIO_FORMATS.WAV;
|
||||
|
||||
@@ -79,6 +79,7 @@ import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
|
||||
import agentMixin from 'dashboard/mixins/agentMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
export default {
|
||||
@@ -87,7 +88,7 @@ export default {
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
},
|
||||
mixins: [snoozeTimesMixin],
|
||||
mixins: [snoozeTimesMixin, agentMixin],
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
@@ -202,6 +203,16 @@ export default {
|
||||
teams: 'teams/getTeams',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
filteredAgentOnAvailability() {
|
||||
const agents = this.$store.getters[
|
||||
'inboxAssignableAgents/getAssignableAgents'
|
||||
](this.inboxId);
|
||||
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
||||
const filteredAgents = this.sortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
return filteredAgents;
|
||||
},
|
||||
assignableAgents() {
|
||||
return [
|
||||
{
|
||||
@@ -212,9 +223,7 @@ export default {
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.inboxId
|
||||
),
|
||||
...this.filteredAgentOnAvailability,
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -246,6 +255,7 @@ export default {
|
||||
...(type === 'icon' && { icon: option.icon }),
|
||||
...(type === 'label' && { color: option.color }),
|
||||
...(type === 'agent' && { thumbnail: option.thumbnail }),
|
||||
...(type === 'agent' && { status: option.availability_status }),
|
||||
...(type === 'text' && { label: option.label }),
|
||||
...(type === 'label' && { label: option.title }),
|
||||
...(type === 'agent' && { label: option.name }),
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
v-if="variant === 'agent'"
|
||||
:username="option.label"
|
||||
:src="option.thumbnail"
|
||||
:status="option.status"
|
||||
size="20px"
|
||||
class="agent-thumbnail"
|
||||
/>
|
||||
|
||||
@@ -10,15 +10,22 @@
|
||||
"TITLE": "Manage Audit Logs",
|
||||
"DESC": "Audit Logs are trails for events and actions in a Chatwoot System.",
|
||||
"TABLE_HEADER": [
|
||||
"User",
|
||||
"Action",
|
||||
"IP Address",
|
||||
"Time"
|
||||
"Activity",
|
||||
"Time",
|
||||
"IP Address"
|
||||
]
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
},
|
||||
"ACTION": {
|
||||
"ADD": "created",
|
||||
"EDIT": "updated",
|
||||
"DELETE": "deleted",
|
||||
"SIGN_IN": "signed in",
|
||||
"SIGN_OUT": "signed out",
|
||||
"SYSTEM": "System"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
"DELETE": "Delete article"
|
||||
}
|
||||
},
|
||||
"ARTICLE_SEARCH_RESULT": {
|
||||
"UNCATEGORIZED": "Uncategorized",
|
||||
"INSERT_ARTICLE": "Insert",
|
||||
"NO_RESULT": "No articles found",
|
||||
"COPY_LINK": "Copy article link to clipboard",
|
||||
"OPEN_LINK": "Open article in new tab",
|
||||
"PREVIEW_LINK": "Preview article"
|
||||
},
|
||||
"PORTAL": {
|
||||
"HEADER": "Portals",
|
||||
"DEFAULT": "Default",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"CHANNEL_GREETING_TOGGLE": {
|
||||
"LABEL": "Enable channel greeting",
|
||||
"HELP_TEXT": "Automatically send a greeting message after the contact's first message in a conversation.",
|
||||
"HELP_TEXT": "Auto-send greeting messages when customers start a conversation and send their first message.",
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
|
||||
@@ -2,39 +2,68 @@ import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
assignableAgents() {
|
||||
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.inboxId
|
||||
);
|
||||
},
|
||||
...mapGetters({ currentUser: 'getCurrentUser' }),
|
||||
isAgentSelected() {
|
||||
return this.currentChat?.meta?.assignee;
|
||||
},
|
||||
createNoneAgent() {
|
||||
return {
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: 0,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
};
|
||||
},
|
||||
agentsList() {
|
||||
const agents = this.assignableAgents || [];
|
||||
return [
|
||||
...(this.isAgentSelected
|
||||
? [
|
||||
{
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: 0,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...agents,
|
||||
].map(item =>
|
||||
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
||||
const none = this.createNoneAgent;
|
||||
const filteredAgentsByAvailability = this.sortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
const filteredAgents = [
|
||||
...(this.isAgentSelected ? [none] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
];
|
||||
return filteredAgents;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getAgentsByAvailability(agents, availability) {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
sortedAgentsByAvailability(agents) {
|
||||
const onlineAgents = this.getAgentsByAvailability(agents, 'online');
|
||||
const busyAgents = this.getAgentsByAvailability(agents, 'busy');
|
||||
const offlineAgents = this.getAgentsByAvailability(agents, 'offline');
|
||||
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
|
||||
return filteredAgents;
|
||||
},
|
||||
getAgentsByUpdatedPresence(agents) {
|
||||
// Here we are updating the availability status of the current user dynamically (live) based on the current account availability status
|
||||
const agentsWithDynamicPresenceUpdate = agents.map(item =>
|
||||
item.id === this.currentUser.id
|
||||
? {
|
||||
...item,
|
||||
availability_status: this.currentUser.availability_status,
|
||||
availability_status: this.currentUser.accounts.find(
|
||||
account => account.id === this.currentAccountId
|
||||
).availability_status,
|
||||
}
|
||||
: item
|
||||
);
|
||||
return agentsWithDynamicPresenceUpdate;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,36 @@ export default {
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
formattedAgents: [
|
||||
{
|
||||
@@ -32,7 +62,17 @@ export default {
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
@@ -40,6 +80,70 @@ export default {
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
onlineAgents: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
],
|
||||
busyAgents: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
@@ -51,4 +155,92 @@ export default {
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
offlineAgents: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
sortedByAvailability: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
formattedAgentsByPresenceOnline: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abr@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
formattedAgentsByPresenceOffline: [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abr@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,12 +12,71 @@ describe('agentMixin', () => {
|
||||
getters = {
|
||||
getCurrentUser: () => ({
|
||||
id: 1,
|
||||
availability_status: 'busy',
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
availability_status: 'online',
|
||||
auto_offline: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
getCurrentAccountId: () => 1,
|
||||
};
|
||||
store = new Vuex.Store({ getters });
|
||||
});
|
||||
|
||||
it('return agents by availability', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [agentMixin],
|
||||
data() {
|
||||
return {
|
||||
inboxId: 1,
|
||||
currentChat: { meta: { assignee: { name: 'John' } } },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
assignableAgents() {
|
||||
return agentFixtures.allAgents;
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(
|
||||
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'online')
|
||||
).toEqual(agentFixtures.onlineAgents);
|
||||
expect(
|
||||
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'busy')
|
||||
).toEqual(agentFixtures.busyAgents);
|
||||
expect(
|
||||
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'offline')
|
||||
).toEqual(agentFixtures.offlineAgents);
|
||||
});
|
||||
|
||||
it('return sorted agents by availability', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [agentMixin],
|
||||
data() {
|
||||
return {
|
||||
inboxId: 1,
|
||||
currentChat: { meta: { assignee: { name: 'John' } } },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
assignableAgents() {
|
||||
return agentFixtures.allAgents;
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(
|
||||
wrapper.vm.sortedAgentsByAvailability(agentFixtures.allAgents)
|
||||
).toEqual(agentFixtures.sortedByAvailability);
|
||||
});
|
||||
|
||||
it('return formatted agents', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
@@ -38,4 +97,44 @@ describe('agentMixin', () => {
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.agentsList).toEqual(agentFixtures.formattedAgents);
|
||||
});
|
||||
|
||||
it('return formatted agents by presence', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [agentMixin],
|
||||
data() {
|
||||
return {
|
||||
inboxId: 1,
|
||||
currentChat: { meta: { assignee: { name: 'John' } } },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentUser() {
|
||||
return {
|
||||
id: 1,
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
availability_status: 'offline',
|
||||
auto_offline: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
currentAccountId() {
|
||||
return 1;
|
||||
},
|
||||
assignableAgents() {
|
||||
return agentFixtures.allAgents;
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(
|
||||
wrapper.vm.getAgentsByUpdatedPresence(
|
||||
agentFixtures.formattedAgentsByPresenceOnline
|
||||
)
|
||||
).toEqual(agentFixtures.formattedAgentsByPresenceOffline);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ export default {
|
||||
const errorMessage = error?.message;
|
||||
this.alertMessage =
|
||||
errorMessage || this.filterType === 0
|
||||
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.ERROR_MESSAGE')
|
||||
? errorMessage
|
||||
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
|
||||
} finally {
|
||||
this.showAlert(this.alertMessage);
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="article-item">
|
||||
<h4 class="text-block-title margin-bottom-0">{{ title }}</h4>
|
||||
<p class="margin-bottom-0 text-truncate">{{ body }}</p>
|
||||
<div class="footer">
|
||||
<p class="text-small meta">
|
||||
{{ locale }}
|
||||
{{ ` / ` }}
|
||||
{{
|
||||
category ||
|
||||
$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.ARTICLE_SEARCH_RESULT')
|
||||
}}
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<woot-button
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.COPY_LINK')"
|
||||
variant="hollow"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
icon="copy"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
|
||||
<a
|
||||
:href="url"
|
||||
class="button hollow button--only-icon tiny secondary"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.OPEN_LINK')"
|
||||
>
|
||||
<fluent-icon size="12" icon="arrow-up-right" />
|
||||
<span class="show-for-sr">{{ url }}</span>
|
||||
</a>
|
||||
<woot-button
|
||||
variant="hollow"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
icon="preview-link"
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.PREVIEW_LINK')"
|
||||
@click="handlePreview"
|
||||
/>
|
||||
<woot-button
|
||||
class="insert-button"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.INSERT_ARTICLE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
|
||||
export default {
|
||||
name: 'ArticleSearchResultItem',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Untitled',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
locale: {
|
||||
type: String,
|
||||
default: 'en-US',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick() {
|
||||
this.$emit('click');
|
||||
},
|
||||
handlePreview() {
|
||||
this.$emit('preview');
|
||||
},
|
||||
async handleCopy() {
|
||||
await copyTextToClipboard(this.url);
|
||||
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.article-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-micro);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-micro);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--s-600);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-micro);
|
||||
}
|
||||
.action-buttons .button:not(.insert-button) {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
|
||||
.article-item:hover .action-buttons .button:not(.insert-button) {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import ArticleSearchResultItem from '../ArticleSearchResultItem.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center/ArticleSearchResultItem',
|
||||
component: ArticleSearchResultItem,
|
||||
argTypes: {
|
||||
title: {
|
||||
defaultValue: 'Setup your account',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
body: {
|
||||
defaultValue:
|
||||
'You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
category: {
|
||||
defaultValue: 'Getting started',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
defaultValue: 'en-US',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
url: {
|
||||
defaultValue: '/app/accounts/1/conversations/23842',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { ArticleSearchResultItem },
|
||||
template:
|
||||
'<ArticleSearchResultItem v-bind="$props" ></ArticleSearchResultItem>',
|
||||
});
|
||||
|
||||
export const ArticleSearchResultItemStory = Template.bind({});
|
||||
ArticleSearchResultItemStory.args = {
|
||||
title: 'Setup your account',
|
||||
body: `You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.
|
||||
|
||||
You can manage your conversations and collaborate with your team on the go with Chatwoot mobile apps (available for Android and iOS).
|
||||
|
||||
In this user guide, we’ve explained the features, capabilities, modes of operation, and step-by-step procedures for easily using the Chatwoot platform.`,
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="column content-box">
|
||||
<div class="column content-box audit-log--settings">
|
||||
<!-- List Audit Logs -->
|
||||
<div class="row">
|
||||
<div class="small-8 columns with-right-space ">
|
||||
<div>
|
||||
<div>
|
||||
<p
|
||||
v-if="!uiFlags.fetchingList && !records.length"
|
||||
class="no-items-error-message"
|
||||
@@ -16,8 +16,13 @@
|
||||
|
||||
<table
|
||||
v-if="!uiFlags.fetchingList && records.length"
|
||||
class="woot-table"
|
||||
class="woot-table width-100"
|
||||
>
|
||||
<colgroup>
|
||||
<col class="column-activity" />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<!-- Header -->
|
||||
<th
|
||||
@@ -29,16 +34,20 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
|
||||
<td class="wrap-break-words">{{ auditLogItem.username }}</td>
|
||||
<td class="wrap-break-words">
|
||||
{{ auditLogItem.auditable_type }}.{{ auditLogItem.action }}
|
||||
{{ generateLogText(auditLogItem) }}
|
||||
</td>
|
||||
<td class="wrap-break-words">
|
||||
{{
|
||||
messageTimestamp(
|
||||
auditLogItem.created_at,
|
||||
'MMM dd, yyyy hh:mm a'
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td class="remote-address">
|
||||
{{ auditLogItem.remote_address }}
|
||||
</td>
|
||||
<td class="wrap-break-words">
|
||||
{{ dynamicTime(auditLogItem.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -76,13 +85,48 @@ export default {
|
||||
records: 'auditlogs/getAuditLogs',
|
||||
uiFlags: 'auditlogs/getUIFlags',
|
||||
meta: 'auditlogs/getMeta',
|
||||
agentList: 'agents/getAgents',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
// Fetch API Call
|
||||
this.$store.dispatch('auditlogs/fetch', { page: 1 });
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
getAgentName(email) {
|
||||
if (email === null) {
|
||||
return this.$t('AUDIT_LOGS.ACTION.SYSTEM');
|
||||
}
|
||||
const agentName = this.agentList.find(agent => agent.email === email)
|
||||
?.name;
|
||||
// If agent does not exist(removed/deleted), return email from audit log
|
||||
return agentName || email;
|
||||
},
|
||||
generateLogText(auditLogItem) {
|
||||
const username = this.getAgentName(auditLogItem.username);
|
||||
const auditableType = auditLogItem.auditable_type.toLowerCase();
|
||||
const action = auditLogItem.action.toLowerCase();
|
||||
|
||||
const logActions = {
|
||||
create: this.$t('AUDIT_LOGS.ACTION.ADD'),
|
||||
destroy: this.$t('AUDIT_LOGS.ACTION.DELETE'),
|
||||
update: this.$t('AUDIT_LOGS.ACTION.EDIT'),
|
||||
sign_in: this.$t('AUDIT_LOGS.ACTION.SIGN_IN'),
|
||||
sign_out: this.$t('AUDIT_LOGS.ACTION.SIGN_OUT'),
|
||||
};
|
||||
|
||||
// detect if the action is custom user action, which involves
|
||||
// only the user, such as signing in, signing out etc.
|
||||
// if it is, then do not show the auditable type
|
||||
const userActions = this.getUserActions(action);
|
||||
return `${username} ${logActions[action] || action} ${
|
||||
userActions ? '' : auditableType
|
||||
}`;
|
||||
},
|
||||
getUserActions(action) {
|
||||
return ['sign_in', 'sign_out'].includes(action);
|
||||
},
|
||||
onPageChange(page) {
|
||||
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
|
||||
try {
|
||||
@@ -96,12 +140,24 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.remote-address {
|
||||
width: 14rem;
|
||||
}
|
||||
.wrap-break-words {
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.audit-log--settings {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
|
||||
.remote-address {
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
.wrap-break-words {
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.column-activity {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.DOWNLOAD') }}
|
||||
</woot-button>
|
||||
<csat-metrics />
|
||||
<csat-metrics :filters="requestPayload" />
|
||||
<csat-table :page-index="pageIndex" @page-change="onPageNumberChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default {
|
||||
}
|
||||
if (!this.accountReport.data.length) return {};
|
||||
const labels = this.accountReport.data.map(element => {
|
||||
if (this.groupBy.period === GROUP_BY_FILTER[2].period) {
|
||||
if (this.groupBy?.period === GROUP_BY_FILTER[2].period) {
|
||||
let week_date = new Date(fromUnixTime(element.timestamp));
|
||||
const first_day = week_date.getDate() - week_date.getDay();
|
||||
const last_day = first_day + 6;
|
||||
@@ -105,10 +105,10 @@ export default {
|
||||
'dd/MM/yy'
|
||||
)}`;
|
||||
}
|
||||
if (this.groupBy.period === GROUP_BY_FILTER[3].period) {
|
||||
if (this.groupBy?.period === GROUP_BY_FILTER[3].period) {
|
||||
return format(fromUnixTime(element.timestamp), 'MMM-yyyy');
|
||||
}
|
||||
if (this.groupBy.period === GROUP_BY_FILTER[4].period) {
|
||||
if (this.groupBy?.period === GROUP_BY_FILTER[4].period) {
|
||||
return format(fromUnixTime(element.timestamp), 'yyyy');
|
||||
}
|
||||
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
|
||||
@@ -213,7 +213,7 @@ export default {
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy.period,
|
||||
groupBy: groupBy?.period,
|
||||
businessHours,
|
||||
};
|
||||
},
|
||||
|
||||
+17
-1
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div class="medium-2 small-6 csat--metric-card">
|
||||
<div
|
||||
class="medium-2 small-6 csat--metric-card"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
}"
|
||||
>
|
||||
<h3 class="heading">
|
||||
<span>{{ label }}</span>
|
||||
<fluent-icon
|
||||
@@ -29,6 +34,10 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -37,6 +46,13 @@ export default {
|
||||
margin: 0;
|
||||
padding: var(--space-normal);
|
||||
|
||||
&.disabled {
|
||||
// grayscale everything
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heading {
|
||||
align-items: center;
|
||||
color: var(--color-heading);
|
||||
|
||||
+21
-5
@@ -6,16 +6,20 @@
|
||||
:value="responseCount"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:disabled="ratingFilterEnabled"
|
||||
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
|
||||
:value="formatToPercent(satisfactionScore)"
|
||||
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(responseRate)"
|
||||
/>
|
||||
<div v-if="metrics.totalResponseCount" class="medium-6 report-card">
|
||||
<div
|
||||
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
|
||||
class="medium-6 report-card"
|
||||
>
|
||||
<h3 class="heading">
|
||||
<div class="emoji--distribution">
|
||||
<div
|
||||
@@ -24,7 +28,7 @@
|
||||
class="emoji--distribution-item"
|
||||
>
|
||||
<span class="emoji--distribution-key">{{
|
||||
csatRatings[key - 1].emoji
|
||||
ratingToEmoji(key)
|
||||
}}</span>
|
||||
<span>{{ formatToPercent(rating) }}</span>
|
||||
</div>
|
||||
@@ -45,6 +49,12 @@ export default {
|
||||
components: {
|
||||
CsatMetricCard,
|
||||
},
|
||||
props: {
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
csatRatings: CSAT_RATINGS,
|
||||
@@ -57,12 +67,15 @@ export default {
|
||||
satisfactionScore: 'csat/getSatisfactionScore',
|
||||
responseRate: 'csat/getResponseRate',
|
||||
}),
|
||||
ratingFilterEnabled() {
|
||||
return Boolean(this.filters.rating);
|
||||
},
|
||||
chartData() {
|
||||
return {
|
||||
labels: ['Rating'],
|
||||
datasets: CSAT_RATINGS.map((rating, index) => ({
|
||||
datasets: CSAT_RATINGS.map(rating => ({
|
||||
label: rating.emoji,
|
||||
data: [this.ratingPercentage[index + 1]],
|
||||
data: [this.ratingPercentage[rating.value]],
|
||||
backgroundColor: rating.color,
|
||||
})),
|
||||
};
|
||||
@@ -77,6 +90,9 @@ export default {
|
||||
formatToPercent(value) {
|
||||
return value ? `${value}%` : '--';
|
||||
},
|
||||
ratingToEmoji(value) {
|
||||
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ export default {
|
||||
<style scoped>
|
||||
.filter-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-gap: var(--space-slab);
|
||||
|
||||
margin-bottom: var(--space-normal);
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import CsatMetrics from '../CsatMetrics.vue';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const mountParams = {
|
||||
mocks: {
|
||||
$t: msg => msg,
|
||||
},
|
||||
stubs: ['csat-metric-card', 'woot-horizontal-bar'],
|
||||
};
|
||||
|
||||
describe('CsatMetrics.vue', () => {
|
||||
let getters;
|
||||
let store;
|
||||
let wrapper;
|
||||
const filters = { rating: 3 };
|
||||
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
'csat/getMetrics': () => ({ totalResponseCount: 100 }),
|
||||
'csat/getRatingPercentage': () => ({ 1: 10, 2: 20, 3: 30, 4: 30, 5: 10 }),
|
||||
'csat/getSatisfactionScore': () => 85,
|
||||
'csat/getResponseRate': () => 90,
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
getters,
|
||||
});
|
||||
|
||||
wrapper = shallowMount(CsatMetrics, {
|
||||
store,
|
||||
localVue,
|
||||
propsData: { filters },
|
||||
...mountParams,
|
||||
});
|
||||
});
|
||||
|
||||
it('computes response count correctly', () => {
|
||||
expect(wrapper.vm.responseCount).toBe('100');
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('formats values to percent correctly', () => {
|
||||
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
|
||||
expect(wrapper.vm.formatToPercent(null)).toBe('--');
|
||||
});
|
||||
|
||||
it('maps rating value to emoji correctly', () => {
|
||||
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
|
||||
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
|
||||
});
|
||||
|
||||
it('hides report card if rating filter is enabled', () => {
|
||||
expect(wrapper.find('.report-card').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows report card if rating filter is not enabled', async () => {
|
||||
await wrapper.setProps({ filters: {} });
|
||||
expect(wrapper.find('.report-card').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import CsatMetricCard from '../CsatMetricCard.vue';
|
||||
|
||||
import VTooltip from 'v-tooltip';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VTooltip);
|
||||
|
||||
describe('CsatMetricCard.vue', () => {
|
||||
it('renders props correctly', () => {
|
||||
const label = 'Total Responses';
|
||||
const value = '100';
|
||||
const infoText = 'Total number of responses';
|
||||
const wrapper = shallowMount(CsatMetricCard, {
|
||||
propsData: { label, value, infoText },
|
||||
localVue,
|
||||
stubs: ['fluent-icon'],
|
||||
});
|
||||
|
||||
expect(wrapper.find('.heading span').text()).toMatch(label);
|
||||
expect(wrapper.find('.metric').text()).toMatch(value);
|
||||
expect(wrapper.find('.csat--icon').classes()).toContain('has-tooltip');
|
||||
});
|
||||
|
||||
it('adds disabled class when disabled prop is true', () => {
|
||||
const wrapper = shallowMount(CsatMetricCard, {
|
||||
propsData: { label: '', value: '', infoText: '', disabled: true },
|
||||
localVue,
|
||||
stubs: ['fluent-icon'],
|
||||
});
|
||||
|
||||
expect(wrapper.find('.csat--metric-card').classes()).toContain('disabled');
|
||||
});
|
||||
|
||||
it('does not add disabled class when disabled prop is false', () => {
|
||||
const wrapper = shallowMount(CsatMetricCard, {
|
||||
propsData: { label: '', value: '', infoText: '', disabled: false },
|
||||
localVue,
|
||||
stubs: ['fluent-icon'],
|
||||
});
|
||||
|
||||
expect(wrapper.find('.csat--metric-card').classes()).not.toContain(
|
||||
'disabled'
|
||||
);
|
||||
});
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CsatMetrics.vue computes response count correctly 1`] = `
|
||||
<div class="row csat--metrics-container">
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP"></csat-metric-card-stub>
|
||||
<!---->
|
||||
</div>
|
||||
`;
|
||||
@@ -146,6 +146,12 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
||||
IFrameHelper.sendMessage('set-locale', { locale: localeToBeUsed });
|
||||
},
|
||||
|
||||
setColorScheme(darkMode = 'light') {
|
||||
IFrameHelper.sendMessage('set-color-scheme', {
|
||||
darkMode: getDarkMode(darkMode),
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
if (window.$chatwoot.isOpen) {
|
||||
IFrameHelper.events.toggleBubble();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const BUBBLE_DESIGN = ['standard', 'expanded_bubble'];
|
||||
export const WIDGET_DESIGN = ['standard', 'flat'];
|
||||
export const DARK_MODE = ['light', 'auto'];
|
||||
export const DARK_MODE = ['light', 'auto', 'dark'];
|
||||
|
||||
@@ -196,6 +196,10 @@
|
||||
"M5 18C5 17.4477 5.44772 17 6 17H10C10.5523 17 11 17.4477 11 18V21C11 21.5523 10.5523 22 10 22H6C5.44772 22 5 21.5523 5 21V18Z",
|
||||
"M13 18C13 17.4477 13.4477 17 14 17H18C18.5523 17 19 17.4477 19 18V21C19 21.5523 18.5523 22 18 22H14C13.4477 22 13 21.5523 13 21V18Z"
|
||||
],
|
||||
"preview-link-outline": [
|
||||
"M4.524 6.25a.75.75 0 0 1 .75-.75H18.73a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-.75.75H5.274a.75.75 0 0 1-.75-.75v-3.5Zm1.5.75v2H17.98V7H6.024ZM14.23 11.979a.75.75 0 0 0-.75.75v4.5c0 .414.335.75.75.75h4.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-4.5Zm.75 4.5v-3h3v3h-3ZM4.524 13.25a.75.75 0 0 1 .75-.75h5.976a.75.75 0 0 1 0 1.5H5.274a.75.75 0 0 1-.75-.75ZM5.274 16a.75.75 0 0 0 0 1.5h5.976a.75.75 0 0 0 0-1.5H5.274Z",
|
||||
"M2 5.75A2.75 2.75 0 0 1 4.75 3h14.5A2.75 2.75 0 0 1 22 5.75v12.5A2.75 2.75 0 0 1 19.25 21H4.75A2.75 2.75 0 0 1 2 18.25V5.75ZM4.75 4.5c-.69 0-1.25.56-1.25 1.25v12.5c0 .69.56 1.25 1.25 1.25h14.5c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25H4.75Z"
|
||||
],
|
||||
"priority-urgent-outline": [
|
||||
"M2.33325 2.91667C2.33325 2.27233 2.85559 1.75 3.49992 1.75C4.14425 1.75 4.66659 2.27233 4.66659 2.91667V8.16667C4.66659 8.811 4.14425 9.33333 3.49992 9.33333C2.85559 9.33333 2.33325 8.811 2.33325 8.16667V2.91667Z",
|
||||
"M2.33325 11.0833C2.33325 10.439 2.85559 9.91667 3.49992 9.91667C4.14425 9.91667 4.66659 10.439 4.66659 11.0833C4.66659 11.7277 4.14425 12.25 3.49992 12.25C2.85559 12.25 2.33325 11.7277 2.33325 11.0833Z",
|
||||
|
||||
@@ -39,7 +39,8 @@ export const getUnixStartOfDay = date => getUnixTime(startOfDay(date));
|
||||
export const getUnixEndOfDay = date => getUnixTime(endOfDay(date));
|
||||
|
||||
export const generateRelativeTime = (value, unit, languageCode) => {
|
||||
const rtf = new Intl.RelativeTimeFormat(languageCode, {
|
||||
const code = languageCode?.replace(/_/g, '-'); // Hacky fix we need to handle it from source
|
||||
const rtf = new Intl.RelativeTimeFormat(code, {
|
||||
numeric: 'auto',
|
||||
});
|
||||
return rtf.format(value, unit);
|
||||
|
||||
@@ -64,15 +64,59 @@ describe('#isTimeAfter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generateRelativeTime', () => {
|
||||
it('should return correct relative time', () => {
|
||||
expect(generateRelativeTime(-1, 'day', 'en')).toEqual('yesterday');
|
||||
expect(generateRelativeTime(1, 'day', 'en')).toEqual('tomorrow');
|
||||
expect(generateRelativeTime(1, 'hour', 'en')).toEqual('in 1 hour');
|
||||
expect(generateRelativeTime(-1, 'hour', 'en')).toEqual('1 hour ago');
|
||||
expect(generateRelativeTime(1, 'minute', 'en')).toEqual('in 1 minute');
|
||||
expect(generateRelativeTime(-1, 'minute', 'en')).toEqual('1 minute ago');
|
||||
expect(generateRelativeTime(1, 'second', 'en')).toEqual('in 1 second');
|
||||
expect(generateRelativeTime(-1, 'second', 'en')).toEqual('1 second ago');
|
||||
describe('generateRelativeTime', () => {
|
||||
it('should return a string with the relative time', () => {
|
||||
const value = 1;
|
||||
const unit = 'second';
|
||||
const languageCode = 'en-US';
|
||||
const expectedResult = 'in 1 second';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should return a string with the relative time in a different language', () => {
|
||||
const value = 10;
|
||||
const unit = 'minute';
|
||||
const languageCode = 'de-DE';
|
||||
const expectedResult = 'in 10 Minuten';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should return a string with the relative time for a different unit', () => {
|
||||
const value = 1;
|
||||
const unit = 'hour';
|
||||
const languageCode = 'en-US';
|
||||
const expectedResult = 'in 1 hour';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should throw an error if the value is not a number', () => {
|
||||
const value = 1;
|
||||
const unit = 'day';
|
||||
const languageCode = 'en_US';
|
||||
const expectedResult = 'tomorrow';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should throw an error if the value is not a number', () => {
|
||||
const value = 1;
|
||||
const unit = 'day';
|
||||
const languageCode = 'en-US';
|
||||
const expectedResult = 'tomorrow';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
v-if="!conversationSize && isFetchingList"
|
||||
class="flex flex-1 items-center h-full bg-black-25 justify-center"
|
||||
:class="{ dark: prefersDarkMode }"
|
||||
>
|
||||
<spinner size="" />
|
||||
</div>
|
||||
@@ -13,6 +14,7 @@
|
||||
'is-widget-right': isRightAligned,
|
||||
'is-bubble-hidden': hideMessageBubble,
|
||||
'is-flat-design': isWidgetStyleFlat,
|
||||
dark: prefersDarkMode,
|
||||
}"
|
||||
>
|
||||
<router-view />
|
||||
@@ -65,6 +67,7 @@ export default {
|
||||
isFetchingList: 'conversation/getIsFetchingList',
|
||||
isRightAligned: 'appConfig/isRightAligned',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
darkMode: 'appConfig/darkMode',
|
||||
messageCount: 'conversation/getMessageCount',
|
||||
unreadMessageCount: 'conversation/getUnreadMessageCount',
|
||||
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
|
||||
@@ -75,6 +78,12 @@ export default {
|
||||
isRNWebView() {
|
||||
return RNHelper.isRNWebView();
|
||||
},
|
||||
prefersDarkMode() {
|
||||
const isOSOnDarkMode =
|
||||
this.darkMode === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return isOSOnDarkMode || this.darkMode === 'dark';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeCampaign() {
|
||||
@@ -108,6 +117,7 @@ export default {
|
||||
'setReferrerHost',
|
||||
'setWidgetColor',
|
||||
'setBubbleVisibility',
|
||||
'setColorScheme',
|
||||
]),
|
||||
...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
|
||||
...mapActions('campaign', [
|
||||
@@ -298,6 +308,8 @@ export default {
|
||||
} else if (message.event === 'set-locale') {
|
||||
this.setLocale(message.locale);
|
||||
this.setBubbleLabel();
|
||||
} else if (message.event === 'set-color-scheme') {
|
||||
this.setColorScheme(message.darkMode);
|
||||
} else if (message.event === 'toggle-open') {
|
||||
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
|
||||
|
||||
|
||||
@@ -42,11 +42,10 @@
|
||||
</div>
|
||||
<p
|
||||
v-if="message.showAvatar || hasRecordedResponse"
|
||||
v-dompurify-html="agentName"
|
||||
class="agent-name"
|
||||
:class="$dm('text-slate-700', 'dark:text-slate-200')"
|
||||
>
|
||||
{{ agentName }}
|
||||
</p>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
isAgentTyping: 'conversation/getIsAgentTyping',
|
||||
}),
|
||||
colorSchemeClass() {
|
||||
return `${this.darkMode === 'light' ? 'light' : 'dark'}`;
|
||||
return `${this.darkMode === 'dark' ? 'dark-scheme' : 'light-scheme'}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -117,10 +117,10 @@ export default {
|
||||
overflow-y: auto;
|
||||
color-scheme: light dark;
|
||||
|
||||
&.light {
|
||||
&.light-scheme {
|
||||
color-scheme: light;
|
||||
}
|
||||
&.dark {
|
||||
&.dark-scheme {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export default {
|
||||
if (this.darkMode === 'light') {
|
||||
return light;
|
||||
}
|
||||
if (this.darkMode === 'dark') {
|
||||
return dark;
|
||||
}
|
||||
return light + ' ' + dark;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
SET_BUBBLE_VISIBILITY,
|
||||
SET_COLOR_SCHEME,
|
||||
SET_REFERRER_HOST,
|
||||
SET_WIDGET_APP_CONFIG,
|
||||
SET_WIDGET_COLOR,
|
||||
@@ -55,6 +56,9 @@ export const actions = {
|
||||
setWidgetColor({ commit }, widgetColor) {
|
||||
commit(SET_WIDGET_COLOR, widgetColor);
|
||||
},
|
||||
setColorScheme({ commit }, darkMode) {
|
||||
commit(SET_COLOR_SCHEME, darkMode);
|
||||
},
|
||||
setReferrerHost({ commit }, referrerHost) {
|
||||
commit(SET_REFERRER_HOST, referrerHost);
|
||||
},
|
||||
@@ -83,6 +87,9 @@ export const mutations = {
|
||||
[SET_BUBBLE_VISIBILITY]($state, hideMessageBubble) {
|
||||
$state.hideMessageBubble = hideMessageBubble;
|
||||
},
|
||||
[SET_COLOR_SCHEME]($state, darkMode) {
|
||||
$state.darkMode = darkMode;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -24,4 +24,11 @@ describe('#actions', () => {
|
||||
expect(commit.mock.calls).toEqual([['SET_WIDGET_COLOR', '#eaeaea']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setColorScheme', () => {
|
||||
it('creates actions for dark mode properly', () => {
|
||||
actions.setColorScheme({ commit }, 'dark');
|
||||
expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,4 +24,12 @@ describe('#mutations', () => {
|
||||
expect(state.widgetColor).toEqual('#00bcd4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_COLOR_SCHEME', () => {
|
||||
it('sets dark mode properly', () => {
|
||||
const state = { darkMode: 'light' };
|
||||
mutations.SET_COLOR_SCHEME(state, 'dark');
|
||||
expect(state.darkMode).toEqual('dark');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ export const CLEAR_CONVERSATION_ATTRIBUTES = 'CLEAR_CONVERSATION_ATTRIBUTES';
|
||||
export const SET_CONVERSATION_ATTRIBUTES = 'SET_CONVERSATION_ATTRIBUTES';
|
||||
export const SET_WIDGET_APP_CONFIG = 'SET_WIDGET_APP_CONFIG';
|
||||
export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR';
|
||||
export const SET_COLOR_SCHEME = 'SET_COLOR_SCHEME';
|
||||
export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
|
||||
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
|
||||
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
|
||||
|
||||
@@ -3,10 +3,8 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
|
||||
|
||||
def perform
|
||||
Channel::Whatsapp.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL',
|
||||
15.minutes.ago).find_in_batches do |channels_batch|
|
||||
channels_batch.each do |channel|
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
|
||||
end
|
||||
3.hours.ago).limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT).all.each do |channel|
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,8 +20,11 @@ class HookJob < ApplicationJob
|
||||
return unless ['message.created'].include?(event_name)
|
||||
|
||||
message = event_data[:message]
|
||||
|
||||
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
|
||||
if message.attachments.blank?
|
||||
::SendOnSlackJob.perform_later(message, hook)
|
||||
else
|
||||
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
||||
end
|
||||
end
|
||||
|
||||
def process_dialogflow_integration(hook, event_name, event_data)
|
||||
|
||||
@@ -7,9 +7,10 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
return unless should_fetch_email?(channel)
|
||||
|
||||
process_email_for_channel(channel)
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
Rails.logger.error e
|
||||
channel.authorization_error!
|
||||
rescue EOFError, OpenSSL::SSL::SSLError => e
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError => e
|
||||
Rails.logger.error e
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class SendOnSlackJob < ApplicationJob
|
||||
queue_as :medium
|
||||
|
||||
def perform(message, hook)
|
||||
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
|
||||
end
|
||||
end
|
||||
@@ -77,4 +77,4 @@ class AutomationRule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
AutomationRule.include_mod_with('Audit::Inbox')
|
||||
AutomationRule.include_mod_with('Audit::AutomationRule')
|
||||
|
||||
@@ -4,29 +4,41 @@ module CacheKeys
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
included do
|
||||
class_attribute :cacheable_models
|
||||
self.cacheable_models = [Label, Inbox, Team]
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
{
|
||||
label: fetch_value_for_key(id, Label.name.underscore),
|
||||
inbox: fetch_value_for_key(id, Inbox.name.underscore),
|
||||
team: fetch_value_for_key(id, Team.name.underscore)
|
||||
}
|
||||
keys = {}
|
||||
self.class.cacheable_models.each do |model|
|
||||
keys[model.name.underscore.to_sym] = fetch_value_for_key(id, model.name.underscore)
|
||||
end
|
||||
|
||||
keys
|
||||
end
|
||||
|
||||
def invalidate_cache_key_for(key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(id, key)
|
||||
Redis::Alfred.del(prefixed_cache_key)
|
||||
dispatch_cache_udpate_event
|
||||
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)
|
||||
dispatch_cache_udpate_event
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
def reset_cache_keys
|
||||
self.class.cacheable_models.each do |model|
|
||||
invalidate_cache_key_for(model.name.underscore)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_cache_udpate_event
|
||||
def dispatch_cache_update_event
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,16 @@ module Pubsubable
|
||||
included do
|
||||
# Used by the actionCable/PubSub Service we use for real time communications
|
||||
has_secure_token :pubsub_token
|
||||
before_save :rotate_pubsub_token
|
||||
end
|
||||
|
||||
def rotate_pubsub_token
|
||||
# ATM we are only rotating the token if the user is changing their password
|
||||
return unless is_a?(User)
|
||||
|
||||
# Using the class method to avoid the extra Save
|
||||
# TODO: Should we do this on signin ?
|
||||
self.pubsub_token = self.class.generate_unique_secure_token if will_save_change_to_encrypted_password?
|
||||
end
|
||||
|
||||
def pubsub_token
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# index_contacts_on_account_id (account_id)
|
||||
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
|
||||
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
|
||||
# index_contacts_on_identifiable_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text)) # rubocop:disable Layout/LineLength
|
||||
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
|
||||
# uniq_email_per_account_contact (email,account_id) UNIQUE
|
||||
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
|
||||
@@ -136,7 +137,7 @@ class Contact < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.resolved_contacts
|
||||
where("COALESCE(NULLIF(contacts.email,''),NULLIF(contacts.phone_number,''),NULLIF(contacts.identifier,'')) IS NOT NULL")
|
||||
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
|
||||
end
|
||||
|
||||
def discard_invalid_attrs
|
||||
|
||||
@@ -17,8 +17,16 @@
|
||||
# index_custom_filters_on_user_id (user_id)
|
||||
#
|
||||
class CustomFilter < ApplicationRecord
|
||||
MAX_FILTER_PER_USER = 50
|
||||
belongs_to :user
|
||||
belongs_to :account
|
||||
|
||||
enum filter_type: { conversation: 0, contact: 1, report: 2 }
|
||||
validate :validate_number_of_filters
|
||||
|
||||
def validate_number_of_filters
|
||||
return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
|
||||
|
||||
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -101,7 +101,7 @@ class Notification < ApplicationRecord
|
||||
I18n.t(
|
||||
'notifications.notification_title.assigned_conversation_new_message',
|
||||
display_id: conversation.display_id,
|
||||
content: primary_actor&.content&.truncate_words(10)
|
||||
content: transform_user_mention_content(primary_actor&.content&.truncate_words(10))
|
||||
)
|
||||
when 'conversation_mention'
|
||||
"[##{conversation&.display_id}] #{transform_user_mention_content primary_actor&.content}"
|
||||
|
||||
@@ -166,3 +166,5 @@ class User < ApplicationRecord
|
||||
macros.personal.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
User.include_mod_with('Audit::User')
|
||||
|
||||
@@ -38,4 +38,4 @@ class Webhook < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
Webhook.include_mod_with('Audit::Inbox')
|
||||
Webhook.include_mod_with('Audit::Webhook')
|
||||
|
||||
@@ -58,8 +58,9 @@ class Conversations::FilterService < FilterService
|
||||
|
||||
def conversations
|
||||
@conversations = @conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
|
||||
)
|
||||
|
||||
@conversations.latest.page(current_page)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,8 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
||||
|
||||
def sync_templates
|
||||
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
|
||||
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
|
||||
whatsapp_channel[:message_templates] = response['waba_templates'] if response.success?
|
||||
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
|
||||
end
|
||||
|
||||
def validate_provider_config?
|
||||
|
||||
@@ -24,8 +24,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
def sync_templates
|
||||
templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
|
||||
|
||||
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
|
||||
whatsapp_channel[:message_templates] = templates if templates.present?
|
||||
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
|
||||
end
|
||||
|
||||
def fetch_whatsapp_templates(url)
|
||||
|
||||
@@ -17,7 +17,7 @@ json.meta do
|
||||
end
|
||||
|
||||
json.id conversation.display_id
|
||||
if conversation.messages.count.zero?
|
||||
if conversation.messages.first.blank?
|
||||
json.messages []
|
||||
elsif conversation.unread_incoming_messages.count.zero?
|
||||
json.messages [conversation.messages.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)]
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
<% if @resource.confirmed? %>
|
||||
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
|
||||
<% else %>
|
||||
<% if account_user&.inviter.blank? %>
|
||||
<p>
|
||||
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
|
||||
</p>
|
||||
<% end %>
|
||||
<p>Please take a moment and click the link below and activate your account.</p>
|
||||
<% end %>
|
||||
|
||||
@@ -24,4 +26,4 @@
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
|
||||
<% else %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -50,8 +50,8 @@
|
||||
window.browserConfig = {
|
||||
browser_name: '<%= browser.name %>',
|
||||
}
|
||||
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>'
|
||||
window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '')%>'
|
||||
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '') %>'
|
||||
window.logRocketProjectId = '<%= ENV.fetch('LOG_ROCKET_PROJECT_ID', '') %>'
|
||||
</script>
|
||||
<% if @global_config['ANALYTICS_TOKEN'].present? %>
|
||||
<script>
|
||||
@@ -84,5 +84,14 @@
|
||||
})(document,"script");
|
||||
</script>
|
||||
<% end %>
|
||||
<% if ENV.fetch('MS_CLARITY_TOKEN', nil).present? %>
|
||||
<script type="text/javascript">
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, 'clarity', 'script', '<%= ENV.fetch('MS_CLARITY_TOKEN', '') %>');
|
||||
</script>
|
||||
<% end %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<% if @article.author&.avatar_url&.present? %>
|
||||
<img src="<%= @article.author.avatar_url %>" alt="<%= @article.author.display_name %>" class="w-12 h-12 border rounded-full pr-1">
|
||||
<div class="pr-1">
|
||||
<img src="<%= @article.author.avatar_url %>" alt="<%= @article.author.display_name %>" class="w-12 h-12 border rounded-full">
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<h5 class="text-base font-medium text-slate-900 mb-2"><%= @article.author.available_name %></h5>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<section class="main-content__body">
|
||||
<hr/>
|
||||
<%= form_for([:reset_cache, namespace, page.resource], method: :post, html: { class: "form" }) do |f| %>
|
||||
<div class="form-actions">
|
||||
<p>This will clear the IndexedDB cache keys from redis. <br>The next load will fetch the data from backend.</p>
|
||||
<%= f.submit 'Reset Frontend Cache' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -87,3 +87,5 @@ as well as a link to its edit page.
|
||||
</section>
|
||||
|
||||
<%= render partial: "seed_data", locals: {page: page} %>
|
||||
|
||||
<%= render partial: "reset_cache", locals: {page: page} %>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '2.17.0'
|
||||
version: '2.17.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -48,6 +48,10 @@ class Rack::Attack
|
||||
|
||||
throttle('req/ip', limit: 300, period: 1.minute, &:ip)
|
||||
|
||||
###-----------------------------------------------###
|
||||
###-----Authentication Related Throttling---------###
|
||||
###-----------------------------------------------###
|
||||
|
||||
### Prevent Brute-Force Super Admin Login Attacks ###
|
||||
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/super_admin/sign_in' && req.post?
|
||||
@@ -95,6 +99,12 @@ class Rack::Attack
|
||||
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
|
||||
end
|
||||
|
||||
##-----------------------------------------------##
|
||||
|
||||
###-----------------------------------------------###
|
||||
###-----------Widget API Throttling---------------###
|
||||
###-----------------------------------------------###
|
||||
|
||||
## Prevent Conversation Bombing on Widget APIs ###
|
||||
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
|
||||
@@ -109,6 +119,20 @@ class Rack::Attack
|
||||
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extentions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
end
|
||||
|
||||
##-----------------------------------------------##
|
||||
|
||||
###-----------------------------------------------###
|
||||
###----------Application API Throttling-----------###
|
||||
###-----------------------------------------------###
|
||||
|
||||
## Prevent Abuse of Converstion Transcript APIs ###
|
||||
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript', limit: 20, period: 1.hour) do |req|
|
||||
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/conversations/(?<conversation_id>\d+)/transcript}.match(req.path)
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## ----------------------------------------------- ##
|
||||
end
|
||||
|
||||
# Log blocked events
|
||||
|
||||
@@ -70,6 +70,8 @@ en:
|
||||
connection_closed_error: Connection closed.
|
||||
validations:
|
||||
name: should not start or end with symbols, and it should not have < > / \ @ characters.
|
||||
custom_filters:
|
||||
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
|
||||
|
||||
reports:
|
||||
period: Reporting period %{since} to %{until}
|
||||
|
||||
@@ -397,6 +397,7 @@ Rails.application.routes.draw do
|
||||
# order of resources affect the order of sidebar navigation in super admin
|
||||
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
|
||||
post :seed, on: :member
|
||||
post :reset_cache, on: :member
|
||||
end
|
||||
resources :users, only: [:index, :new, :create, :show, :edit, :update, :destroy]
|
||||
resources :access_tokens, only: [:index, :show]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddPartialIndexForResolvedContacts < ActiveRecord::Migration[7.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :contacts, [:account_id, :email, :phone_number, :identifier], where: "(email <> '' OR phone_number <> '' OR identifier <> '')",
|
||||
name: 'index_contacts_on_nonempty_fields', algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_05_15_051424) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_05_23_104139) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -405,6 +405,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_15_051424) do
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
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"
|
||||
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
|
||||
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
|
||||
|
||||
+2
-1
@@ -36,7 +36,7 @@ COPY Gemfile Gemfile.lock ./
|
||||
# https://github.com/googleapis/google-cloud-ruby/issues/13306
|
||||
# adding xz as nokogiri was failing to build libxml
|
||||
# https://github.com/chatwoot/chatwoot/issues/4045
|
||||
RUN apk update && apk add --no-cache build-base musl ruby-full ruby-dev gcc make musl-dev openssl openssl-dev g++ linux-headers xz
|
||||
RUN apk update && apk add --no-cache build-base musl ruby-full ruby-dev gcc make musl-dev openssl openssl-dev g++ linux-headers xz vips
|
||||
RUN bundle config set --local force_ruby_platform true
|
||||
|
||||
# Do not install development or test gems in production
|
||||
@@ -97,6 +97,7 @@ RUN apk update && apk add --no-cache \
|
||||
postgresql-client \
|
||||
imagemagick \
|
||||
git \
|
||||
vips \
|
||||
&& gem install bundler
|
||||
|
||||
RUN if [ "$RAILS_ENV" != "production" ]; then \
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
module Enterprise::DeviseOverrides::SessionsController
|
||||
def render_create_success
|
||||
create_audit_event('sign_in')
|
||||
super
|
||||
end
|
||||
|
||||
def destroy
|
||||
create_audit_event('sign_out')
|
||||
super
|
||||
end
|
||||
|
||||
def create_audit_event(action)
|
||||
return unless @resource
|
||||
|
||||
associated_type = 'Account'
|
||||
@resource.accounts.each do |account|
|
||||
@resource.audits.create(
|
||||
action: action,
|
||||
user_id: @resource.id,
|
||||
associated_id: account.id,
|
||||
associated_type: associated_type
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module Enterprise::Audit::User
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited only: [
|
||||
:availability,
|
||||
:display_name,
|
||||
:email,
|
||||
:name
|
||||
]
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
+8
-4
@@ -13,11 +13,15 @@ module.exports = {
|
||||
'^.+\\.(js|jsx)?$': 'babel-jest',
|
||||
},
|
||||
cacheDirectory: '<rootDir>/.jest-cache',
|
||||
collectCoverage: false,
|
||||
coverageDirectory: 'buildreports',
|
||||
collectCoverageFrom: ['**/app/javascript/**/*.{js,vue}'],
|
||||
collectCoverage: true,
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov'],
|
||||
collectCoverageFrom: [
|
||||
'**/app/javascript/**/*.js',
|
||||
'!**/*.stories.js',
|
||||
'!**/i18n/locale/**/*.js',
|
||||
],
|
||||
reporters: ['default'],
|
||||
// setupTestFrameworkScriptFile: './tests/setup.ts',
|
||||
transformIgnorePatterns: ['node_modules/*'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/app/javascript/$1',
|
||||
|
||||
@@ -12,7 +12,7 @@ module ExceptionList
|
||||
].freeze
|
||||
|
||||
IMAP_EXCEPTIONS = [
|
||||
Errno::ECONNREFUSED, Net::OpenTimeout, Net::IMAP::NoResponseError,
|
||||
Errno::ECONNREFUSED, Net::OpenTimeout,
|
||||
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
|
||||
SocketError
|
||||
].freeze
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
module Limits
|
||||
BULK_ACTIONS_LIMIT = 100
|
||||
BULK_EXTERNAL_HTTP_CALLS_LIMIT = 25
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "2.17.0",
|
||||
"version": "2.17.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -27,7 +27,9 @@ RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/contact_inboxes', typ
|
||||
end.to change(ContactInbox, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(contact.reload.contact_inboxes.map(&:inbox_id)).to include(channel_api.inbox.id)
|
||||
contact_inbox = contact.reload.contact_inboxes.find_by(inbox_id: channel_api.inbox.id)
|
||||
expect(contact_inbox).to be_present
|
||||
expect(contact_inbox.hmac_verified).to be(false)
|
||||
end
|
||||
|
||||
it 'creates a valid email contact inbox' do
|
||||
@@ -43,6 +45,21 @@ RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/contact_inboxes', typ
|
||||
expect(contact.reload.contact_inboxes.map(&:inbox_id)).to include(channel_email.inbox.id)
|
||||
end
|
||||
|
||||
it 'creates an hmac verified contact inbox' do
|
||||
create(:inbox_member, inbox: channel_api.inbox, user: agent)
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/contact_inboxes",
|
||||
params: { inbox_id: channel_api.inbox.id, hmac_verified: true },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(ContactInbox, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
contact_inbox = contact.reload.contact_inboxes.find_by(inbox_id: channel_api.inbox.id)
|
||||
expect(contact_inbox).to be_present
|
||||
expect(contact_inbox.hmac_verified).to be(true)
|
||||
end
|
||||
|
||||
it 'throws error for invalid source id' do
|
||||
create(:inbox_member, inbox: channel_twilio_sms.inbox, user: agent)
|
||||
expect do
|
||||
|
||||
@@ -273,21 +273,6 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(response_data[:status]).to eq('pending')
|
||||
end
|
||||
|
||||
# TODO: remove this spec when we remove the condition check in controller
|
||||
# Added for backwards compatibility for bot status
|
||||
# remove this in subsequent release
|
||||
# it 'creates a conversation as pending if status is specified as bot' do
|
||||
# allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
# post "/api/v1/accounts/#{account.id}/conversations",
|
||||
# headers: agent.create_new_auth_token,
|
||||
# params: { source_id: contact_inbox.source_id, status: 'bot' },
|
||||
# as: :json
|
||||
|
||||
# expect(response).to have_http_status(:success)
|
||||
# response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
# expect(response_data[:status]).to eq('pending')
|
||||
# end
|
||||
|
||||
it 'creates a new conversation with message when message is passed' do
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
post "/api/v1/accounts/#{account.id}/conversations",
|
||||
@@ -304,13 +289,13 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
it 'calls contact inbox builder if contact_id and inbox_id is present' do
|
||||
builder = double
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
allow(ContactInboxBuilder).to receive(:new).and_return(builder)
|
||||
allow(ContactInboxBuilder).to receive(:new).with(contact: contact, inbox: inbox, source_id: nil, hmac_verified: false).and_return(builder)
|
||||
allow(builder).to receive(:perform)
|
||||
expect(builder).to receive(:perform)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { contact_id: contact.id, inbox_id: inbox.id },
|
||||
params: { contact_id: contact.id, inbox_id: inbox.id, hmac_verified: 'false' },
|
||||
as: :json
|
||||
end
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ RSpec.describe 'Custom Filters API', type: :request do
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:new_account) { create(:account) }
|
||||
let(:new_user) { create(:user, account: new_account) }
|
||||
|
||||
it 'creates the filter' do
|
||||
expect do
|
||||
@@ -77,6 +79,23 @@ RSpec.describe 'Custom Filters API', type: :request do
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eq 'vip-customers'
|
||||
end
|
||||
|
||||
it 'gives the error for 51st record' do
|
||||
CustomFilter::MAX_FILTER_PER_USER.times do
|
||||
create(:custom_filter, user: user, account: account)
|
||||
end
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/custom_filters", headers: user.create_new_auth_token,
|
||||
params: payload
|
||||
end.not_to change(CustomFilter, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['message']).to include(
|
||||
'Account Limit reached. The maximum number of allowed custom filters for a user per account is 50.'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -469,17 +469,37 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
expect(api_channel.reload.webhook_url).to eq('webhook.test')
|
||||
end
|
||||
|
||||
it 'updates twitter inbox when administrator' do
|
||||
api_channel = create(:channel_twitter_profile, account: account, tweets_enabled: true)
|
||||
api_inbox = create(:inbox, channel: api_channel, account: account)
|
||||
it 'updates whatsapp inbox when administrator' do
|
||||
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook').to_return(status: 200, body: '', headers: {})
|
||||
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates').to_return(status: 200, body: '', headers: {})
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account)
|
||||
whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
|
||||
whatsapp_channel.prompt_reauthorization!
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{api_inbox.id}",
|
||||
expect(whatsapp_channel).to be_reauthorization_required
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { enable_auto_assignment: false, channel: { provider_config: { api_key: 'new_key' } } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(whatsapp_inbox.reload.enable_auto_assignment).to be_falsey
|
||||
expect(whatsapp_channel.reload.provider_config['api_key']).to eq('new_key')
|
||||
expect(whatsapp_channel.reload).not_to be_reauthorization_required
|
||||
end
|
||||
|
||||
it 'updates twitter inbox when administrator' do
|
||||
twitter_channel = create(:channel_twitter_profile, account: account, tweets_enabled: true)
|
||||
twitter_inbox = create(:inbox, channel: twitter_channel, account: account)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{twitter_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { channel: { tweets_enabled: false } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(api_channel.reload.tweets_enabled).to be(false)
|
||||
expect(twitter_channel.reload.tweets_enabled).to be(false)
|
||||
end
|
||||
|
||||
it 'updates email inbox when administrator' do
|
||||
|
||||
@@ -15,8 +15,6 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let!(:account) { create(:account) }
|
||||
|
||||
it 'shows the list of accounts' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
get '/super_admin/accounts'
|
||||
@@ -27,6 +25,32 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /super_admin/accounts/{account_id}/reset_cache' do
|
||||
before do
|
||||
create(:label, account: account)
|
||||
create(:inbox, account: account)
|
||||
create(:team, account: account)
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/super_admin/accounts/#{account.id}/reset_cache"
|
||||
expect(response).to have_http_status(:redirect)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' 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)
|
||||
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])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /super_admin/accounts/{account_id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Audit API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, password: 'Password1!', account: account) }
|
||||
|
||||
describe 'POST /sign_in' do
|
||||
it 'creates a sign_in audit event wwith valid credentials' do
|
||||
params = { email: user.email, password: 'Password1!' }
|
||||
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(user.email)
|
||||
|
||||
# Check if the sign_in event is created
|
||||
user.reload
|
||||
expect(user.audits.last.action).to eq('sign_in')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
end
|
||||
|
||||
it 'will not create a sign_in audit event with invalid credentials' do
|
||||
params = { email: user.email, password: 'invalid' }
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.not_to change(Enterprise::AuditLog, :count)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /sign_out' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'signs out the user and creates an audit event' do
|
||||
expect do
|
||||
delete '/auth/sign_out', headers: user.create_new_auth_token
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
user.reload
|
||||
|
||||
expect(user.audits.last.action).to eq('sign_out')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ RSpec.describe Channels::Whatsapp::TemplatesSyncSchedulerJob do
|
||||
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
|
||||
non_synced = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: nil)
|
||||
synced_recently = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: Time.zone.now)
|
||||
synced_old = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 16.minutes.ago)
|
||||
synced_old = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 4.hours.ago)
|
||||
described_class.perform_now
|
||||
expect(Channels::Whatsapp::TemplatesSyncJob).not_to(
|
||||
have_been_enqueued.with(synced_recently).on_queue('low')
|
||||
|
||||
@@ -22,18 +22,19 @@ RSpec.describe HookJob do
|
||||
allow(process_service).to receive(:perform)
|
||||
end
|
||||
|
||||
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do
|
||||
it 'calls SendOnSlackJob when its a slack hook' do
|
||||
hook = create(:integrations_hook, app_id: 'slack', account: account)
|
||||
allow(Integrations::Slack::SendOnSlackService).to receive(:new).and_return(process_service)
|
||||
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message], hook: hook)
|
||||
allow(SendOnSlackJob).to receive(:perform_later).and_return(process_service)
|
||||
expect(SendOnSlackJob).to receive(:perform_later).with(event_data[:message], hook)
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
|
||||
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook for template message' do
|
||||
event_data = { message: create(:message, account: account, message_type: :template) }
|
||||
it 'calls SendOnSlackJob when its a slack hook for message with attachments' do
|
||||
event_data = { message: create(:message, :with_attachment, account: account) }
|
||||
hook = create(:integrations_hook, app_id: 'slack', account: account)
|
||||
allow(Integrations::Slack::SendOnSlackService).to receive(:new).and_return(process_service)
|
||||
expect(Integrations::Slack::SendOnSlackService).to receive(:new)
|
||||
allow(SendOnSlackJob).to receive(:set).with(wait: 2.seconds).and_return(SendOnSlackJob)
|
||||
allow(SendOnSlackJob).to receive(:perform_later).and_return(process_service)
|
||||
expect(SendOnSlackJob).to receive(:perform_later).with(event_data[:message], hook)
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendOnSlackJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: create(:message, account: account, content: 'muchas muchas gracias', message_type: :incoming) } }
|
||||
|
||||
context 'when handleable events like message.created' do
|
||||
let(:process_service) { double }
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://slack.com/api/chat.postMessage')
|
||||
allow(process_service).to receive(:perform)
|
||||
end
|
||||
|
||||
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do
|
||||
hook = create(:integrations_hook, app_id: 'slack', account: account)
|
||||
slack_service_instance = Integrations::Slack::SendOnSlackService.new(message: event_data[:message], hook: hook)
|
||||
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message],
|
||||
hook: hook).and_return(slack_service_instance)
|
||||
described_class.perform_now(event_data[:message], hook)
|
||||
end
|
||||
|
||||
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook for template message' do
|
||||
event_data = { message: create(:message, account: account, message_type: :template) }
|
||||
hook = create(:integrations_hook, app_id: 'slack', account: account)
|
||||
slack_service_instance = Integrations::Slack::SendOnSlackService.new(message: event_data[:message], hook: hook)
|
||||
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message],
|
||||
hook: hook).and_return(slack_service_instance)
|
||||
described_class.perform_now(event_data[:message], hook)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -27,6 +27,7 @@ RSpec.describe 'Confirmation Instructions' do
|
||||
|
||||
it 'does not refer to the inviter and their account' do
|
||||
expect(mail.body).to_not match('has invited you to try out Chatwoot!')
|
||||
expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
|
||||
end
|
||||
|
||||
it 'sends a confirmation link' do
|
||||
@@ -41,6 +42,7 @@ RSpec.describe 'Confirmation Instructions' do
|
||||
expect(mail.body).to match(
|
||||
"#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to try out Chatwoot."
|
||||
)
|
||||
expect(mail.body).not_to match('We have a suite of powerful tools ready for you to explore.')
|
||||
end
|
||||
|
||||
it 'sends a password reset link' do
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CacheKeys do
|
||||
let(:test_model) do
|
||||
Struct.new(:id) do
|
||||
include CacheKeys
|
||||
|
||||
def fetch_value_for_key(_id, _key)
|
||||
'value'
|
||||
end
|
||||
end.new(1)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:delete)
|
||||
allow(Redis::Alfred).to receive(:set)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
end
|
||||
|
||||
describe '#cache_keys' do
|
||||
it 'returns a hash of cache keys' do
|
||||
expected_keys = test_model.class.cacheable_models.map do |model|
|
||||
[model.name.underscore.to_sym, 'value']
|
||||
end.to_h
|
||||
|
||||
expect(test_model.cache_keys).to eq(expected_keys)
|
||||
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)
|
||||
end
|
||||
|
||||
it 'dispatches a cache update event' do
|
||||
test_model.update_cache_key('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 '#reset_cache_keys' 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}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -59,6 +59,32 @@ RSpec.describe Notification do
|
||||
#{message.content.truncate_words(10)}"
|
||||
end
|
||||
|
||||
it 'returns appropriate title suited for the notification type participating_conversation_new_message having mention' do
|
||||
message = create(:message, sender: create(:user), content: 'Hey [@John](mention://user/1/john), can you check this ticket?')
|
||||
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: message,
|
||||
secondary_actor: message.sender)
|
||||
|
||||
expect(notification.push_message_title).to eq "[New message] - ##{notification.conversation.display_id} Hey @John, can you check this ticket?"
|
||||
end
|
||||
|
||||
it 'returns appropriate title suited for the notification type participating_conversation_new_message having multple mention' do
|
||||
message = create(:message, sender: create(:user),
|
||||
content: 'Hey [@John](mention://user/1/john), [@Alisha Peter](mention://user/2/alisha) can you check this ticket?')
|
||||
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: message,
|
||||
secondary_actor: message.sender)
|
||||
|
||||
expect(notification.push_message_title).to eq "[New message] - ##{notification.conversation.display_id} \
|
||||
Hey @John, @Alisha Peter can you check this ticket?"
|
||||
end
|
||||
|
||||
it 'returns appropriate title suited for the notification type participating_conversation_new_message if username contains white space' do
|
||||
message = create(:message, sender: create(:user), content: 'Hey [@John Peter](mention://user/1/john%20K) please check this?')
|
||||
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: message,
|
||||
secondary_actor: message.sender)
|
||||
|
||||
expect(notification.push_message_title).to eq "[New message] - ##{notification.conversation.display_id} Hey @John Peter please check this?"
|
||||
end
|
||||
|
||||
it 'returns appropriate title suited for the notification type conversation_mention' do
|
||||
message = create(:message, sender: create(:user), content: 'Hey [@John](mention://user/1/john), can you check this ticket?')
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: message, secondary_actor: message.sender)
|
||||
|
||||
@@ -34,6 +34,22 @@ RSpec.describe User do
|
||||
|
||||
it { expect(user.pubsub_token).not_to be_nil }
|
||||
it { expect(user.saved_changes.keys).not_to eq('pubsub_token') }
|
||||
|
||||
context 'rotates the pubsub_token' do
|
||||
it 'changes the pubsub_token when password changes' do
|
||||
pubsub_token = user.pubsub_token
|
||||
user.password = Faker::Internet.password(special_characters: true)
|
||||
user.save!
|
||||
expect(user.pubsub_token).not_to eq(pubsub_token)
|
||||
end
|
||||
|
||||
it 'will not change pubsub_token when other attributes change' do
|
||||
pubsub_token = user.pubsub_token
|
||||
user.name = Faker::Name.name
|
||||
user.save!
|
||||
expect(user.pubsub_token).to eq(pubsub_token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'hmac_identifier' do
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::Providers::Whatsapp360DialogService do
|
||||
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
|
||||
|
||||
let!(:whatsapp_channel) { create(:channel_whatsapp, sync_templates: false, validate_provider_config: false) }
|
||||
|
||||
describe '#sync_templates' do
|
||||
context 'when called' do
|
||||
it 'updates message_templates_last_updated even when template request fails' do
|
||||
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
|
||||
.to_return(status: 401)
|
||||
|
||||
timstamp = whatsapp_channel.reload.message_templates_last_updated
|
||||
subject.sync_templates
|
||||
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1 +0,0 @@
|
||||
## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb
|
||||
@@ -127,10 +127,21 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
], paging: { prev: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json }
|
||||
)
|
||||
|
||||
timstamp = whatsapp_channel.reload.message_templates_last_updated
|
||||
expect(subject.sync_templates).to be(true)
|
||||
expect(whatsapp_channel.reload.message_templates.first).to eq({ id: '123456789', name: 'test_template' }.stringify_keys)
|
||||
expect(whatsapp_channel.reload.message_templates.second).to eq({ id: '123456789', name: 'next_template' }.stringify_keys)
|
||||
expect(whatsapp_channel.reload.message_templates.last).to eq({ id: '123456789', name: 'last_template' }.stringify_keys)
|
||||
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
|
||||
end
|
||||
|
||||
it 'updates message_templates_last_updated even when template request fails' do
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
|
||||
.to_return(status: 401)
|
||||
|
||||
timstamp = whatsapp_channel.reload.message_templates_last_updated
|
||||
subject.sync_templates
|
||||
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user