Merge branch 'develop' into feature/cw-1605

This commit is contained in:
Shivam Mishra
2023-05-18 17:51:31 +05:30
committed by GitHub
112 changed files with 2787 additions and 215 deletions
+1 -1
View File
@@ -1 +1 @@
2.2.0
2.17.0
+1 -1
View File
@@ -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
+19 -4
View File
@@ -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',
});
+2
View File
@@ -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'
);
@@ -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', () => {
+2 -28
View File
@@ -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);
+22
View File
@@ -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>
+75 -9
View File
@@ -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();
}
},
};
+2 -2
View File
@@ -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');
});
});
+2
View File
@@ -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,
+2 -2
View File
@@ -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 });
};
+2 -2
View File
@@ -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');
+2
View File
@@ -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,
+11 -1
View File
@@ -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,
},
});
});
});
});
+40 -19
View File
@@ -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
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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'
+2 -1
View 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')
+27
View File
@@ -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?
+2 -2
View File
@@ -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
+1 -2
View File
@@ -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
+1
View File
@@ -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 %>
+1 -1
View File
@@ -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],
+1 -1
View File
@@ -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],
+1 -1
View File
@@ -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],
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -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 %>
+1 -1
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '2.16.1'
version: '2.17.0'
development:
<<: *shared
+2 -1
View File
@@ -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 }
+1
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_05_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"
+27 -3
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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)
+22
View File
@@ -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
[
+7
View File
@@ -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)
+9
View File
@@ -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