Merge branch 'develop' into feature/cw-1605
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
2.2.0
|
||||
2.17.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.1.0
|
||||
2.3.0
|
||||
|
||||
@@ -63,7 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
|
||||
@conversation ||= Conversation.where(
|
||||
"additional_attributes ->> 'type' = 'instagram_direct_message'"
|
||||
).find_by(conversation_params) || build_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@@ -95,7 +97,8 @@ 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
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message' }
|
||||
))
|
||||
end
|
||||
|
||||
@@ -103,10 +106,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id,
|
||||
additional_attributes: {
|
||||
type: 'instagram_direct_message'
|
||||
}
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversations_count = result[:count]
|
||||
end
|
||||
|
||||
def attachments
|
||||
@attachments = @conversation.attachments
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
|
||||
|
||||
@@ -48,7 +48,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
def message_finder_params
|
||||
{
|
||||
filter_internal_messages: true,
|
||||
before: permitted_params[:before]
|
||||
before: permitted_params[:before],
|
||||
after: permitted_params[:after]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -62,7 +63,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
end
|
||||
|
||||
def set_message
|
||||
|
||||
@@ -5,7 +5,8 @@ class ConversationFinder
|
||||
SORT_OPTIONS = {
|
||||
latest: 'latest',
|
||||
sort_on_created_at: 'sort_on_created_at',
|
||||
last_user_message_at: 'last_user_message_at'
|
||||
last_user_message_at: 'last_user_message_at',
|
||||
sort_on_priority: 'sort_on_priority'
|
||||
}.with_indifferent_access
|
||||
|
||||
# assumptions
|
||||
@@ -53,9 +54,10 @@ class ConversationFinder
|
||||
|
||||
find_all_conversations
|
||||
filter_by_status unless params[:q]
|
||||
filter_by_team if @team
|
||||
filter_by_labels if params[:labels]
|
||||
filter_by_query if params[:q]
|
||||
filter_by_team
|
||||
filter_by_labels
|
||||
filter_by_query
|
||||
filter_by_source_id
|
||||
end
|
||||
|
||||
def set_inboxes
|
||||
@@ -106,6 +108,8 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def filter_by_query
|
||||
return unless params[:q]
|
||||
|
||||
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
|
||||
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
|
||||
.where(messages: { message_type: allowed_message_types }).includes(:messages)
|
||||
@@ -120,13 +124,24 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def filter_by_team
|
||||
return unless @team
|
||||
|
||||
@conversations = @conversations.where(team: @team)
|
||||
end
|
||||
|
||||
def filter_by_labels
|
||||
return unless params[:labels]
|
||||
|
||||
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
||||
end
|
||||
|
||||
def filter_by_source_id
|
||||
return unless params[:source_id]
|
||||
|
||||
@conversations = @conversations.joins(:contact_inbox)
|
||||
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
|
||||
@@ -261,6 +261,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Basic filter dropdown
|
||||
.basic-filter {
|
||||
left: 0;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
// Card label
|
||||
.label-container {
|
||||
.label {
|
||||
|
||||
@@ -74,6 +74,7 @@ import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin, clickaway],
|
||||
@@ -144,6 +145,15 @@ export default {
|
||||
closeDropdown() {
|
||||
this.showDropdown = false;
|
||||
},
|
||||
async recordAnalytics({ type, tone }) {
|
||||
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||
if (event) {
|
||||
this.$track(event, {
|
||||
type,
|
||||
tone,
|
||||
});
|
||||
}
|
||||
},
|
||||
async processEvent(type = 'rephrase') {
|
||||
this.uiFlags[type] = true;
|
||||
try {
|
||||
@@ -159,6 +169,7 @@ export default {
|
||||
} = result;
|
||||
this.$emit('replace-text', generatedMessage || this.message);
|
||||
this.closeDropdown();
|
||||
this.recordAnalytics({ type, tone: this.activeTone });
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
|
||||
} finally {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}"
|
||||
@mouseenter="onCardHover"
|
||||
@mouseleave="onCardLeave"
|
||||
@click="cardClick(chat)"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
|
||||
@@ -313,21 +313,33 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
cardClick(chat) {
|
||||
const { activeInbox } = this;
|
||||
const path = conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
});
|
||||
onCardClick(e) {
|
||||
const { activeInbox, chat } = this;
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
})
|
||||
);
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
window.open(
|
||||
window.chatwootConfig.hostURL + path,
|
||||
'_blank',
|
||||
'noopener noreferrer nofollow'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isActiveChat) {
|
||||
return;
|
||||
}
|
||||
router.push({ path: frontendURL(path) });
|
||||
|
||||
router.push({ path });
|
||||
},
|
||||
onCardHover() {
|
||||
this.hovered = !this.hideThumbnail;
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
controls
|
||||
class="skip-context-menu"
|
||||
>
|
||||
<source :src="attachment.data_url" />
|
||||
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
<bubble-video
|
||||
v-else-if="attachment.file_type === 'video'"
|
||||
|
||||
@@ -15,6 +15,7 @@ export default {
|
||||
SORT_BY_TYPE: {
|
||||
LATEST: 'latest',
|
||||
CREATED_AT: 'sort_on_created_at',
|
||||
PRIORITY: 'sort_on_priority',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -76,3 +76,9 @@ export const PORTALS_EVENTS = Object.freeze({
|
||||
DELETE_ARTICLE: 'Deleted an article',
|
||||
PREVIEW_ARTICLE: 'Previewed article',
|
||||
});
|
||||
|
||||
export const OPEN_AI_EVENTS = Object.freeze({
|
||||
SUMMARIZE: 'OpenAI: Used summarize',
|
||||
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||
REPHRASE: 'OpenAI: Used rephrase',
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import es from './locale/es';
|
||||
import fa from './locale/fa';
|
||||
import fi from './locale/fi';
|
||||
import fr from './locale/fr';
|
||||
import he from './locale/he';
|
||||
import hi from './locale/hi';
|
||||
import hu from './locale/hu';
|
||||
import id from './locale/id';
|
||||
@@ -47,6 +48,7 @@ export default {
|
||||
fa,
|
||||
fi,
|
||||
fr,
|
||||
he,
|
||||
hi,
|
||||
hu,
|
||||
id,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Canned Response added successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
"ERROR_MESSAGE": "Could not create canned response, Please try again later"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -56,14 +56,14 @@
|
||||
"BUTTON_TEXT": "Edit",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Canned Response updated successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
"ERROR_MESSAGE": "Could not update canned response, Please try again later"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Canned response deleted successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
"ERROR_MESSAGE": "Could not delete canned response, Please try again later"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
},
|
||||
"sort_on_created_at": {
|
||||
"TEXT": "Created at"
|
||||
},
|
||||
"sort_on_priority": {
|
||||
"TEXT": "Priority"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"CHANNEL_GREETING_TOGGLE": {
|
||||
"LABEL": "Enable channel greeting",
|
||||
"HELP_TEXT": "Automatically send a greeting message when a new conversation is created.",
|
||||
"HELP_TEXT": "Automatically send a greeting message after the contact's first message in a conversation.",
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('#messageTimestamp', () => {
|
||||
|
||||
describe('#dynamicTime', () => {
|
||||
it('returns correct value', () => {
|
||||
Date.now = jest.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
|
||||
expect(TimeMixin.methods.dynamicTime(1612971343)).toEqual(
|
||||
'about 2 years ago'
|
||||
);
|
||||
|
||||
+4
-1
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<thumbnail
|
||||
v-if="notificationItem.primary_actor.meta.assignee"
|
||||
v-if="hasAssignee(notificationItem)"
|
||||
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
|
||||
size="16px"
|
||||
:username="notificationItem.primary_actor.meta.assignee.name"
|
||||
@@ -127,6 +127,9 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
hasAssignee(notification) {
|
||||
return notification.primary_actor.meta?.assignee;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:header-title="$t('CANNED_MGMT.ADD.TITLE')"
|
||||
:header-content="$t('CANNED_MGMT.ADD.DESC')"
|
||||
/>
|
||||
<form class="row" @submit.prevent="addAgent()">
|
||||
<form class="row" @submit.prevent="addCannedResponse()">
|
||||
<div class="medium-12 columns">
|
||||
<label :class="{ error: $v.shortCode.$error }">
|
||||
{{ $t('CANNED_MGMT.ADD.FORM.SHORT_CODE.LABEL') }}
|
||||
@@ -107,7 +107,7 @@ export default {
|
||||
this.$v.shortCode.$reset();
|
||||
this.$v.content.$reset();
|
||||
},
|
||||
addAgent() {
|
||||
addCannedResponse() {
|
||||
// Show loading on button
|
||||
this.addCanned.showLoading = true;
|
||||
// Make API Calls
|
||||
@@ -123,9 +123,11 @@ export default {
|
||||
this.resetForm();
|
||||
this.onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(error => {
|
||||
this.addCanned.showLoading = false;
|
||||
this.showAlert(this.$t('CANNED_MGMT.ADD.API.ERROR_MESSAGE'));
|
||||
const errorMessage =
|
||||
error?.message || this.$t('CANNED_MGMT.ADD.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import Modal from '../../../../components/Modal';
|
||||
|
||||
export default {
|
||||
@@ -65,6 +66,7 @@ export default {
|
||||
Modal,
|
||||
WootMessageEditor,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
id: { type: Number, default: null },
|
||||
edcontent: { type: String, default: '' },
|
||||
@@ -76,7 +78,6 @@ export default {
|
||||
editCanned: {
|
||||
showAlert: false,
|
||||
showLoading: false,
|
||||
message: '',
|
||||
},
|
||||
shortCode: this.edshortCode,
|
||||
content: this.edcontent,
|
||||
@@ -102,9 +103,6 @@ export default {
|
||||
this.$v.content.$touch();
|
||||
this.content = name;
|
||||
},
|
||||
showAlert() {
|
||||
bus.$emit('newToastMessage', this.editCanned.message);
|
||||
},
|
||||
resetForm() {
|
||||
this.shortCode = '';
|
||||
this.content = '';
|
||||
@@ -124,21 +122,17 @@ export default {
|
||||
.then(() => {
|
||||
// Reset Form, Show success message
|
||||
this.editCanned.showLoading = false;
|
||||
this.editCanned.message = this.$t(
|
||||
'CANNED_MGMT.EDIT.API.SUCCESS_MESSAGE'
|
||||
);
|
||||
this.showAlert();
|
||||
this.showAlert(this.$t('CANNED_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
this.resetForm();
|
||||
setTimeout(() => {
|
||||
this.onClose();
|
||||
}, 10);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(error => {
|
||||
this.editCanned.showLoading = false;
|
||||
this.editCanned.message = this.$t(
|
||||
'CANNED_MGMT.EDIT.API.ERROR_MESSAGE'
|
||||
);
|
||||
this.showAlert();
|
||||
const errorMessage =
|
||||
error?.message || this.$t('CANNED_MGMT.EDIT.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -198,8 +198,10 @@ export default {
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(() => {
|
||||
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE'));
|
||||
.catch(error => {
|
||||
const errorMessage =
|
||||
error?.message || this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import * as types from '../mutation-types';
|
||||
import CannedResponseAPI from '../../api/cannedResponse';
|
||||
@@ -46,8 +47,10 @@ const actions = {
|
||||
const response = await CannedResponseAPI.create(cannedObj);
|
||||
commit(types.default.ADD_CANNED, response.data);
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,8 +63,10 @@ const actions = {
|
||||
const response = await CannedResponseAPI.update(id, updateObj);
|
||||
commit(types.default.EDIT_CANNED, response.data);
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,8 +76,10 @@ const actions = {
|
||||
await CannedResponseAPI.delete(id);
|
||||
commit(types.default.DELETE_CANNED, id);
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
|
||||
return id;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import {
|
||||
MESSAGE_TYPE,
|
||||
CONVERSATION_PRIORITY_ORDER,
|
||||
} from 'shared/constants/messages';
|
||||
import { applyPageFilters } from './helpers';
|
||||
|
||||
export const getSelectedChatConversation = ({
|
||||
@@ -13,6 +16,12 @@ const getters = {
|
||||
const comparator = {
|
||||
latest: (a, b) => b.last_activity_at - a.last_activity_at,
|
||||
sort_on_created_at: (a, b) => a.created_at - b.created_at,
|
||||
sort_on_priority: (a, b) => {
|
||||
return (
|
||||
CONVERSATION_PRIORITY_ORDER[a.priority] -
|
||||
CONVERSATION_PRIORITY_ORDER[b.priority]
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return allConversations.sort(comparator[chatSortFilter]);
|
||||
|
||||
@@ -130,6 +130,66 @@ describe('#getters', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('order conversations based on priority', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
priority: 'low',
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
priority: 'urgent',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messages: [{ content: 'test3' }],
|
||||
priority: 'medium',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466421280,
|
||||
},
|
||||
],
|
||||
chatSortFilter: 'sort_on_priority',
|
||||
};
|
||||
|
||||
expect(getters.getAllConversations(state)).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
messages: [{ content: 'test2' }],
|
||||
priority: 'urgent',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466424480,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messages: [{ content: 'test3' }],
|
||||
priority: 'medium',
|
||||
created_at: 1652109801,
|
||||
last_activity_at: 1466421280,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
content: 'test1',
|
||||
},
|
||||
],
|
||||
priority: 'low',
|
||||
created_at: 1683645801,
|
||||
last_activity_at: 2466424490,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('#getUnAssignedChats', () => {
|
||||
it('order returns only chats assigned to user', () => {
|
||||
|
||||
@@ -1,35 +1,9 @@
|
||||
// This file is automatically compiled by Webpack, along with any other files
|
||||
// present in this directory. You're encouraged to place your actual application logic in
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so that it will be compiled.
|
||||
|
||||
import Vue from 'vue';
|
||||
import Rails from '@rails/ujs';
|
||||
import Turbolinks from 'turbolinks';
|
||||
import PublicArticleSearch from '../portal/components/PublicArticleSearch.vue';
|
||||
|
||||
import { navigateToLocalePage } from '../portal/portalHelpers';
|
||||
|
||||
import '../portal/application.scss';
|
||||
import { InitializationHelpers } from '../portal/portalHelpers';
|
||||
|
||||
Rails.start();
|
||||
Turbolinks.start();
|
||||
|
||||
const initPageSetUp = () => {
|
||||
navigateToLocalePage();
|
||||
const isSearchContainerAvailable = document.querySelector('#search-wrap');
|
||||
if (isSearchContainerAvailable) {
|
||||
new Vue({
|
||||
components: { PublicArticleSearch },
|
||||
template: '<PublicArticleSearch />',
|
||||
}).$mount('#search-wrap');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initPageSetUp();
|
||||
});
|
||||
|
||||
document.addEventListener('turbolinks:load', () => {
|
||||
initPageSetUp();
|
||||
});
|
||||
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
|
||||
|
||||
@@ -16,3 +16,25 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
// Taking these utils from tailwind 3.x.x, need to remove once we upgrade
|
||||
.scroll-mt-24 {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.top-24 {
|
||||
top: 6rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
.permalink {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.permalink {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="hidden lg:block flex-1 scroll-mt-24 pl-4">
|
||||
<div v-if="rows.length > 0" class="sticky top-24 py-12 overflow-auto">
|
||||
<nav class="max-w-2xl">
|
||||
<h2
|
||||
id="on-this-page-title"
|
||||
class="text-slate-800 font-semibold tracking-wide border-b mb-3 leading-7"
|
||||
>
|
||||
{{ tocHeader }}
|
||||
</h2>
|
||||
<ol role="list" class="mt-4 space-y-3 text-base">
|
||||
<li v-for="element in rows" :key="element.slug" class="leading-6">
|
||||
<p :class="getClassName(element)">
|
||||
<a
|
||||
:href="`#${element.slug}`"
|
||||
data-turbolinks="false"
|
||||
class="text-base text-slate-800 cursor-pointer"
|
||||
>
|
||||
{{ element.title }}
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
tocHeader() {
|
||||
return window.portalConfig.tocHeader;
|
||||
},
|
||||
h1Count() {
|
||||
return this.rows.filter(el => el.tag === 'h1').length;
|
||||
},
|
||||
h2Count() {
|
||||
return this.rows.filter(el => el.tag === 'h2').length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getClassName(el) {
|
||||
if (el.tag === 'h1') {
|
||||
return '';
|
||||
}
|
||||
if (el.tag === 'h2') {
|
||||
if (this.h1Count > 0) {
|
||||
return 'ml-2';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (el.tag === 'h3') {
|
||||
if (!this.h1Count && !this.h2Count) {
|
||||
return '';
|
||||
}
|
||||
return 'ml-8';
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,13 +1,79 @@
|
||||
export const navigateToLocalePage = () => {
|
||||
const allLocaleSwitcher = document.querySelector('.locale-switcher');
|
||||
import slugifyWithCounter from '@sindresorhus/slugify';
|
||||
import Vue from 'vue';
|
||||
|
||||
if (!allLocaleSwitcher) {
|
||||
return false;
|
||||
}
|
||||
import PublicArticleSearch from './components/PublicArticleSearch.vue';
|
||||
import TableOfContents from './components/TableOfContents.vue';
|
||||
|
||||
const { portalSlug } = allLocaleSwitcher.dataset;
|
||||
allLocaleSwitcher.addEventListener('change', event => {
|
||||
window.location = `/hc/${portalSlug}/${event.target.value}/`;
|
||||
export const getHeadingsfromTheArticle = () => {
|
||||
const rows = [];
|
||||
const articleElement = document.getElementById('cw-article-content');
|
||||
articleElement.querySelectorAll('h1, h2, h3').forEach(element => {
|
||||
const slug = slugifyWithCounter(element.innerText);
|
||||
element.id = slug;
|
||||
element.className = 'scroll-mt-24 heading';
|
||||
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbolinks="false">#</a>`;
|
||||
rows.push({
|
||||
slug,
|
||||
title: element.innerText,
|
||||
tag: element.tagName.toLowerCase(),
|
||||
});
|
||||
});
|
||||
return false;
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const InitializationHelpers = {
|
||||
navigateToLocalePage: () => {
|
||||
const allLocaleSwitcher = document.querySelector('.locale-switcher');
|
||||
|
||||
if (!allLocaleSwitcher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { portalSlug } = allLocaleSwitcher.dataset;
|
||||
allLocaleSwitcher.addEventListener('change', event => {
|
||||
window.location = `/hc/${portalSlug}/${event.target.value}/`;
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
||||
initalizeSearch: () => {
|
||||
const isSearchContainerAvailable = document.querySelector('#search-wrap');
|
||||
if (isSearchContainerAvailable) {
|
||||
new Vue({
|
||||
components: { PublicArticleSearch },
|
||||
template: '<PublicArticleSearch />',
|
||||
}).$mount('#search-wrap');
|
||||
}
|
||||
},
|
||||
|
||||
initializeTableOfContents: () => {
|
||||
const isOnArticlePage = document.querySelector('#cw-hc-toc');
|
||||
if (isOnArticlePage) {
|
||||
new Vue({
|
||||
components: { TableOfContents },
|
||||
data: { rows: getHeadingsfromTheArticle() },
|
||||
template: '<table-of-contents :rows="rows" />',
|
||||
}).$mount('#cw-hc-toc');
|
||||
}
|
||||
},
|
||||
|
||||
initialize: () => {
|
||||
InitializationHelpers.navigateToLocalePage();
|
||||
InitializationHelpers.initalizeSearch();
|
||||
InitializationHelpers.initializeTableOfContents();
|
||||
},
|
||||
|
||||
onLoad: () => {
|
||||
InitializationHelpers.initialize();
|
||||
if (window.location.hash) {
|
||||
if ('scrollRestoration' in window.history) {
|
||||
window.history.scrollRestoration = 'manual';
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = window.location.hash;
|
||||
a['data-turbolinks'] = false;
|
||||
a.click();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { navigateToLocalePage } from '../portalHelpers';
|
||||
import { InitializationHelpers } from '../portalHelpers';
|
||||
|
||||
describe('#navigateToLocalePage', () => {
|
||||
it('returns correct cookie name', () => {
|
||||
@@ -14,7 +14,7 @@ describe('#navigateToLocalePage', () => {
|
||||
callback({ target: { value: 1 } });
|
||||
});
|
||||
|
||||
navigateToLocalePage();
|
||||
InitializationHelpers.navigateToLocalePage();
|
||||
expect(allLocaleSwitcher.addEventListener).toBeCalledWith(
|
||||
'change',
|
||||
expect.any(Function)
|
||||
|
||||
@@ -27,6 +27,14 @@ export const CONVERSATION_PRIORITY = {
|
||||
MEDIUM: 'medium',
|
||||
};
|
||||
|
||||
export const CONVERSATION_PRIORITY_ORDER = {
|
||||
urgent: 1,
|
||||
high: 2,
|
||||
medium: 3,
|
||||
low: 4,
|
||||
null: 5,
|
||||
};
|
||||
|
||||
// Size in mega bytes
|
||||
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
||||
export const MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL = 5;
|
||||
|
||||
@@ -30,3 +30,10 @@ export const isTimeAfter = (h1, m1, h2, m2) => {
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const generateRelativeTime = (value, unit, languageCode) => {
|
||||
const rtf = new Intl.RelativeTimeFormat(languageCode, {
|
||||
numeric: 'auto',
|
||||
});
|
||||
return rtf.format(value, unit);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
formatUnixDate,
|
||||
formatDigitToString,
|
||||
isTimeAfter,
|
||||
generateRelativeTime,
|
||||
} from '../DateHelper';
|
||||
|
||||
describe('#DateHelper', () => {
|
||||
@@ -62,3 +63,16 @@ describe('#isTimeAfter', () => {
|
||||
expect(isTimeAfter(11, 59, 12, 0)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import es from './locale/es.json';
|
||||
import fa from './locale/fa.json';
|
||||
import fi from './locale/fi.json';
|
||||
import fr from './locale/fr.json';
|
||||
import he from './locale/he.json';
|
||||
import hi from './locale/hi.json';
|
||||
import hu from './locale/hu.json';
|
||||
import id from './locale/id.json';
|
||||
@@ -47,6 +48,7 @@ export default {
|
||||
fa,
|
||||
fi,
|
||||
fr,
|
||||
he,
|
||||
hi,
|
||||
hu,
|
||||
id,
|
||||
|
||||
@@ -16,8 +16,8 @@ const sendAttachmentAPI = async attachment => {
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const getMessagesAPI = async ({ before }) => {
|
||||
const urlData = endPoints.getConversation({ before });
|
||||
const getMessagesAPI = async ({ before, after }) => {
|
||||
const urlData = endPoints.getConversation({ before, after });
|
||||
return API.get(urlData.url, { params: urlData.params });
|
||||
};
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ const sendAttachment = ({ attachment }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getConversation = ({ before }) => ({
|
||||
const getConversation = ({ before, after }) => ({
|
||||
url: `/api/v1/widget/messages${window.location.search}`,
|
||||
params: { before },
|
||||
params: { before, after },
|
||||
});
|
||||
|
||||
const updateMessage = id => ({
|
||||
|
||||
@@ -79,3 +79,26 @@ describe('#triggerCampaign', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getConversation', () => {
|
||||
it('should returns correct payload', () => {
|
||||
const spy = jest.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
const windowSpy = jest.spyOn(window, 'window', 'get');
|
||||
expect(
|
||||
endPoints.getConversation({
|
||||
after: 123,
|
||||
})
|
||||
).toEqual({
|
||||
url: `/api/v1/widget/messages`,
|
||||
params: {
|
||||
after: 123,
|
||||
before: undefined,
|
||||
},
|
||||
});
|
||||
windowSpy.mockRestore();
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import HeaderActions from './HeaderActions';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
@@ -57,7 +58,7 @@ export default {
|
||||
FluentIcon,
|
||||
HeaderActions,
|
||||
},
|
||||
mixins: [availabilityMixin, routerMixin, darkMixin],
|
||||
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin, darkMixin],
|
||||
props: {
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
@@ -93,11 +94,6 @@ export default {
|
||||
}
|
||||
return anyAgentOnline;
|
||||
},
|
||||
replyWaitMessage() {
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
: this.$t('TEAM_AVAILABILITY.OFFLINE');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onBackButtonClick() {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
|
||||
import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
||||
import CustomButton from 'shared/components/Button';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
@@ -47,7 +48,7 @@ export default {
|
||||
AvailableAgents,
|
||||
CustomButton,
|
||||
},
|
||||
mixins: [configMixin, availabilityMixin, darkMixin],
|
||||
mixins: [configMixin, nextAvailabilityTime, availabilityMixin, darkMixin],
|
||||
props: {
|
||||
availableAgents: {
|
||||
type: Array,
|
||||
@@ -75,17 +76,6 @@ export default {
|
||||
}
|
||||
return anyAgentOnline;
|
||||
},
|
||||
replyWaitMessage() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
|
||||
if (this.isOnline) {
|
||||
return this.replyTimeStatus;
|
||||
}
|
||||
if (workingHoursEnabled) {
|
||||
return this.outOfOfficeMessage;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
startConversation() {
|
||||
|
||||
@@ -25,6 +25,22 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
}
|
||||
|
||||
onDisconnected = () => {
|
||||
this.setLastMessageId();
|
||||
};
|
||||
|
||||
onReconnect = () => {
|
||||
this.syncLatestMessages();
|
||||
};
|
||||
|
||||
setLastMessageId = () => {
|
||||
this.app.$store.dispatch('conversation/setLastMessageId');
|
||||
};
|
||||
|
||||
syncLatestMessages = () => {
|
||||
this.app.$store.dispatch('conversation/syncLatestMessages');
|
||||
};
|
||||
|
||||
onStatusChange = data => {
|
||||
if (data.status === 'resolved') {
|
||||
this.app.$store.dispatch('campaign/resetCampaign');
|
||||
|
||||
@@ -9,6 +9,7 @@ import es from './locale/es.json';
|
||||
import fa from './locale/fa.json';
|
||||
import fi from './locale/fi.json';
|
||||
import fr from './locale/fr.json';
|
||||
import he from './locale/he.json';
|
||||
import hi from './locale/hi.json';
|
||||
import hu from './locale/hu.json';
|
||||
import id from './locale/id.json';
|
||||
@@ -47,6 +48,7 @@ export default {
|
||||
fa,
|
||||
fi,
|
||||
fr,
|
||||
he,
|
||||
hi,
|
||||
hu,
|
||||
id,
|
||||
|
||||
@@ -19,8 +19,18 @@
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day"
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN": "We will be back online"
|
||||
},
|
||||
"DAY_NAMES": [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
],
|
||||
"START_CONVERSATION": "Start Conversation",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
|
||||
@@ -21,6 +21,17 @@ export default {
|
||||
return this.$t('REPLY_TIME.IN_A_FEW_HOURS');
|
||||
}
|
||||
},
|
||||
replyWaitMessage() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
if (workingHoursEnabled) {
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
|
||||
}
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
: this.$t('TEAM_AVAILABILITY.OFFLINE');
|
||||
},
|
||||
outOfOfficeMessage() {
|
||||
return this.channelConfig.outOfOfficeMessage;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
timeSlotParse,
|
||||
defaultTimeSlot,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import { generateRelativeTime } from 'shared/helpers/DateHelper';
|
||||
|
||||
const MINUTE_ROUNDING_FACTOR = 5;
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
dayNames: this.$t('DAY_NAMES'),
|
||||
timeSlots: [...defaultTimeSlot],
|
||||
timeSlot: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
channelConfig() {
|
||||
return window.chatwootWebChannel;
|
||||
},
|
||||
workingHours() {
|
||||
return this.channelConfig.workingHours;
|
||||
},
|
||||
newDateWithTimeZone() {
|
||||
return utcToZonedTime(new Date(), this.timeZoneValue);
|
||||
},
|
||||
presentHour() {
|
||||
return this.newDateWithTimeZone.getHours();
|
||||
},
|
||||
presentMinute() {
|
||||
return this.newDateWithTimeZone.getMinutes();
|
||||
},
|
||||
currentDay() {
|
||||
const date = this.newDateWithTimeZone;
|
||||
const day = date.getDay();
|
||||
const currentDay = Object.keys(this.dayNames).find(
|
||||
key => this.dayNames[key] === this.dayNames[day]
|
||||
);
|
||||
return Number(currentDay);
|
||||
},
|
||||
timeZoneValue() {
|
||||
return this.channelConfig.timezone;
|
||||
},
|
||||
languageCode() {
|
||||
return window.chatwootWebChannel.locale;
|
||||
},
|
||||
currentDayWorkingHours() {
|
||||
return this.workingHours.find(
|
||||
slot => slot.day_of_week === this.currentDay
|
||||
);
|
||||
},
|
||||
nextDayWorkingHours() {
|
||||
let nextDay = this.getNextDay(this.currentDay);
|
||||
let nextWorkingHour = this.getNextWorkingHour(nextDay);
|
||||
|
||||
// It gets the next working hour for the next day. If there is no working hour for the next day,
|
||||
// it keeps iterating through the days of the week until it finds the next working hour.
|
||||
while (!nextWorkingHour) {
|
||||
nextDay = this.getNextDay(nextDay);
|
||||
nextWorkingHour = this.getNextWorkingHour(nextDay);
|
||||
}
|
||||
return nextWorkingHour;
|
||||
},
|
||||
currentDayTimings() {
|
||||
const {
|
||||
open_hour: openHour,
|
||||
open_minutes: openMinute,
|
||||
close_hour: closeHour,
|
||||
} = this.currentDayWorkingHours ?? {};
|
||||
return {
|
||||
openHour,
|
||||
openMinute,
|
||||
closeHour,
|
||||
};
|
||||
},
|
||||
nextDayTimings() {
|
||||
const { open_hour: openHour, open_minutes: openMinute } =
|
||||
this.nextDayWorkingHours ?? {};
|
||||
return {
|
||||
openHour,
|
||||
openMinute,
|
||||
};
|
||||
},
|
||||
dayDiff() {
|
||||
// Here this is used to get the difference between current day and next working day
|
||||
const nextDay = this.nextDayWorkingHours.day_of_week;
|
||||
const totalDays = 6;
|
||||
return nextDay > this.currentDay
|
||||
? nextDay - this.currentDay - 1
|
||||
: totalDays - this.currentDay + nextDay;
|
||||
},
|
||||
dayNameOfNextWorkingDay() {
|
||||
return this.dayNames[this.nextDayWorkingHours.day_of_week];
|
||||
},
|
||||
hoursAndMinutesBackInOnline() {
|
||||
if (this.presentHour >= this.currentDayTimings.closeHour) {
|
||||
return this.getHoursAndMinutesUntilNextDayOpen(
|
||||
this.nextDayWorkingHours.open_all_day
|
||||
? 0
|
||||
: this.nextDayTimings.openHour,
|
||||
this.nextDayTimings.openMinute,
|
||||
this.currentDayTimings.closeHour
|
||||
);
|
||||
}
|
||||
return this.getHoursAndMinutesUntilNextDayOpen(
|
||||
this.currentDayTimings.openHour,
|
||||
this.currentDayTimings.openMinute,
|
||||
this.currentDayTimings.closeHour
|
||||
);
|
||||
},
|
||||
exactTimeInAmPm() {
|
||||
return `${
|
||||
this.timeSlot.day === this.currentDay ? `at ${this.timeSlot.from}` : ''
|
||||
}`;
|
||||
},
|
||||
hoursAndMinutesLeft() {
|
||||
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
|
||||
|
||||
const timeLeftChars = [];
|
||||
|
||||
if (hoursLeft > 0) {
|
||||
const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft;
|
||||
const hourRelative = generateRelativeTime(
|
||||
roundedUpHoursLeft,
|
||||
'hour',
|
||||
this.languageCode
|
||||
);
|
||||
timeLeftChars.push(`${hourRelative}`);
|
||||
}
|
||||
|
||||
if (minutesLeft > 0 && hoursLeft === 0) {
|
||||
const roundedUpMinLeft =
|
||||
Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) *
|
||||
MINUTE_ROUNDING_FACTOR;
|
||||
const minRelative = generateRelativeTime(
|
||||
roundedUpMinLeft,
|
||||
'minutes',
|
||||
this.languageCode
|
||||
);
|
||||
timeLeftChars.push(`${minRelative}`);
|
||||
}
|
||||
|
||||
return timeLeftChars.join(' ');
|
||||
},
|
||||
hoursAndMinutesToBack() {
|
||||
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
|
||||
if (hoursLeft >= 3) {
|
||||
return this.exactTimeInAmPm;
|
||||
}
|
||||
if (hoursLeft > 0 || minutesLeft > 0) {
|
||||
return this.hoursAndMinutesLeft;
|
||||
}
|
||||
return 'in some time';
|
||||
},
|
||||
timeLeftToBackInOnline() {
|
||||
if (
|
||||
this.hoursAndMinutesBackInOnline.hoursLeft >= 24 ||
|
||||
(this.timeSlot.day !== this.currentDay && this.dayDiff === 0)
|
||||
) {
|
||||
const hourRelative = generateRelativeTime(
|
||||
this.dayDiff + 1,
|
||||
'days',
|
||||
this.languageCode
|
||||
);
|
||||
return `${hourRelative}`;
|
||||
}
|
||||
if (
|
||||
this.dayDiff >= 1 &&
|
||||
this.presentHour >= this.currentDayTimings.closeHour
|
||||
) {
|
||||
return `on ${this.dayNameOfNextWorkingDay}`;
|
||||
}
|
||||
return this.hoursAndMinutesToBack;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setTimeSlot();
|
||||
},
|
||||
methods: {
|
||||
getNextDay(day) {
|
||||
// This code calculates the next day of the week based on the current day. If the current day is Saturday (6), then the next day will be Sunday (0).
|
||||
return (day + 1) % 7;
|
||||
},
|
||||
getNextWorkingHour(day) {
|
||||
const workingHour = this.workingHours.find(
|
||||
slot => slot.day_of_week === day
|
||||
);
|
||||
if (workingHour && !workingHour.closed_all_day) {
|
||||
return workingHour;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getHoursAndMinutesUntilNextDayOpen(
|
||||
openHour, // If the present time is after the closing time of the current day, then the openHour will be the opening hour of the next day else it will be the opening hour of the current day.
|
||||
openMinutes, // If the present time is after the closing time of the current day, then the openMinutes will be the opening minutes of the next day else it will be the opening minutes of the current day.
|
||||
closeHour // The closeHour will be the closing hour of the current day. It will be used to calculate the time remaining until the next day's opening hours.
|
||||
) {
|
||||
// This code calculates the time remaining until the next day's opening hours,
|
||||
// given the current time, the opening hours, and the closing hours of the current day.
|
||||
if (closeHour < openHour) {
|
||||
openHour += 24;
|
||||
}
|
||||
let diffMinutes =
|
||||
openHour * 60 +
|
||||
openMinutes -
|
||||
(this.presentHour * 60 + this.presentMinute);
|
||||
diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes;
|
||||
const [hoursLeft, minutesLeft] = [
|
||||
Math.floor(diffMinutes / 60),
|
||||
diffMinutes % 60,
|
||||
];
|
||||
|
||||
// It returns the remaining time in hours and minutes as an object with keys hours and minutes.
|
||||
return { hoursLeft, minutesLeft };
|
||||
},
|
||||
setTimeSlot() {
|
||||
// It checks if the working hours feature is enabled for the store.
|
||||
|
||||
const timeSlots = this.workingHours;
|
||||
|
||||
// If the present hour is after the closing hour of the current day,
|
||||
// then the next day's working hours will be used to calculate the time remaining until the next day's opening hours,
|
||||
// else the current day's working hours will be used
|
||||
const currentSlot =
|
||||
this.presentHour >= this.currentDayTimings.closeHour
|
||||
? this.nextDayWorkingHours
|
||||
: this.currentDayWorkingHours;
|
||||
|
||||
// It parses the working hours to get the time slots in AM/PM format.
|
||||
const slots = timeSlotParse(timeSlots).length
|
||||
? timeSlotParse(timeSlots)
|
||||
: defaultTimeSlot;
|
||||
this.timeSlots = slots;
|
||||
|
||||
// It finds the time slot for the current slot.
|
||||
this.timeSlot = this.timeSlots.find(
|
||||
slot => slot.day === currentSlot.day_of_week
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,544 @@
|
||||
import { createWrapper } from '@vue/test-utils';
|
||||
import nextAvailabilityTimeMixin from '../nextAvailabilityTime';
|
||||
import Vue from 'vue';
|
||||
|
||||
describe('nextAvailabilityTimeMixin', () => {
|
||||
const chatwootWebChannel = {
|
||||
workingHoursEnabled: true,
|
||||
workingHours: [
|
||||
{
|
||||
day_of_week: 0,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 2,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 3,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 4,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 5,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 6,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
window.chatwootWebChannel = chatwootWebChannel;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.chatwootWebChannel;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return day names', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.dayNames).toEqual([
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return channelConfig', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
expect(wrapper.vm.channelConfig).toEqual(chatwootWebChannel);
|
||||
});
|
||||
|
||||
it('should return workingHours', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
expect(wrapper.vm.workingHours).toEqual(chatwootWebChannel.workingHours);
|
||||
});
|
||||
|
||||
it('should return currentDayWorkingHours', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const currentDay = new Date().getDay();
|
||||
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
|
||||
slot => slot.day_of_week === currentDay
|
||||
);
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.currentDayWorkingHours).toEqual(expectedWorkingHours);
|
||||
});
|
||||
|
||||
it('should return nextDayWorkingHours', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const currentDay = new Date().getDay();
|
||||
const nextDay = currentDay === 6 ? 0 : currentDay + 1;
|
||||
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
|
||||
slot => slot.day_of_week === nextDay
|
||||
);
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.nextDayWorkingHours).toEqual(expectedWorkingHours);
|
||||
});
|
||||
|
||||
it('should return presentHour', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
expect(wrapper.vm.presentHour).toBe(new Date().getHours());
|
||||
});
|
||||
|
||||
it('should return presentMinute', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
expect(wrapper.vm.presentMinute).toBe(new Date().getMinutes());
|
||||
});
|
||||
|
||||
it('should return currentDay', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const date = new Date();
|
||||
const day = date.getDay();
|
||||
const currentDay = Object.keys(wrapper.vm.dayNames).find(
|
||||
key => wrapper.vm.dayNames[key] === wrapper.vm.dayNames[day]
|
||||
);
|
||||
expect(wrapper.vm.currentDay).toBe(Number(currentDay));
|
||||
});
|
||||
|
||||
it('should return currentDayTimings', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const {
|
||||
open_hour: openHour,
|
||||
open_minutes: openMinute,
|
||||
close_hour: closeHour,
|
||||
} = wrapper.vm.currentDayWorkingHours;
|
||||
expect(wrapper.vm.currentDayTimings).toEqual({
|
||||
openHour,
|
||||
openMinute,
|
||||
closeHour,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nextDayTimings', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const {
|
||||
open_hour: openHour,
|
||||
open_minutes: openMinute,
|
||||
} = wrapper.vm.nextDayWorkingHours;
|
||||
|
||||
expect(wrapper.vm.nextDayTimings).toEqual({
|
||||
openHour,
|
||||
openMinute,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dayDiff', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const currentDay = wrapper.vm.currentDay;
|
||||
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
|
||||
const totalDays = 6;
|
||||
const expectedDayDiff =
|
||||
nextDay > currentDay
|
||||
? nextDay - currentDay - 1
|
||||
: totalDays - currentDay + nextDay;
|
||||
|
||||
expect(wrapper.vm.dayDiff).toEqual(expectedDayDiff);
|
||||
});
|
||||
|
||||
it('should return dayNameOfNextWorkingDay', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
|
||||
const expectedDayName = wrapper.vm.dayNames[nextDay];
|
||||
expect(wrapper.vm.dayNameOfNextWorkingDay).toEqual(expectedDayName);
|
||||
});
|
||||
|
||||
it('should return hoursAndMinutesBackInOnline', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const currentDayCloseHour =
|
||||
chatwootWebChannel.workingHours[wrapper.vm.currentDay].close_hour;
|
||||
const nextDayOpenHour =
|
||||
chatwootWebChannel.workingHours[
|
||||
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
|
||||
].open_hour;
|
||||
const nextDayOpenMinute =
|
||||
chatwootWebChannel.workingHours[
|
||||
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
|
||||
].open_minutes;
|
||||
const expectedHoursAndMinutes = wrapper.vm.getHoursAndMinutesUntilNextDayOpen(
|
||||
nextDayOpenHour,
|
||||
nextDayOpenMinute,
|
||||
currentDayCloseHour
|
||||
);
|
||||
expect(wrapper.vm.hoursAndMinutesBackInOnline).toEqual(
|
||||
expectedHoursAndMinutes
|
||||
);
|
||||
});
|
||||
|
||||
it('should return getNextDay', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
expect(wrapper.vm.getNextDay(6)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return in 30 minutes', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
jest
|
||||
.useFakeTimers('modern')
|
||||
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.timeSlot = {
|
||||
day: 4,
|
||||
from: '12:00 AM',
|
||||
openAllDay: false,
|
||||
to: '08:00 AM',
|
||||
valid: true,
|
||||
};
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
chatwootWebChannel.workingHours[4].open_hour = 18;
|
||||
chatwootWebChannel.workingHours[4].open_minutes = 0;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 23;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 30 minutes');
|
||||
});
|
||||
|
||||
it('should return in 3 hours', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
jest
|
||||
.useFakeTimers('modern')
|
||||
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.timeSlot = {
|
||||
day: 4,
|
||||
from: '12:00 PM',
|
||||
openAllDay: false,
|
||||
to: '11:30 PM',
|
||||
valid: true,
|
||||
};
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
chatwootWebChannel.workingHours[4].open_hour = 19;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 2 hours');
|
||||
});
|
||||
|
||||
it('should return at 10:00 AM', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
jest
|
||||
.useFakeTimers('modern')
|
||||
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.timeSlot = {
|
||||
day: 4,
|
||||
from: '10:00 AM',
|
||||
openAllDay: false,
|
||||
to: '11:00 AM',
|
||||
valid: true,
|
||||
};
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
chatwootWebChannel.workingHours[4].open_hour = 10;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('at 10:00 AM');
|
||||
});
|
||||
|
||||
it('should return tomorrow', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
jest
|
||||
.useFakeTimers('modern')
|
||||
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.timeSlot = {
|
||||
day: 0,
|
||||
from: '12:00 AM',
|
||||
openAllDay: false,
|
||||
to: '08:00 AM',
|
||||
valid: true,
|
||||
};
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
chatwootWebChannel.workingHours[4].open_hour = 9;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 16;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('tomorrow');
|
||||
});
|
||||
|
||||
it('should return on Saturday', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
mixins: [nextAvailabilityTimeMixin],
|
||||
};
|
||||
jest
|
||||
.useFakeTimers('modern')
|
||||
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
wrapper.vm.timeSlot = {
|
||||
day: 0,
|
||||
from: '12:00 AM',
|
||||
openAllDay: false,
|
||||
to: '08:00 AM',
|
||||
valid: true,
|
||||
};
|
||||
wrapper.vm.dayNames = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
chatwootWebChannel.workingHours[4].open_hour = 9;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 16;
|
||||
chatwootWebChannel.workingHours[5].closed_all_day = true;
|
||||
expect(wrapper.vm.timeLeftToBackInOnline).toBe('on Saturday');
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,10 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
setLastMessageId: async ({ commit }) => {
|
||||
commit('setLastMessageId');
|
||||
},
|
||||
|
||||
sendAttachment: async ({ commit }, params) => {
|
||||
const {
|
||||
attachment: { thumbUrl, fileType },
|
||||
@@ -99,6 +103,36 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
syncLatestMessages: async ({ state, commit }) => {
|
||||
try {
|
||||
const { lastMessageId, conversations } = state;
|
||||
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await getMessagesAPI({ after: lastMessageId });
|
||||
|
||||
const { contact_last_seen_at: lastSeen } = meta;
|
||||
const formattedMessages = getNonDeletedMessages({ messages: payload });
|
||||
const missingMessages = formattedMessages.filter(
|
||||
message => conversations?.[message.id] === undefined
|
||||
);
|
||||
if (!missingMessages.length) return;
|
||||
missingMessages.forEach(message => {
|
||||
conversations[message.id] = message;
|
||||
});
|
||||
// Sort conversation messages by created_at
|
||||
const updatedConversation = Object.fromEntries(
|
||||
Object.entries(conversations).sort(
|
||||
(a, b) => a[1].created_at - b[1].created_at
|
||||
)
|
||||
);
|
||||
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
|
||||
commit('setMissingMessagesInConversation', updatedConversation);
|
||||
} catch (error) {
|
||||
// IgnoreError
|
||||
}
|
||||
},
|
||||
|
||||
clearConversations: ({ commit }) => {
|
||||
commit('clearConversations');
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ const state = {
|
||||
isAgentTyping: false,
|
||||
isCreating: false,
|
||||
},
|
||||
lastMessageId: null,
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -62,6 +62,10 @@ export const mutations = {
|
||||
payload.map(message => Vue.set($state.conversations, message.id, message));
|
||||
},
|
||||
|
||||
setMissingMessagesInConversation($state, payload) {
|
||||
Vue.set($state, 'conversation', payload);
|
||||
},
|
||||
|
||||
updateMessage($state, { id, content_attributes }) {
|
||||
$state.conversations[id] = {
|
||||
...$state.conversations[id],
|
||||
@@ -94,4 +98,12 @@ export const mutations = {
|
||||
setMetaUserLastSeenAt($state, lastSeen) {
|
||||
$state.meta.userLastSeenAt = lastSeen;
|
||||
},
|
||||
|
||||
setLastMessageId($state) {
|
||||
const { conversations } = $state;
|
||||
const lastMessage = Object.values(conversations).pop();
|
||||
if (!lastMessage) return;
|
||||
const { id } = lastMessage;
|
||||
$state.lastMessageId = id;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -217,4 +217,209 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#syncLatestMessages', () => {
|
||||
it('latest message should append to end of list', async () => {
|
||||
const state = {
|
||||
uiFlags: { allMessagesLoaded: false },
|
||||
conversations: {
|
||||
'454': {
|
||||
id: 454,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682244355, // Sunday, 23 April 2023 10:05:55
|
||||
conversation_id: 20,
|
||||
},
|
||||
'463': {
|
||||
id: 463,
|
||||
content: 'ss',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682490729, // Wednesday, 26 April 2023 06:32:09
|
||||
conversation_id: 20,
|
||||
},
|
||||
},
|
||||
lastMessageId: 463,
|
||||
};
|
||||
API.get.mockResolvedValue({
|
||||
data: {
|
||||
payload: [
|
||||
{
|
||||
id: 465,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682504326, // Wednesday, 26 April 2023 10:18:46
|
||||
conversation_id: 20,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
contact_last_seen_at: 1466424490,
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.syncLatestMessages({ state, commit }, {});
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['conversation/setMetaUserLastSeenAt', 1466424490, { root: true }],
|
||||
[
|
||||
'setMissingMessagesInConversation',
|
||||
|
||||
{
|
||||
'454': {
|
||||
id: 454,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682244355,
|
||||
conversation_id: 20,
|
||||
},
|
||||
'463': {
|
||||
id: 463,
|
||||
content: 'ss',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682490729,
|
||||
conversation_id: 20,
|
||||
},
|
||||
'465': {
|
||||
id: 465,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682504326,
|
||||
conversation_id: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('old message should insert to exact position', async () => {
|
||||
const state = {
|
||||
uiFlags: { allMessagesLoaded: false },
|
||||
conversations: {
|
||||
'454': {
|
||||
id: 454,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682244355, // Sunday, 23 April 2023 10:05:55
|
||||
conversation_id: 20,
|
||||
},
|
||||
'463': {
|
||||
id: 463,
|
||||
content: 'ss',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682490729, // Wednesday, 26 April 2023 06:32:09
|
||||
conversation_id: 20,
|
||||
},
|
||||
},
|
||||
lastMessageId: 463,
|
||||
};
|
||||
API.get.mockResolvedValue({
|
||||
data: {
|
||||
payload: [
|
||||
{
|
||||
id: 460,
|
||||
content: 'Hi how are you',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682417926, // Tuesday, 25 April 2023 10:18:46
|
||||
conversation_id: 20,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
contact_last_seen_at: 14664223490,
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.syncLatestMessages({ state, commit }, {});
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['conversation/setMetaUserLastSeenAt', 14664223490, { root: true }],
|
||||
[
|
||||
'setMissingMessagesInConversation',
|
||||
|
||||
{
|
||||
'454': {
|
||||
id: 454,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682244355,
|
||||
conversation_id: 20,
|
||||
},
|
||||
'460': {
|
||||
id: 460,
|
||||
content: 'Hi how are you',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682417926,
|
||||
conversation_id: 20,
|
||||
},
|
||||
'463': {
|
||||
id: 463,
|
||||
content: 'ss',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682490729,
|
||||
conversation_id: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('abort syncing if there is no missing messages ', async () => {
|
||||
const state = {
|
||||
uiFlags: { allMessagesLoaded: false },
|
||||
conversation: {
|
||||
'454': {
|
||||
id: 454,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682244355, // Sunday, 23 April 2023 10:05:55
|
||||
conversation_id: 20,
|
||||
},
|
||||
'463': {
|
||||
id: 463,
|
||||
content: 'ss',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682490729, // Wednesday, 26 April 2023 06:32:09
|
||||
conversation_id: 20,
|
||||
},
|
||||
},
|
||||
lastMessageId: 463,
|
||||
};
|
||||
API.get.mockResolvedValue({
|
||||
data: {
|
||||
payload: [],
|
||||
meta: {
|
||||
contact_last_seen_at: 14664223490,
|
||||
},
|
||||
},
|
||||
});
|
||||
await actions.syncLatestMessages({ state, commit }, {});
|
||||
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,4 +183,72 @@ describe('#mutations', () => {
|
||||
expect(state.conversations).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setMissingMessages', () => {
|
||||
it('sets messages if payload is not empty', () => {
|
||||
const state = {
|
||||
uiFlags: { allMessagesLoaded: false },
|
||||
conversations: {
|
||||
'454': {
|
||||
id: 454,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682432667,
|
||||
conversation_id: 20,
|
||||
},
|
||||
'464': {
|
||||
id: 464,
|
||||
content: 'hey will be back soon',
|
||||
message_type: 3,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682490729,
|
||||
conversation_id: 20,
|
||||
},
|
||||
},
|
||||
};
|
||||
mutations.setMessagesInConversation(state, [
|
||||
{
|
||||
id: 455,
|
||||
content: 'Hey billowing-grass-423 how are you?',
|
||||
message_type: 3,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682432667,
|
||||
conversation_id: 20,
|
||||
},
|
||||
]);
|
||||
expect(state.conversations).toEqual({
|
||||
'454': {
|
||||
id: 454,
|
||||
content: 'hi',
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682432667,
|
||||
conversation_id: 20,
|
||||
},
|
||||
'455': {
|
||||
id: 455,
|
||||
content: 'Hey billowing-grass-423 how are you?',
|
||||
message_type: 3,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682432667,
|
||||
conversation_id: 20,
|
||||
},
|
||||
'464': {
|
||||
id: 464,
|
||||
content: 'hey will be back soon',
|
||||
message_type: 3,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
created_at: 1682490729,
|
||||
conversation_id: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
process_email_for_channel(channel)
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS
|
||||
channel.authorization_error!
|
||||
rescue EOFError => e
|
||||
rescue EOFError, OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.error e
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
@@ -33,23 +33,32 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
end
|
||||
|
||||
def fetch_mail_for_channel(channel)
|
||||
# TODO: rather than setting this as default method for all mail objects, lets if can do new mail object
|
||||
# using Mail.retriever_method.new(params)
|
||||
Mail.defaults do
|
||||
retriever_method :imap, address: channel.imap_address,
|
||||
port: channel.imap_port,
|
||||
user_name: channel.imap_login,
|
||||
password: channel.imap_password,
|
||||
enable_ssl: channel.imap_enable_ssl
|
||||
end
|
||||
imap_inbox = authenticated_imap_inbox(channel, channel.imap_password, 'PLAIN')
|
||||
last_email_time = DateTime.parse(Net::IMAP.format_datetime(last_email_time(channel)))
|
||||
|
||||
Mail.find(what: :last, count: 10, order: :asc).each do |inbound_mail|
|
||||
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
|
||||
received_mails(imap_inbox).each do |message_id|
|
||||
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
next if email_already_present?(channel, inbound_mail, last_email_time)
|
||||
|
||||
process_mail(inbound_mail, channel)
|
||||
end
|
||||
end
|
||||
|
||||
def email_already_present?(channel, inbound_mail, last_email_time)
|
||||
processed_email?(inbound_mail, last_email_time) || channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
|
||||
end
|
||||
|
||||
def received_mails(imap_inbox)
|
||||
imap_inbox.search(['BEFORE', tomorrow, 'SINCE', yesterday])
|
||||
end
|
||||
|
||||
def processed_email?(current_email, last_email_time)
|
||||
return current_email.date < last_email_time if current_email.date.present?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def fetch_mail_for_ms_provider(channel)
|
||||
return if channel.provider_config['access_token'].blank?
|
||||
|
||||
@@ -57,14 +66,14 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
|
||||
return unless access_token
|
||||
|
||||
imap = imap_authenticate(channel, access_token)
|
||||
imap_inbox = authenticated_imap_inbox(channel, access_token, 'XOAUTH2')
|
||||
|
||||
process_mails(imap, channel)
|
||||
process_mails(imap_inbox, channel)
|
||||
end
|
||||
|
||||
def process_mails(imap, channel)
|
||||
imap.search(['BEFORE', tomorrow, 'SINCE', yesterday]).each do |message_id|
|
||||
inbound_mail = Mail.read_from_string imap.fetch(message_id, 'RFC822')[0].attr['RFC822']
|
||||
def process_mails(imap_inbox, channel)
|
||||
received_mails(imap_inbox).each do |message_id|
|
||||
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
|
||||
|
||||
@@ -72,13 +81,25 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def imap_authenticate(channel, access_token)
|
||||
def authenticated_imap_inbox(channel, access_token, auth_method)
|
||||
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
|
||||
imap.authenticate('XOAUTH2', channel.imap_login, access_token)
|
||||
imap.authenticate(auth_method, channel.imap_login, access_token)
|
||||
imap.select('INBOX')
|
||||
imap
|
||||
end
|
||||
|
||||
def last_email_time(channel)
|
||||
# we are only checking for emails in last 2 day
|
||||
last_email_incoming_message = channel.inbox.messages.incoming.where('messages.created_at >= ?', 2.days.ago).last
|
||||
if last_email_incoming_message.present?
|
||||
time = last_email_incoming_message.content_attributes['email']['date']
|
||||
time ||= last_email_incoming_message.created_at.to_s
|
||||
end
|
||||
time ||= 1.hour.ago.to_s
|
||||
|
||||
DateTime.parse(time)
|
||||
end
|
||||
|
||||
def yesterday
|
||||
(Time.zone.today - 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class Webhooks::InstagramEventsJob < ApplicationJob
|
||||
|
||||
@entries.each do |entry|
|
||||
entry = entry.with_indifferent_access
|
||||
entry[:messaging].each do |messaging|
|
||||
messages(entry).each do |messaging|
|
||||
send(@event_name, messaging) if event_name(messaging)
|
||||
end
|
||||
end
|
||||
@@ -29,4 +29,8 @@ class Webhooks::InstagramEventsJob < ApplicationJob
|
||||
def message(messaging)
|
||||
::Instagram::MessageText.new(messaging).perform
|
||||
end
|
||||
|
||||
def messages(entry)
|
||||
(entry[:messaging].presence || entry[:standby] || [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ module MailboxHelper
|
||||
def add_attachments_to_message
|
||||
return if @message.blank?
|
||||
|
||||
processed_mail.attachments.each do |mail_attachment|
|
||||
processed_mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS).each do |mail_attachment|
|
||||
attachment = @message.attachments.new(
|
||||
account_id: @conversation.account_id,
|
||||
file_type: 'file'
|
||||
|
||||
@@ -34,7 +34,7 @@ class Account < ApplicationRecord
|
||||
|
||||
validates :name, presence: true
|
||||
validates :auto_resolve_duration, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 999, allow_nil: true }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :domain, length: { maximum: 100 }
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
@@ -149,4 +149,5 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
Account.prepend_mod_with('Account')
|
||||
Account.include_mod_with('EnterpriseAccountConcern')
|
||||
Account.include_mod_with('Audit::Account')
|
||||
|
||||
@@ -2,6 +2,8 @@ class ApplicationRecord < ActiveRecord::Base
|
||||
include Events::Types
|
||||
self.abstract_class = true
|
||||
|
||||
before_validation :validates_column_content_length
|
||||
|
||||
# the models that exposed in email templates through liquid
|
||||
DROPPABLES = %w[Account Channel Conversation Inbox User Message].freeze
|
||||
|
||||
@@ -14,6 +16,31 @@ class ApplicationRecord < ActiveRecord::Base
|
||||
|
||||
private
|
||||
|
||||
# Generic validation for all columns of type string and text
|
||||
# Validates the length of the column to prevent DOS via large payloads
|
||||
# if a custom length validation is already present, skip the validation
|
||||
def validates_column_content_length
|
||||
self.class.columns.each do |column|
|
||||
check_and_validate_content_length(column) if column_of_type_string_or_text?(column)
|
||||
end
|
||||
end
|
||||
|
||||
def column_of_type_string_or_text?(column)
|
||||
%i[string text].include?(column.type)
|
||||
end
|
||||
|
||||
def check_and_validate_content_length(column)
|
||||
length_validator = self.class.validators_on(column.name).find { |v| v.kind == :length }
|
||||
validate_content_length(column) if length_validator.blank?
|
||||
end
|
||||
|
||||
def validate_content_length(column)
|
||||
max_length = column.type == :text ? 20_000 : 255
|
||||
return if self[column.name].nil? || self[column.name].length <= max_length
|
||||
|
||||
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
|
||||
end
|
||||
|
||||
def normalize_empty_string_to_nil(attrs = [])
|
||||
attrs.each do |attr|
|
||||
self[attr] = nil if self[attr].blank?
|
||||
|
||||
@@ -37,7 +37,7 @@ class Attachment < ApplicationRecord
|
||||
belongs_to :message
|
||||
has_one_attached :file
|
||||
validate :acceptable_file
|
||||
|
||||
validates :external_url, length: { maximum: 1000 }
|
||||
enum file_type: [:image, :audio, :video, :file, :location, :fallback, :share, :story_mention, :contact]
|
||||
|
||||
def push_event_data
|
||||
@@ -56,7 +56,7 @@ class Attachment < ApplicationRecord
|
||||
|
||||
# NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases
|
||||
def download_url
|
||||
ActiveStorage::Current.host = Rails.application.routes.default_url_options[:host] if ActiveStorage::Current.host.blank?
|
||||
ActiveStorage::Current.url_options = Rails.application.routes.default_url_options if ActiveStorage::Current.url_options.blank?
|
||||
file.attached? ? file.blob.url : ''
|
||||
end
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# email :string
|
||||
# identifier :string
|
||||
# last_activity_at :datetime
|
||||
# name :string
|
||||
# name :string default("")
|
||||
# phone_number :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -36,7 +36,6 @@ class Contact < ApplicationRecord
|
||||
validates :phone_number,
|
||||
allow_blank: true, uniqueness: { scope: [:account_id] },
|
||||
format: { with: /\+[1-9]\d{1,14}\z/, message: I18n.t('errors.contacts.phone_number.invalid') }
|
||||
validates :name, length: { maximum: 255 }
|
||||
|
||||
belongs_to :account
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
|
||||
@@ -95,6 +95,7 @@ class Conversation < ApplicationRecord
|
||||
has_one :csat_survey_response, dependent: :destroy_async
|
||||
has_many :conversation_participants, dependent: :destroy_async
|
||||
has_many :notifications, as: :primary_actor, dependent: :destroy_async
|
||||
has_many :attachments, through: :messages
|
||||
|
||||
before_save :ensure_snooze_until_reset
|
||||
before_create :mark_conversation_pending_if_bot
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.payload @attachments.map(&:push_event_data)
|
||||
@@ -1,22 +1,27 @@
|
||||
<p>Welcome, <%= @resource.name %>!</p>
|
||||
<p>Hi <%= @resource.name %>,</p>
|
||||
|
||||
<% account_user = @resource&.account_users&.first %>
|
||||
|
||||
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
|
||||
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! </p>
|
||||
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @resource.confirmed? %>
|
||||
<p>You can login to your account through the link below:</p>
|
||||
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
|
||||
<% else %>
|
||||
<p>You can confirm your account email through the link below:</p>
|
||||
<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>
|
||||
<p>Please take a moment and click the link below and activate your account.</p>
|
||||
<% end %>
|
||||
|
||||
|
||||
<% if @resource.unconfirmed_email.present? %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
|
||||
<% elsif @resource.confirmed? %>
|
||||
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
|
||||
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
|
||||
<% elsif account_user&.inviter.present? %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
|
||||
<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>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -21,7 +21,7 @@ By default, the relationship is rendered as a link to the associated object.
|
||||
field.display_associated_resource,
|
||||
super_admin_user_path(field.data),
|
||||
) %>
|
||||
<% elsif valid_action?(:show, field.associated_class) %>
|
||||
<% elsif existing_action?(field.associated_class, :show) %>
|
||||
<%= link_to(
|
||||
field.display_associated_resource,
|
||||
[namespace, field.data],
|
||||
|
||||
@@ -16,7 +16,7 @@ By default, the relationship is rendered as a link to the associated object.
|
||||
%>
|
||||
|
||||
<% if field.data %>
|
||||
<% if valid_action?(:show, field.associated_class) %>
|
||||
<% if existing_action?(field.associated_class, :show) %>
|
||||
<%= link_to(
|
||||
field.display_associated_resource,
|
||||
[namespace, field.data],
|
||||
|
||||
@@ -17,7 +17,7 @@ By default, the relationship is rendered as a link to the associated object.
|
||||
%>
|
||||
|
||||
<% if field.data %>
|
||||
<% if valid_action?(:show, field.data.class) %>
|
||||
<% if existing_action?(field.data.class, :show) %>
|
||||
<%= link_to(
|
||||
field.display_associated_resource,
|
||||
[namespace, field.data],
|
||||
|
||||
@@ -44,8 +44,9 @@ By default, it renders:
|
||||
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
|
||||
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
|
||||
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
|
||||
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>'
|
||||
}
|
||||
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
|
||||
},
|
||||
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
|
||||
};
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -35,9 +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">
|
||||
<img src="<%= @article.author.avatar_url %>" alt="<%= @article.author.display_name %>" class="w-12 h-12 border rounded-full pr-1">
|
||||
<% end %>
|
||||
<div class="pl-1">
|
||||
<div>
|
||||
<h5 class="text-base font-medium text-slate-900 mb-2"><%= @article.author.available_name %></h5>
|
||||
<p class="text-sm font-normal text-slate-700">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: @article.updated_at.strftime("%b %d, %Y")) %>
|
||||
@@ -46,10 +46,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-6xl flex-grow w-full px-8 py-8 mx-auto space-y-12">
|
||||
<article class="space-y-8">
|
||||
<div class="text-slate-800 text-lg max-w-3xl prose break-words">
|
||||
<p><%= @parsed_content %></p>
|
||||
</div>
|
||||
<div class="flex max-w-6xl w-full px-8 mx-auto">
|
||||
<article id="cw-article-content" class="flex-grow flex-2 py-12 mx-auto text-slate-800 text-lg max-w-3xl prose break-words">
|
||||
<%= @parsed_content %>
|
||||
</article>
|
||||
<div class="flex-1" id="cw-hc-toc"></div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ as well as a link to its edit page.
|
||||
t("administrate.actions.edit_resource", name: page.page_title),
|
||||
[:edit, namespace, page.resource],
|
||||
class: "button",
|
||||
) if valid_action?(:edit) && show_action?(:edit, page.resource) %>
|
||||
) if accessible_action?(page.resource, :edit) %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ to display a collection of resources in an HTML table.
|
||||
<% end %>
|
||||
</th>
|
||||
<% end %>
|
||||
<% [valid_action?(:edit, collection_presenter.resource_name),
|
||||
valid_action?(:destroy, collection_presenter.resource_name)].count(true).times do %>
|
||||
<% [existing_action?(collection_presenter.resource_name, :edit),
|
||||
existing_action?(collection_presenter.resource_name, :destroy)].count(true).times do %>
|
||||
<th scope="col"></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
@@ -56,13 +56,13 @@ to display a collection of resources in an HTML table.
|
||||
<% resources.each do |resource| %>
|
||||
<tr class="js-table-row"
|
||||
tabindex="0"
|
||||
<% if valid_action? :show, collection_presenter.resource_name %>
|
||||
<% if existing_action? collection_presenter.resource_name, :show %>
|
||||
<%= %(role=link data-url=#{polymorphic_path([namespace, resource])}) %>
|
||||
<% end %>
|
||||
>
|
||||
<% collection_presenter.attributes_for(resource).each do |attribute| %>
|
||||
<td class="cell-data cell-data--<%= attribute.html_class %>">
|
||||
<% if show_action? :show, resource -%>
|
||||
<% if authorized_action? resource, :show -%>
|
||||
<a href="<%= polymorphic_path([namespace, resource]) -%>"
|
||||
class="action-show"
|
||||
>
|
||||
@@ -72,22 +72,22 @@ to display a collection of resources in an HTML table.
|
||||
</td>
|
||||
<% end %>
|
||||
|
||||
<% if valid_action? :edit, collection_presenter.resource_name %>
|
||||
<% if existing_action? collection_presenter.resource_name, :edit %>
|
||||
<td><%= link_to(
|
||||
t("administrate.actions.edit"),
|
||||
[:edit, namespace, resource],
|
||||
class: "action-edit",
|
||||
) if show_action? :edit, resource%></td>
|
||||
) if authorized_action? resource, :edit%></td>
|
||||
<% end %>
|
||||
|
||||
<% if valid_action? :destroy, collection_presenter.resource_name %>
|
||||
<% if existing_action? collection_presenter.resource_name, :destroy %>
|
||||
<td><%= link_to(
|
||||
t("administrate.actions.destroy"),
|
||||
[namespace, resource],
|
||||
class: "text-color-red",
|
||||
method: :delete,
|
||||
data: { confirm: t("administrate.actions.confirm") }
|
||||
) if show_action? :destroy, resource %></td>
|
||||
) if authorized_action? resource, :destroy %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -46,7 +46,7 @@ as defined by the routes in the `admin/` namespace
|
||||
<%= link_to(
|
||||
display_resource_name(resource),
|
||||
resource_index_route(resource)
|
||||
) if valid_action? :index, resource %>
|
||||
) if existing_action? resource, :index %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ It renders the `_table` partial to display details about the resources.
|
||||
),
|
||||
[:new, namespace, page.resource_path.to_sym],
|
||||
class: "button",
|
||||
) if valid_action?(:new) && show_action?(:new, new_resource) %>
|
||||
) if accessible_action?(new_resource, :new) %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ to display a collection of resources in an HTML table.
|
||||
<% end %>
|
||||
</th>
|
||||
<% end %>
|
||||
<% [valid_action?(:edit, collection_presenter.resource_name),
|
||||
valid_action?(:destroy, collection_presenter.resource_name)].count(true).times do %>
|
||||
<% [existing_action?(collection_presenter.resource_name, :edit),
|
||||
existing_action?(collection_presenter.resource_name, :destroy)].count(true).times do %>
|
||||
<th scope="col"></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
@@ -56,13 +56,13 @@ to display a collection of resources in an HTML table.
|
||||
<% resources.each do |resource| %>
|
||||
<tr class="js-table-row"
|
||||
tabindex="0"
|
||||
<% if valid_action? :show, collection_presenter.resource_name %>
|
||||
<% if existing_action? collection_presenter.resource_name, :show %>
|
||||
<%= %(role=link data-url=#{polymorphic_path([namespace, resource.becomes(User)])}) %>
|
||||
<% end %>
|
||||
>
|
||||
<% collection_presenter.attributes_for(resource).each do |attribute| %>
|
||||
<td class="cell-data cell-data--<%= attribute.html_class %>">
|
||||
<% if show_action? :show, resource -%>
|
||||
<% if authorized_action? resource, :show -%>
|
||||
<a href="<%= polymorphic_path([namespace, resource.becomes(User)]) -%>"
|
||||
class="action-show"
|
||||
>
|
||||
@@ -72,22 +72,22 @@ to display a collection of resources in an HTML table.
|
||||
</td>
|
||||
<% end %>
|
||||
|
||||
<% if valid_action? :edit, collection_presenter.resource_name %>
|
||||
<% if existing_action? collection_presenter.resource_name, :edit %>
|
||||
<td><%= link_to(
|
||||
t("administrate.actions.edit"),
|
||||
[:edit, namespace, resource.becomes(User)],
|
||||
class: "action-edit",
|
||||
) if show_action? :edit, resource%></td>
|
||||
) if authorized_action? resource, :edit%></td>
|
||||
<% end %>
|
||||
|
||||
<% if valid_action? :destroy, collection_presenter.resource_name %>
|
||||
<% if existing_action? collection_presenter.resource_name, :destroy %>
|
||||
<td><%= link_to(
|
||||
t("administrate.actions.destroy"),
|
||||
[namespace, resource.becomes(User)],
|
||||
class: "text-color-red",
|
||||
method: :delete,
|
||||
data: { confirm: t("administrate.actions.confirm") }
|
||||
) if show_action? :destroy, resource %></td>
|
||||
) if authorized_action? resource, :destroy %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -28,7 +28,7 @@ as well as a link to its edit page.
|
||||
t("administrate.actions.edit_resource", name: page.page_title),
|
||||
[:edit, namespace, page.resource],
|
||||
class: "button",
|
||||
) if valid_action?(:edit) && show_action?(:edit, page.resource) %>
|
||||
) if accessible_action?(:edit, page.resource) %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
workingHours: <%= @web_widget.inbox.working_hours.to_json.html_safe %>,
|
||||
outOfOfficeMessage: <%= @web_widget.inbox.out_of_office_message.to_json.html_safe %>,
|
||||
utcOffset: '<%= ActiveSupport::TimeZone[@web_widget.inbox.timezone].now.formatted_offset %>',
|
||||
timezone: '<%= @web_widget.inbox.timezone %>',
|
||||
allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>,
|
||||
disableBranding: <%= @web_widget.inbox.account.feature_enabled?('disable_branding') %>
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '2.16.1'
|
||||
version: '2.17.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -38,7 +38,8 @@ LANGUAGES_CONFIG = {
|
||||
33 => { name: 'украї́нська мо́ва (uk)', iso_639_3_code: 'ukr', iso_639_1_code: 'uk', enabled: true },
|
||||
34 => { name: 'ภาษาไทย (th)', iso_639_3_code: 'tha', iso_639_1_code: 'th', enabled: true },
|
||||
35 => { name: 'latviešu valoda (lv)', iso_639_3_code: 'lav', iso_639_1_code: 'lv', enabled: true },
|
||||
36 => { name: 'íslenska (is)', iso_639_3_code: 'isl', iso_639_1_code: 'is', enabled: true }
|
||||
36 => { name: 'íslenska (is)', iso_639_3_code: 'isl', iso_639_1_code: 'is', enabled: true },
|
||||
37 => { name: 'עִברִית (he)', iso_639_3_code: 'heb', iso_639_1_code: 'he', enabled: true }
|
||||
}.filter { |_key, val| val[:enabled] }.freeze
|
||||
|
||||
Rails.configuration.i18n.available_locales = LANGUAGES_CONFIG.map { |_index, lang| lang[:iso_639_1_code].to_sym }
|
||||
|
||||
@@ -196,6 +196,7 @@ en:
|
||||
empty_placeholder: No results found.
|
||||
loading_placeholder: Searching...
|
||||
results_title: Search results
|
||||
toc_header: 'On this page'
|
||||
hero:
|
||||
sub_title: Search for the articles here or browse the categories below.
|
||||
common:
|
||||
|
||||
@@ -64,6 +64,7 @@ Rails.application.routes.draw do
|
||||
post :execute, on: :member
|
||||
post :attach_file, on: :collection
|
||||
end
|
||||
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
|
||||
namespace :channels do
|
||||
@@ -96,6 +97,7 @@ Rails.application.routes.draw do
|
||||
post :update_last_seen
|
||||
post :unread
|
||||
post :custom_attributes
|
||||
get :attachments
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateSlaPolicies < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :sla_policies do |t|
|
||||
t.string :name, null: false
|
||||
t.float :frt_threshold, default: nil
|
||||
t.float :rt_threshold, default: nil
|
||||
t.boolean 'only_during_business_hours', default: false
|
||||
t.references :account, index: true, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class SetDefaultEmptyStringForContactName < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
change_column_default :contacts, :name, from: nil, to: ''
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
class ArticleKeyConverter
|
||||
def initialize(article)
|
||||
@article = article
|
||||
end
|
||||
|
||||
def process
|
||||
new_content = replace(@article.content)
|
||||
@article.update(content: new_content)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def convert_key(id)
|
||||
verifier_name = 'ActiveStorage'
|
||||
key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base, iterations: 1000,
|
||||
hash_digest_class: OpenSSL::Digest::SHA1)
|
||||
key_generator = ActiveSupport::CachingKeyGenerator.new(key_generator)
|
||||
secret = key_generator.generate_key(verifier_name.to_s)
|
||||
verifier = ActiveSupport::MessageVerifier.new(secret)
|
||||
|
||||
begin
|
||||
ActiveStorage::Blob.find(verifier.verify(id, purpose: :blob_id))
|
||||
.try(:signed_id)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def replace(text)
|
||||
keys = get_keys(text)
|
||||
keys.each do |key|
|
||||
new_key = convert_key(key)
|
||||
text = text.gsub(key, new_key) if new_key
|
||||
end
|
||||
text
|
||||
end
|
||||
|
||||
def get_keys(text)
|
||||
uris = text.scan(URI::DEFAULT_PARSER.make_regexp).flatten.select do |x|
|
||||
x.to_s.include?('rails/active_storage')
|
||||
end
|
||||
|
||||
uris.map { |x| x.split('/')[-2] }
|
||||
end
|
||||
end
|
||||
|
||||
class UpdateArticleImageKeys < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
# Iterate through all articles
|
||||
Article.find_each do |article|
|
||||
# Run the ArticleKeyConverter for each one
|
||||
ArticleKeyConverter.new(article).process
|
||||
end
|
||||
end
|
||||
end
|
||||
+13
-2
@@ -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_10_060828) 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"
|
||||
@@ -394,7 +394,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_10_060828) do
|
||||
end
|
||||
|
||||
create_table "contacts", id: :serial, force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.string "name", default: ""
|
||||
t.string "email"
|
||||
t.string "phone_number"
|
||||
t.integer "account_id", null: false
|
||||
@@ -804,6 +804,17 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_10_060828) do
|
||||
t.index ["user_id"], name: "index_reporting_events_on_user_id"
|
||||
end
|
||||
|
||||
create_table "sla_policies", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.float "frt_threshold"
|
||||
t.float "rt_threshold"
|
||||
t.boolean "only_during_business_hours", default: false
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_sla_policies_on_account_id"
|
||||
end
|
||||
|
||||
create_table "taggings", id: :serial, force: :cascade do |t|
|
||||
t.integer "tag_id"
|
||||
t.string "taggable_type"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Description: Install and manage a Chatwoot installation.
|
||||
# OS: Ubuntu 20.04 LTS
|
||||
# Script Version: 2.2.0
|
||||
# Script Version: 2.3.0
|
||||
# Run this script as root
|
||||
|
||||
set -eu -o errexit -o pipefail -o noclobber -o nounset
|
||||
@@ -19,7 +19,7 @@ fi
|
||||
# option --output/-o requires 1 argument
|
||||
LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,webserver,version
|
||||
OPTIONS=cdhiI:l:rsuwv
|
||||
CWCTL_VERSION="2.2.0"
|
||||
CWCTL_VERSION="2.3.0"
|
||||
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
|
||||
|
||||
# if user does not specify an option
|
||||
@@ -175,6 +175,8 @@ function install_dependencies() {
|
||||
curl -sL https://deb.nodesource.com/setup_16.x | bash -
|
||||
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
|
||||
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
||||
apt update
|
||||
|
||||
apt install -y \
|
||||
@@ -183,7 +185,8 @@ function install_dependencies() {
|
||||
libssl-dev libyaml-dev libreadline-dev gnupg2 \
|
||||
postgresql-client redis-tools \
|
||||
nodejs yarn patch ruby-dev zlib1g-dev liblzma-dev \
|
||||
libgmp-dev libncurses5-dev libffi-dev libgdbm6 libgdbm-dev sudo
|
||||
libgmp-dev libncurses5-dev libffi-dev libgdbm6 libgdbm-dev sudo \
|
||||
libvips
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
@@ -708,6 +711,26 @@ function upgrade_prereq() {
|
||||
EOF
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# Update redis to v7+ for Rails 7 support(-u/--upgrade)
|
||||
# and install libvips for image processing support in Rails 7
|
||||
# Globals:
|
||||
# None
|
||||
# Arguments:
|
||||
# None
|
||||
# Outputs:
|
||||
# None
|
||||
##############################################################################
|
||||
function upgrade_redis() {
|
||||
echo "Upgrading Redis to v7+ for Rails 7 support(Chatwoot v2.17+)"
|
||||
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
||||
apt update -y
|
||||
apt upgrade redis-server -y
|
||||
apt install libvips -y
|
||||
}
|
||||
|
||||
|
||||
##############################################################################
|
||||
# Upgrade an existing installation to latest stable version(-u/--upgrade)
|
||||
# Globals:
|
||||
@@ -722,6 +745,7 @@ function upgrade() {
|
||||
echo "Upgrading Chatwoot to v$CW_VERSION"
|
||||
sleep 3
|
||||
upgrade_prereq
|
||||
upgrade_redis
|
||||
sudo -i -u chatwoot << "EOF"
|
||||
|
||||
# Navigate to the Chatwoot directory
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
# module Enterprise::Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_audit
|
||||
before_action :prepend_view_paths
|
||||
|
||||
RESULTS_PER_PAGE = 15
|
||||
|
||||
# Prepend the view path to the enterprise/app/views won't be available by default
|
||||
def prepend_view_paths
|
||||
prepend_view_path 'enterprise/app/views/'
|
||||
end
|
||||
|
||||
def show
|
||||
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
|
||||
@current_page = @audit_logs.current_page
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class Api::V1::Accounts::EnterpriseAccountsController < Api::V1::Accounts::BaseController
|
||||
before_action :prepend_view_paths
|
||||
|
||||
# Prepend the view path to the enterprise/app/views won't be available by default
|
||||
def prepend_view_paths
|
||||
prepend_view_path 'enterprise/app/views/'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :fetch_sla, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@sla_policies = Current.account.sla_policies
|
||||
end
|
||||
|
||||
def create
|
||||
@sla_policy = Current.account.sla_policies.create!(permitted_params)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@sla_policy.update!(permitted_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@sla_policy.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:sla_policy).permit(:name, :rt_threshold, :frt_threshold, :only_during_business_hours)
|
||||
end
|
||||
|
||||
def fetch_sla
|
||||
@sla_policy = Current.account.sla_policies.find_by(id: params[:id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::EnterpriseAccountConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :sla_policies, dependent: :destroy_async
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: sla_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# frt_threshold :float
|
||||
# name :string not null
|
||||
# only_during_business_hours :boolean default(FALSE)
|
||||
# rt_threshold :float
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_sla_policies_on_account_id (account_id)
|
||||
#
|
||||
class SlaPolicy < ApplicationRecord
|
||||
belongs_to :account
|
||||
validates :name, presence: true
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class SlaPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/sla_policy', formats: [:json], sla_policy: @sla_policy
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
json.payload do
|
||||
json.array! @sla_policies do |sla_policy|
|
||||
json.partial! 'api/v1/models/sla_policy', formats: [:json], sla_policy: sla_policy
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/sla_policy', formats: [:json], sla_policy: @sla_policy
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/sla_policy', formats: [:json], sla_policy: @sla_policy
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
json.id sla_policy.id
|
||||
json.name sla_policy.name
|
||||
json.frt_threshold sla_policy.frt_threshold
|
||||
json.rt_threshold sla_policy.rt_threshold
|
||||
json.only_during_business_hours sla_policy.only_during_business_hours
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "2.16.1",
|
||||
"version": "2.17.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@rails/webpacker": "5.4.4",
|
||||
"@sentry/tracing": "^6.19.7",
|
||||
"@sentry/vue": "^6.19.7",
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@tailwindcss/typography": "0.2.0",
|
||||
"activestorage": "^5.2.6",
|
||||
"axios": "^0.21.2",
|
||||
@@ -133,13 +134,6 @@
|
||||
"pre-push": "sh bin/validate_push"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"collectCoverage": true,
|
||||
"coverageReporters": [
|
||||
"lcov",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"app/**/*.{js,vue}": [
|
||||
"eslint --fix",
|
||||
|
||||
@@ -17,6 +17,14 @@ describe ::Messages::Instagram::MessageBuilder do
|
||||
let(:fb_object) { double }
|
||||
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
|
||||
let(:conversation) do
|
||||
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id,
|
||||
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
|
||||
end
|
||||
let(:message) do
|
||||
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
|
||||
source_id: 'message-id-1')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates contact and message for the facebook inbox' do
|
||||
@@ -45,6 +53,31 @@ describe ::Messages::Instagram::MessageBuilder do
|
||||
expect(message.content).to eq('This is the first message from the customer')
|
||||
end
|
||||
|
||||
it 'discard echo message already sent by chatwoot' do
|
||||
message
|
||||
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{
|
||||
name: 'Jane',
|
||||
id: 'Sender-id-1',
|
||||
account_id: instagram_inbox.account_id,
|
||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
messaging = dm_params[:entry][0]['messaging'][0]
|
||||
contact_inbox
|
||||
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
|
||||
|
||||
instagram_inbox.reload
|
||||
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
end
|
||||
|
||||
it 'creates message with for reply with story id' do
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
|
||||
@@ -731,4 +731,53 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations/:id/attachments' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
create(:message, :with_attachment, conversation: conversation, account: account, inbox: conversation.inbox, message_type: 'incoming')
|
||||
end
|
||||
|
||||
it 'does not return the attachments if you do not have access to it' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'return the attachments if you are an administrator' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = JSON.parse(response.body)
|
||||
expect(response_body['payload'].first['file_type']).to eq('image')
|
||||
end
|
||||
|
||||
it 'return the attachments if you are an agent with access to inbox' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = JSON.parse(response.body)
|
||||
expect(response_body['payload'].length).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise SLA API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:sla_policy, account: account, name: 'SLA 1')
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns all slas in the account' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'][0]).to include('name' => 'SLA 1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns slas in the account' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'][0]).to include('name' => 'SLA 1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'shows the sla' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => sla_policy.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'shows the sla details' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => sla_policy.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:valid_params) do
|
||||
{ sla_policy: { name: 'SLA 2',
|
||||
frt_threshold: 1000,
|
||||
rt_threshold: 1000,
|
||||
only_during_business_hours: false } }
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'creates the sla_policy' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies", params: valid_params,
|
||||
headers: administrator.create_new_auth_token
|
||||
end.to change(SlaPolicy, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => 'SLA 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT #update' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'updates the sla_policy' do
|
||||
put "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
params: { sla_policy: { name: 'SLA 2' } },
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => 'SLA 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
params: { sla_policy: { name: 'SLA 2' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'deletes the sla_policy' do
|
||||
delete "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(SlaPolicy.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Account do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
describe 'sla_policies' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
it 'returns associated sla policies' do
|
||||
expect(account.sla_policies).to eq([sla_policy])
|
||||
end
|
||||
|
||||
it 'deletes associated sla policies' do
|
||||
perform_enqueued_jobs do
|
||||
account.destroy!
|
||||
end
|
||||
expect { sla_policy.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'usage_limits' do
|
||||
before do
|
||||
create(:installation_config, name: 'ACCOUNT_AGENTS_LIMIT', value: 20)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SlaPolicy, type: :model do
|
||||
include ActiveJob::TestHelper
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
it 'creates valid sla policy object' do
|
||||
sla_policy = create(:sla_policy)
|
||||
expect(sla_policy.name).to eq 'sla_1'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,6 +26,33 @@ FactoryBot.define do
|
||||
initialize_with { attributes }
|
||||
end
|
||||
|
||||
factory :instagram_message_standby_event, class: Hash do
|
||||
entry do
|
||||
[
|
||||
{
|
||||
'time': '2021-09-08T06:34:04+0000',
|
||||
'id': 'instagram-message-id-123',
|
||||
'standby': [
|
||||
{
|
||||
'sender': {
|
||||
'id': 'Sender-id-1'
|
||||
},
|
||||
'recipient': {
|
||||
'id': 'chatwoot-app-user-id-1'
|
||||
},
|
||||
'timestamp': '2021-09-08T06:34:04+0000',
|
||||
'message': {
|
||||
'mid': 'message-id-1',
|
||||
'text': 'This is the first standby message from the customer, after 24 hours.'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
initialize_with { attributes }
|
||||
end
|
||||
|
||||
factory :instagram_story_reply_event, class: Hash do
|
||||
entry do
|
||||
[
|
||||
|
||||
@@ -20,6 +20,13 @@ FactoryBot.define do
|
||||
end
|
||||
end
|
||||
|
||||
trait :with_attachment do
|
||||
after(:build) do |message|
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
|
||||
end
|
||||
end
|
||||
|
||||
after(:build) do |message|
|
||||
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
|
||||
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FactoryBot.define do
|
||||
factory :sla_policy do
|
||||
account
|
||||
name { 'sla_1' }
|
||||
rt_threshold { 1000 }
|
||||
frt_threshold { 2000 }
|
||||
only_during_business_hours { false }
|
||||
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