Compare commits

..
Author SHA1 Message Date
iamsivin 8fb043de54 chore: spec fixes 2023-05-19 18:55:09 +05:30
iamsivin 6d07a7d92e fix: Problem when using Brazilian Portuguese 2023-05-19 15:51:41 +05:30
117 changed files with 188 additions and 2421 deletions
+1 -1
View File
@@ -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"
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
- persist_to_workspace:
root: coverage
-3
View File
@@ -173,9 +173,6 @@ 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
View File
@@ -784,8 +784,8 @@ GEM
PLATFORMS
arm64-darwin-20
arm64-darwin-21
arm64-darwin-22
arm64-darwin-21
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_params).find_by(
@conversation ||= Conversation.where(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
) || build_conversation
).find_by(conversation_params) || build_conversation
end
def message_content
@@ -96,7 +96,6 @@ 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,13 +1,11 @@
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],
hmac_verified: hmac_verified?
source_id: params[:source_id]
).perform
end
@@ -1,7 +1,6 @@
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]
@@ -105,6 +104,9 @@ 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
@@ -150,8 +152,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id],
hmac_verified: hmac_verified?
source_id: params[:source_id]
).perform
end
@@ -11,7 +11,6 @@ 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,7 +44,26 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update
@inbox.update!(permitted_params.except(:channel))
update_inbox_working_hours
update_channel if channel_update_required?
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]
end
def agent_bot
@@ -84,35 +103,6 @@ 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
-5
View File
@@ -1,5 +0,0 @@
module HmacConcern
def hmac_verified?
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
end
end
@@ -37,5 +37,3 @@ 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,13 +50,6 @@ 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])
+2 -2
View File
@@ -35,10 +35,10 @@ class CSATReportsAPI extends ApiClient {
});
}
getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
getMetrics({ from, to, user_ids, inbox_id, team_id } = {}) {
// no ratings for metrics
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
params: { since: from, until: to, user_ids, inbox_id, team_id },
});
}
}
@@ -115,10 +115,12 @@
</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: {
@@ -142,6 +144,9 @@ 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;
}
@@ -188,11 +193,8 @@ export default {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
displayValue() {
if (this.attributeType === 'date' && this.editedValue) {
return this.editedValue
.split('-')
.reverse()
.join('-');
if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy');
}
return this.editedValue;
},
@@ -245,12 +247,17 @@ 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, this.editedValue);
this.$emit('update', this.attributeKey, updatedValue);
},
onDelete() {
this.isEditing = false;
@@ -45,14 +45,6 @@ export default {
default: () => {},
},
},
watch: {
collection() {
this.renderChart(this.collection, {
...chartOptions,
...this.chartOptions,
});
},
},
mounted() {
this.renderChart(this.collection, {
...chartOptions,
@@ -25,14 +25,10 @@
<blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image
v-if="!hasImgStoryError && storyUrl"
v-if="!hasStoryError"
:url="storyUrl"
@error="onStoryLoadError"
/>
<bubble-video
v-else-if="hasImgStoryError && storyUrl"
:url="storyUrl"
/>
</blockquote>
<bubble-text
v-if="data.content"
@@ -204,7 +200,7 @@ export default {
hasImageError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasImgStoryError: false,
hasStoryError: false,
};
},
computed: {
@@ -433,12 +429,12 @@ export default {
watch: {
data() {
this.hasImageError = false;
this.hasImgStoryError = false;
this.hasStoryError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasImgStoryError = false;
this.hasStoryError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
@@ -468,7 +464,7 @@ export default {
this.hasImageError = true;
},
onStoryLoadError() {
this.hasImgStoryError = true;
this.hasStoryError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
@@ -36,7 +36,6 @@
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"
@@ -57,7 +56,6 @@
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 || this.isAPIInbox) {
if (this.isAWhatsAppChannel) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -79,7 +79,6 @@ 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 {
@@ -88,7 +87,7 @@ export default {
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [snoozeTimesMixin, agentMixin],
mixins: [snoozeTimesMixin],
props: {
status: {
type: String,
@@ -203,16 +202,6 @@ 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 [
{
@@ -223,7 +212,9 @@ export default {
account_id: 0,
email: 'None',
},
...this.filteredAgentOnAvailability,
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
),
];
},
},
@@ -255,7 +246,6 @@ 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,7 +15,6 @@
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
class="agent-thumbnail"
/>
@@ -10,22 +10,15 @@
"TITLE": "Manage Audit Logs",
"DESC": "Audit Logs are trails for events and actions in a Chatwoot System.",
"TABLE_HEADER": [
"Activity",
"Time",
"IP Address"
"User",
"Action",
"IP Address",
"Time"
]
},
"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,14 +74,6 @@
"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": "Auto-send greeting messages when customers start a conversation and send their first message.",
"HELP_TEXT": "Automatically send a greeting message after the contact's first message in a conversation.",
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
+17 -46
View File
@@ -2,68 +2,39 @@ 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 || [];
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 =>
return [
...(this.isAgentSelected
? [
{
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
},
]
: []),
...agents,
].map(item =>
item.id === this.currentUser.id
? {
...item,
availability_status: this.currentUser.accounts.find(
account => account.id === this.currentAccountId
).availability_status,
availability_status: this.currentUser.availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
},
},
};
@@ -20,36 +20,6 @@ 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: [
{
@@ -62,17 +32,7 @@ export default {
},
{
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',
availability_status: 'busy',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
@@ -80,16 +40,6 @@ 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',
@@ -100,147 +50,5 @@ 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',
},
],
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',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
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,71 +12,12 @@ describe('agentMixin', () => {
getters = {
getCurrentUser: () => ({
id: 1,
accounts: [
{
id: 1,
availability_status: 'online',
auto_offline: false,
},
],
availability_status: 'busy',
}),
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() {},
@@ -97,44 +38,4 @@ 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
? errorMessage
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.ERROR_MESSAGE')
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
@@ -1,130 +0,0 @@
<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>
@@ -1,56 +0,0 @@
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, weve 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 audit-log--settings">
<div class="column content-box">
<!-- List Audit Logs -->
<div>
<div>
<div class="row">
<div class="small-8 columns with-right-space ">
<p
v-if="!uiFlags.fetchingList && !records.length"
class="no-items-error-message"
@@ -16,13 +16,8 @@
<table
v-if="!uiFlags.fetchingList && records.length"
class="woot-table width-100"
class="woot-table"
>
<colgroup>
<col class="column-activity" />
<col />
<col />
</colgroup>
<thead>
<!-- Header -->
<th
@@ -34,20 +29,16 @@
</thead>
<tbody>
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="wrap-break-words">{{ auditLogItem.username }}</td>
<td class="wrap-break-words">
{{ generateLogText(auditLogItem) }}
</td>
<td class="wrap-break-words">
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
{{ auditLogItem.auditable_type }}.{{ auditLogItem.action }}
</td>
<td class="remote-address">
{{ auditLogItem.remote_address }}
</td>
<td class="wrap-break-words">
{{ dynamicTime(auditLogItem.created_at) }}
</td>
</tr>
</tbody>
</table>
@@ -85,48 +76,13 @@ 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 {
@@ -140,24 +96,12 @@ export default {
},
};
</script>
<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 scoped>
.remote-address {
width: 14rem;
}
.wrap-break-words {
word-break: break-all;
white-space: normal;
}
</style>
@@ -16,7 +16,7 @@
>
{{ $t('CSAT_REPORTS.DOWNLOAD') }}
</woot-button>
<csat-metrics :filters="requestPayload" />
<csat-metrics />
<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,
};
},
@@ -1,10 +1,5 @@
<template>
<div
class="medium-2 small-6 csat--metric-card"
:class="{
disabled: disabled,
}"
>
<div class="medium-2 small-6 csat--metric-card">
<h3 class="heading">
<span>{{ label }}</span>
<fluent-icon
@@ -34,10 +29,6 @@ export default {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -46,13 +37,6 @@ 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);
@@ -6,20 +6,16 @@
: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="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
:value="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 && !ratingFilterEnabled"
class="medium-6 report-card"
>
<div v-if="metrics.totalResponseCount" class="medium-6 report-card">
<h3 class="heading">
<div class="emoji--distribution">
<div
@@ -28,7 +24,7 @@
class="emoji--distribution-item"
>
<span class="emoji--distribution-key">{{
ratingToEmoji(key)
csatRatings[key - 1].emoji
}}</span>
<span>{{ formatToPercent(rating) }}</span>
</div>
@@ -49,12 +45,6 @@ export default {
components: {
CsatMetricCard,
},
props: {
filters: {
type: Object,
required: true,
},
},
data() {
return {
csatRatings: CSAT_RATINGS,
@@ -67,15 +57,12 @@ export default {
satisfactionScore: 'csat/getSatisfactionScore',
responseRate: 'csat/getResponseRate',
}),
ratingFilterEnabled() {
return Boolean(this.filters.rating);
},
chartData() {
return {
labels: ['Rating'],
datasets: CSAT_RATINGS.map(rating => ({
datasets: CSAT_RATINGS.map((rating, index) => ({
label: rating.emoji,
data: [this.ratingPercentage[rating.value]],
data: [this.ratingPercentage[index + 1]],
backgroundColor: rating.color,
})),
};
@@ -90,9 +77,6 @@ export default {
formatToPercent(value) {
return value ? `${value}%` : '--';
},
ratingToEmoji(value) {
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
},
},
};
</script>
@@ -231,7 +231,7 @@ export default {
<style scoped>
.filter-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: var(--space-slab);
margin-bottom: var(--space-normal);
@@ -1,64 +0,0 @@
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);
});
});
@@ -1,46 +0,0 @@
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'
);
});
});
@@ -1,10 +0,0 @@
// 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>
`;
-6
View File
@@ -146,12 +146,6 @@ 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 -1
View File
@@ -1,3 +1,3 @@
export const BUBBLE_DESIGN = ['standard', 'expanded_bubble'];
export const WIDGET_DESIGN = ['standard', 'flat'];
export const DARK_MODE = ['light', 'auto', 'dark'];
export const DARK_MODE = ['light', 'auto'];
@@ -196,10 +196,6 @@
"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",
-12
View File
@@ -2,7 +2,6 @@
<div
v-if="!conversationSize && isFetchingList"
class="flex flex-1 items-center h-full bg-black-25 justify-center"
:class="{ dark: prefersDarkMode }"
>
<spinner size="" />
</div>
@@ -14,7 +13,6 @@
'is-widget-right': isRightAligned,
'is-bubble-hidden': hideMessageBubble,
'is-flat-design': isWidgetStyleFlat,
dark: prefersDarkMode,
}"
>
<router-view />
@@ -67,7 +65,6 @@ export default {
isFetchingList: 'conversation/getIsFetchingList',
isRightAligned: 'appConfig/isRightAligned',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
darkMode: 'appConfig/darkMode',
messageCount: 'conversation/getMessageCount',
unreadMessageCount: 'conversation/getUnreadMessageCount',
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
@@ -78,12 +75,6 @@ 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() {
@@ -117,7 +108,6 @@ export default {
'setReferrerHost',
'setWidgetColor',
'setBubbleVisibility',
'setColorScheme',
]),
...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
...mapActions('campaign', [
@@ -308,8 +298,6 @@ 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,10 +42,11 @@
</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 === 'dark' ? 'dark-scheme' : 'light-scheme'}`;
return `${this.darkMode === 'light' ? 'light' : 'dark'}`;
},
},
watch: {
@@ -117,10 +117,10 @@ export default {
overflow-y: auto;
color-scheme: light dark;
&.light-scheme {
&.light {
color-scheme: light;
}
&.dark-scheme {
&.dark {
color-scheme: dark;
}
}
@@ -9,9 +9,6 @@ export default {
if (this.darkMode === 'light') {
return light;
}
if (this.darkMode === 'dark') {
return dark;
}
return light + ' ' + dark;
},
},
@@ -1,6 +1,5 @@
import {
SET_BUBBLE_VISIBILITY,
SET_COLOR_SCHEME,
SET_REFERRER_HOST,
SET_WIDGET_APP_CONFIG,
SET_WIDGET_COLOR,
@@ -56,9 +55,6 @@ 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);
},
@@ -87,9 +83,6 @@ export const mutations = {
[SET_BUBBLE_VISIBILITY]($state, hideMessageBubble) {
$state.hideMessageBubble = hideMessageBubble;
},
[SET_COLOR_SCHEME]($state, darkMode) {
$state.darkMode = darkMode;
},
};
export default {
@@ -24,11 +24,4 @@ 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,12 +24,4 @@ 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');
});
});
});
-1
View File
@@ -2,7 +2,6 @@ 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,8 +3,10 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
def perform
Channel::Whatsapp.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL',
3.hours.ago).limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT).all.each do |channel|
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
15.minutes.ago).find_in_batches do |channels_batch|
channels_batch.each do |channel|
Channels::Whatsapp::TemplatesSyncJob.perform_later(channel)
end
end
end
end
+2 -5
View File
@@ -20,11 +20,8 @@ class HookJob < ApplicationJob
return unless ['message.created'].include?(event_name)
message = event_data[:message]
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
end
def process_dialogflow_integration(hook, event_name, event_data)
+2 -3
View File
@@ -7,10 +7,9 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
return unless should_fetch_email?(channel)
process_email_for_channel(channel)
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error e
rescue *ExceptionList::IMAP_EXCEPTIONS
channel.authorization_error!
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError => e
rescue EOFError, OpenSSL::SSL::SSLError => e
Rails.logger.error e
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
-7
View File
@@ -1,7 +0,0 @@
class SendOnSlackJob < ApplicationJob
queue_as :medium
def perform(message, hook)
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
end
end
+1 -1
View File
@@ -77,4 +77,4 @@ class AutomationRule < ApplicationRecord
end
end
AutomationRule.include_mod_with('Audit::AutomationRule')
AutomationRule.include_mod_with('Audit::Inbox')
+9 -21
View File
@@ -4,41 +4,29 @@ module CacheKeys
include CacheKeysHelper
include Events::Types
included do
class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team]
end
def cache_keys
keys = {}
self.class.cacheable_models.each do |model|
keys[model.name.underscore.to_sym] = fetch_value_for_key(id, model.name.underscore)
end
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)
}
end
def invalidate_cache_key_for(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
Redis::Alfred.delete(prefixed_cache_key)
dispatch_cache_update_event
Redis::Alfred.del(prefixed_cache_key)
dispatch_cache_udpate_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_update_event
end
def reset_cache_keys
self.class.cacheable_models.each do |model|
invalidate_cache_key_for(model.name.underscore)
end
dispatch_cache_udpate_event
end
private
def dispatch_cache_update_event
def dispatch_cache_udpate_event
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
end
end
-10
View File
@@ -6,16 +6,6 @@ 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
+1 -2
View File
@@ -19,7 +19,6 @@
# 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
@@ -137,7 +136,7 @@ class Contact < ApplicationRecord
end
def self.resolved_contacts
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
where("COALESCE(NULLIF(contacts.email,''),NULLIF(contacts.phone_number,''),NULLIF(contacts.identifier,'')) IS NOT NULL")
end
def discard_invalid_attrs
-8
View File
@@ -17,16 +17,8 @@
# 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
+1 -1
View File
@@ -101,7 +101,7 @@ class Notification < ApplicationRecord
I18n.t(
'notifications.notification_title.assigned_conversation_new_message',
display_id: conversation.display_id,
content: transform_user_mention_content(primary_actor&.content&.truncate_words(10))
content: primary_actor&.content&.truncate_words(10)
)
when 'conversation_mention'
"[##{conversation&.display_id}] #{transform_user_mention_content primary_actor&.content}"
-2
View File
@@ -166,5 +166,3 @@ class User < ApplicationRecord
macros.personal.destroy_all
end
end
User.include_mod_with('Audit::User')
+1 -1
View File
@@ -38,4 +38,4 @@ class Webhook < ApplicationRecord
end
end
Webhook.include_mod_with('Audit::Webhook')
Webhook.include_mod_with('Audit::Inbox')
+1 -2
View File
@@ -58,9 +58,8 @@ class Conversations::FilterService < FilterService
def conversations
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
)
@conversations.latest.page(current_page)
end
end
@@ -23,8 +23,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
def sync_templates
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
whatsapp_channel[:message_templates] = response['waba_templates'] if response.success?
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
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[:message_templates] = templates if templates.present?
whatsapp_channel.update(message_templates_last_updated: Time.now.utc)
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
end
def fetch_whatsapp_templates(url)
@@ -17,7 +17,7 @@ json.meta do
end
json.id conversation.display_id
if conversation.messages.first.blank?
if conversation.messages.count.zero?
json.messages []
elsif conversation.unread_incoming_messages.count.zero?
json.messages [conversation.messages.includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data)]
@@ -9,11 +9,9 @@
<% 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 %>
@@ -26,4 +24,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 %>
+2 -11
View File
@@ -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,14 +84,5 @@
})(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,9 +35,7 @@
<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? %>
<div class="pr-1">
<img src="<%= @article.author.avatar_url %>" alt="<%= @article.author.display_name %>" class="w-12 h-12 border rounded-full">
</div>
<img src="<%= @article.author.avatar_url %>" alt="<%= @article.author.display_name %>" class="w-12 h-12 border rounded-full pr-1">
<% end %>
<div>
<h5 class="text-base font-medium text-slate-900 mb-2"><%= @article.author.available_name %></h5>
@@ -1,9 +0,0 @@
<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,5 +87,3 @@ 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
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '2.17.1'
version: '2.17.0'
development:
<<: *shared
-24
View File
@@ -48,10 +48,6 @@ 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?
@@ -99,12 +95,6 @@ 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?
@@ -119,20 +109,6 @@ 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
-2
View File
@@ -70,8 +70,6 @@ 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}
-1
View File
@@ -397,7 +397,6 @@ 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]
@@ -1,8 +0,0 @@
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
+1 -2
View File
@@ -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_23_104139) do
ActiveRecord::Schema[7.0].define(version: 2023_05_15_051424) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -405,7 +405,6 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_23_104139) 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
+1 -2
View File
@@ -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 vips
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 bundle config set --local force_ruby_platform true
# Do not install development or test gems in production
@@ -97,7 +97,6 @@ RUN apk update && apk add --no-cache \
postgresql-client \
imagemagick \
git \
vips \
&& gem install bundler
RUN if [ "$RAILS_ENV" != "production" ]; then \
@@ -1,25 +0,0 @@
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
@@ -1,13 +0,0 @@
module Enterprise::Audit::User
extend ActiveSupport::Concern
included do
audited only: [
:availability,
:display_name,
:email,
:name
]
audited associated_with: :account
end
end
+4 -8
View File
@@ -13,15 +13,11 @@ module.exports = {
'^.+\\.(js|jsx)?$': 'babel-jest',
},
cacheDirectory: '<rootDir>/.jest-cache',
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
collectCoverageFrom: [
'**/app/javascript/**/*.js',
'!**/*.stories.js',
'!**/i18n/locale/**/*.js',
],
collectCoverage: false,
coverageDirectory: 'buildreports',
collectCoverageFrom: ['**/app/javascript/**/*.{js,vue}'],
reporters: ['default'],
// setupTestFrameworkScriptFile: './tests/setup.ts',
transformIgnorePatterns: ['node_modules/*'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/app/javascript/$1',
+1 -1
View File
@@ -12,7 +12,7 @@ module ExceptionList
].freeze
IMAP_EXCEPTIONS = [
Errno::ECONNREFUSED, Net::OpenTimeout,
Errno::ECONNREFUSED, Net::OpenTimeout, Net::IMAP::NoResponseError,
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
SocketError
].freeze
-1
View File
@@ -1,4 +1,3 @@
module Limits
BULK_ACTIONS_LIMIT = 100
BULK_EXTERNAL_HTTP_CALLS_LIMIT = 25
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "2.17.1",
"version": "2.17.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -27,9 +27,7 @@ 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)
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)
expect(contact.reload.contact_inboxes.map(&:inbox_id)).to include(channel_api.inbox.id)
end
it 'creates a valid email contact inbox' do
@@ -45,21 +43,6 @@ 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,6 +273,21 @@ 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",
@@ -289,13 +304,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).with(contact: contact, inbox: inbox, source_id: nil, hmac_verified: false).and_return(builder)
allow(ContactInboxBuilder).to receive(:new).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, hmac_verified: 'false' },
params: { contact_id: contact.id, inbox_id: inbox.id },
as: :json
end
@@ -66,8 +66,6 @@ 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
@@ -79,23 +77,6 @@ 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,37 +469,17 @@ RSpec.describe 'Inboxes API', type: :request do
expect(api_channel.reload.webhook_url).to eq('webhook.test')
end
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!
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)
api_channel = create(:channel_twitter_profile, account: account, tweets_enabled: true)
api_inbox = create(:inbox, channel: api_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{twitter_inbox.id}",
patch "/api/v1/accounts/#{account.id}/inboxes/#{api_inbox.id}",
headers: admin.create_new_auth_token,
params: { channel: { tweets_enabled: false } },
as: :json
expect(response).to have_http_status(:success)
expect(twitter_channel.reload.tweets_enabled).to be(false)
expect(api_channel.reload.tweets_enabled).to be(false)
end
it 'updates email inbox when administrator' do
@@ -15,6 +15,8 @@ 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'
@@ -25,32 +27,6 @@ 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
@@ -1,53 +0,0 @@
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: 4.hours.ago)
synced_old = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 16.minutes.ago)
described_class.perform_now
expect(Channels::Whatsapp::TemplatesSyncJob).not_to(
have_been_enqueued.with(synced_recently).on_queue('low')
+7 -8
View File
@@ -22,19 +22,18 @@ RSpec.describe HookJob do
allow(process_service).to receive(:perform)
end
it 'calls SendOnSlackJob when its a slack hook' do
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do
hook = create(:integrations_hook, app_id: 'slack', account: account)
allow(SendOnSlackJob).to receive(:perform_later).and_return(process_service)
expect(SendOnSlackJob).to receive(:perform_later).with(event_data[:message], hook)
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)
described_class.perform_now(hook, event_name, event_data)
end
it 'calls SendOnSlackJob when its a slack hook for message with attachments' do
event_data = { message: create(:message, :with_attachment, account: account) }
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)
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)
allow(Integrations::Slack::SendOnSlackService).to receive(:new).and_return(process_service)
expect(Integrations::Slack::SendOnSlackService).to receive(:new)
described_class.perform_now(hook, event_name, event_data)
end
-35
View File
@@ -1,35 +0,0 @@
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,7 +27,6 @@ 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
@@ -42,7 +41,6 @@ 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
-73
View File
@@ -1,73 +0,0 @@
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
-26
View File
@@ -59,32 +59,6 @@ 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)
-16
View File
@@ -34,22 +34,6 @@ 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
@@ -1,21 +0,0 @@
## 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
@@ -0,0 +1 @@
## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb
@@ -127,21 +127,10 @@ 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
-18
View File
@@ -18,16 +18,6 @@ custom_attribute:
$ref: ./resource/custom_attribute.yml
automation_rule:
$ref: ./resource/automation_rule.yml
portal:
$ref: ./resource/portal.yml
category:
$ref: ./resource/category.yml
article:
$ref: ./resource/article.yml
category:
$ref: ./resource/category.yml
article:
$ref: ./resource/article.yml
contact:
$ref: ./resource/contact.yml
conversation:
@@ -120,14 +110,6 @@ integrations_hook_update_payload:
automation_rule_create_update_payload:
$ref: ./request/automation_rule/create_update_payload.yml
# Help Center
portal_create_update_payload:
$ref: ./request/portal/portal_create_update_payload.yml
category_create_update_payload:
$ref: ./request/portal/category_create_update_payload.yml
article_create_update_payload:
$ref: ./request/portal/article_create_update_payload.yml
## public requests
public_contact_create_update_payload:
@@ -1,34 +0,0 @@
type: object
properties:
content:
type: string
description: The text content.
meta:
type: object
description: Use for search
example: { tags: ['article_name'], title: 'article title', description: 'article description' }
position:
type: integer
description: article position in category
status:
type: integer
example: ['draft', 'published', 'archived']
title:
type: string
slug:
type: string
views:
type: integer
portal_id:
type: integer
account_id:
type: integer
author_id:
type: integer
category_id:
type: integer
folder_id:
type: integer
associated_article_id:
type: integer
description: To associate similar articles to each other, e.g to provide the link for the reference.

Some files were not shown because too many files have changed in this diff Show More