Compare commits

...
Author SHA1 Message Date
Muhsin Keloth 70fd1e27dc chore: add @chatwoot/ninja-keys 2024-02-21 11:57:22 +05:30
Sivin VargheseandGitHub a2f9cd6e38 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-21 08:28:31 +05:30
Sivin VargheseandGitHub 758c4bd361 Merge branch 'develop' into feat/CW-3059 2024-02-21 08:28:23 +05:30
Vishnu NarayananandGitHub d53097f77d fix: Raise error if email to_header is invalid (#8688) 2024-02-20 17:03:39 -08:00
9911c5dc12 chore: Hide banners on onboarding view (#8934)
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-02-20 16:12:51 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
19aef3e94b chore(deps): bump ip from 1.1.5 to 1.1.9 (#8976)
Bumps [ip](https://github.com/indutny/node-ip) from 1.1.5 to 1.1.9.
- [Commits](https://github.com/indutny/node-ip/compare/v1.1.5...v1.1.9)

---
updated-dependencies:
- dependency-name: ip
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-20 16:11:02 -08:00
23230e0143 feat: sla-7 ensure applied_sla uniqueness (#8938)
* feat: refactor fetching sla in action service

* chore: modify spec

* chore: ensure applied_sla uniqueness

* chore: review fixes

* feat: add unique index on applied_sla

* chore: fix spec

* chore: add new specs to improve coverage

* chore: improve spec

* Update spec/enterprise/services/enterprise/action_service_spec.rb

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-20 21:59:49 +05:30
Vishnu NarayananandGitHub f92cea144c feat(perf): sla-9 improve perf of TriggerSlasForAccountsJob (#8953)
* feat: improve perf of TriggerSlasForAccountsJob
2024-02-20 21:54:37 +05:30
Sivin VargheseandGitHub 4a48569ec4 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-20 21:52:13 +05:30
Sivin VargheseandGitHub 20c609a615 Merge branch 'develop' into feat/CW-3059 2024-02-20 21:50:58 +05:30
Muhsin KelothandGitHub e6cf8c39b7 feat: Update last_snoozed_at after the un-snooze notification (#8943) 2024-02-20 18:55:39 +05:30
Muhsin KelothandGitHub c5c0845151 feat: Add labels, status and priority in notification push_event_data (#8972) 2024-02-20 17:17:25 +05:30
5d9fb55370 feat: Export contact improvements (#8895)
This pull request enhances the export contacts feature by adding a confirmation step before exporting. Previously, clicking the export button would trigger the export action without confirmation.

Additionally, it ensures that only the intended recipient receives the export email, addressing the previous behaviour where all administrators received it.

Fixes: #8504

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-20 17:11:03 +05:30
721a2f5052 feat: API changes to support multi step user signup (#8933)
-API Changes to support the new onboarding flow 

Co-authored-by: Sojan <sojan@pepalo.com>
2024-02-20 16:48:51 +05:30
7320957405 chore: Add facebook_api_version to global config (#8965)
- Ability to configure facebook_api_version from global config

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-20 15:53:29 +05:30
Sivin VargheseandGitHub 010998d541 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-20 09:22:42 +05:30
Sivin VargheseandGitHub 4b88ae906d Merge branch 'develop' into feat/CW-3059 2024-02-19 18:29:51 +05:30
Sojan e4d072c79c Merge branch 'release/3.6.0' into develop 2024-02-19 15:59:26 +05:30
Sojan 71ee10c889 Bump version to 3.6.0 2024-02-19 15:58:21 +05:30
Sojan JoseandGitHub 978a8a4cb2 fix: support_email and inbound_email_domain returning empty string (#8963)
chore: Fix for inbound email domain being nil
2024-02-19 15:43:35 +05:30
Sivin VargheseandGitHub c1ce9fe215 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-19 11:25:49 +05:30
iamsivin 94b86a1244 chore: Review fix 2024-02-19 11:25:14 +05:30
Sivin VargheseandGitHub ac947eedf5 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-18 22:51:08 +05:30
Muhsin KelothandGitHub 6fb3e96bc9 Merge branch 'develop' into feat/CW-3059 2024-02-17 13:59:43 +05:30
cd06b2b337 fix: Inbox view Read/Snoozed display filters (#8907)
* fix: Notification filters

* Update notification_finder.rb

* Update notification_finder.rb

* Update notification_finder.rb

* fix: spec

* fix: specs

* Update notification_finder.rb

* fix: add more fixes

* Update notification_finder.rb

* fix: specs

* chore: better comments

* chore: removed filtering

* chore: refactoring

* fix: review fixes

* fix: API call

* chore: Minor fix

* Rename spec

* Fix params getting undefined

* Fix finder

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-17 13:59:25 +05:30
6eb06377cc feat: Add textarea component to use in forms (#8932)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-16 12:19:08 -08:00
Sojan JoseandGitHub 94892e7168 chore: Fix flaky spec relying on unguaranteed order (#8956) 2024-02-16 12:11:39 -08:00
Sivin VargheseandGitHub 72f64836a5 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-16 23:47:53 +05:30
Sivin VargheseandGitHub 33d93310d2 Merge branch 'develop' into feat/CW-3059 2024-02-16 23:47:39 +05:30
Muhsin KelothandGitHub fe744abe26 fix: Mark notification as unread after the un-snooze (#8917) 2024-02-16 17:30:54 +05:30
Muhsin KelothandGitHub 9ec4ec94ed Merge branch 'develop' into feat/CW-3059 2024-02-16 17:12:21 +05:30
Nithin David ThomasandGitHub a902b49bc5 feat: Adds bulk_invite api for onboarding view (#8931)
- New API for bulk email invite
2024-02-16 17:01:27 +05:30
Sojan JoseandGitHub 9c07b6dd46 chore: Support for updated_within in ConverationFinder (#8947)
- `updated_within' accepts value in seconds and returns all conversations updated in the given period with out pagination. This API will assist in our refetch logic on socket disconnect

ref: #8888
2024-02-16 16:44:55 +05:30
Muhsin KelothandGitHub 3b7b06dbec feat: Add meta to notification model (#8941)
* feat: Add meta and description to notification model

* chore: remove description
2024-02-16 14:38:02 +05:30
Sivin VargheseandGitHub 04e829e639 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-16 10:55:12 +05:30
Sivin VargheseandGitHub 69182f748b Merge branch 'develop' into feat/CW-3059 2024-02-16 10:54:46 +05:30
Sivin VargheseandGitHub 597a4164e8 fix: Editor hyperlink popup is hidden under modal (#8950) 2024-02-16 08:19:06 +05:30
Sivin VargheseandGitHub b6244c870c Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-15 06:00:22 +05:30
Sivin VargheseandGitHub 9d063b730b Merge branch 'develop' into feat/CW-3059 2024-02-15 05:59:55 +05:30
Nithin David ThomasandGitHub 678c00f254 fix: Removes default slot for right of label (#8936) 2024-02-14 10:48:54 -08:00
Sivin VargheseandGitHub 4146da5a6d Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-14 16:44:20 +05:30
Sivin VargheseandGitHub b53730bb73 Merge branch 'develop' into feat/CW-3059 2024-02-14 16:44:08 +05:30
Sivin VargheseandGitHub 75b57d6a0a Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-14 09:34:24 +05:30
iamsivin e3cd3283a0 chore: Minor fix 2024-02-14 09:31:32 +05:30
iamsivin 554395b6c9 feat: Snooze inbox item from context menu 2024-02-13 22:33:59 +05:30
iamsivin 8b2f656372 chore: Clean up 2024-02-13 22:32:43 +05:30
Sivin VargheseandGitHub b4686ec07a Merge branch 'develop' into feat/CW-3059 2024-02-13 19:07:48 +05:30
Sivin VargheseandGitHub 4d995b16f0 Merge branch 'develop' into feat/CW-3059 2024-02-13 16:54:01 +05:30
iamsivin 8de4911fb3 chore: Route review fix 2024-02-13 16:53:41 +05:30
iamsivin a870d1c8a2 chore: Minor UI fixes 2024-02-13 14:15:18 +05:30
iamsivin ab0ab9b032 chore: Fix small screens 2024-02-13 13:49:58 +05:30
iamsivin ac4ce529bf chore: Minor fix 2024-02-13 13:06:06 +05:30
iamsivin f19474c681 chore: Minor fix 2024-02-13 13:05:23 +05:30
Sivin VargheseandGitHub 7359f10be2 Delete app/javascript/shared/mixins/specs/resizeListenerMixin.spec.js 2024-02-13 13:04:36 +05:30
Sivin VargheseandGitHub 778dbaf812 Delete app/javascript/shared/mixins/resizeListenerMixin.js 2024-02-13 13:04:21 +05:30
Sivin VargheseandGitHub faa7a5f585 Merge branch 'develop' into feat/CW-3059 2024-02-13 10:25:59 +05:30
Sivin VargheseandGitHub eaddb253a5 Merge branch 'develop' into feat/CW-3059 2024-02-13 08:55:12 +05:30
iamsivin 8d200b8a99 chore: Review fix 2024-02-13 08:54:56 +05:30
Sivin VargheseandGitHub 34aef51064 Merge branch 'develop' into feat/CW-3059 2024-02-13 06:34:46 +05:30
iamsivin 139b0d8a4c chore: Remove loader 2024-02-12 22:42:27 +05:30
Sivin VargheseandGitHub 440e357c84 Merge branch 'develop' into feat/CW-3059 2024-02-12 20:56:33 +05:30
Sivin VargheseandGitHub 58959d42f5 Merge branch 'develop' into feat/CW-3059 2024-02-12 18:03:22 +05:30
iamsivin 01264d45c2 chore: Clean up 2024-02-12 13:18:53 +05:30
Sivin VargheseandGitHub 8548f6e00b Merge branch 'develop' into feat/CW-3059 2024-02-12 13:07:13 +05:30
iamsivin 0763cc477f chore: Add snooze 2024-02-12 13:05:47 +05:30
Sivin VargheseandGitHub 2fc5c6f236 Merge branch 'develop' into feat/CW-3059 2024-02-12 08:29:40 +05:30
iamsivin 5935a4ff6c chore: Fix codeclimate 2024-02-09 16:18:25 +05:30
iamsivin 43de19f685 chore: Inbox view enhancements 2024-02-09 15:40:12 +05:30
80 changed files with 1162 additions and 676 deletions
@@ -49,6 +49,11 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok
end
@@ -46,7 +46,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
head :ok, message: I18n.t('errors.contacts.export.success')
end
@@ -7,8 +7,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
before_action :set_current_page, only: [:index]
def index
@notifications = notification_finder.notifications
@unread_count = notification_finder.unread_count
@notifications = notification_finder.perform
@count = notification_finder.count
end
@@ -54,7 +54,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def snooze
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil)
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
render json: @notification
end
@@ -46,6 +46,7 @@ class Api::V1::AccountsController < Api::BaseController
def update
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -22,6 +22,7 @@ class Api::V2::AccountsController < Api::BaseController
).perform
fetch_account_and_user_info
update_account_info if @account.present?
if @user
send_auth_headers(@user)
@@ -33,6 +34,18 @@ class Api::V2::AccountsController < Api::BaseController
private
def account_attributes
{
custom_attributes: @account.custom_attributes.merge({ 'onboarding_step' => 'profile_update' })
}
end
def update_account_info
@account.update!(
account_attributes
)
end
def fetch_account_and_user_info; end
def fetch_account
+1 -1
View File
@@ -55,7 +55,7 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0',
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -34,7 +34,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
def allowed_configs
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
+7 -1
View File
@@ -169,6 +169,12 @@ class ConversationFinder
)
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations.send(sort_by, sort_order).page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
@conversations = @conversations.send(sort_by, sort_order)
if params[:updated_within].present?
@conversations.where('conversations.updated_at > ?', Time.zone.now - params[:updated_within].to_i.seconds)
else
@conversations.page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
end
end
end
+14 -10
View File
@@ -10,8 +10,8 @@ class NotificationFinder
set_up
end
def perform
notifications
def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: sort_order)
end
def unread_count
@@ -26,27 +26,31 @@ class NotificationFinder
def set_up
find_all_notifications
filter_by_read_status
filter_by_status
filter_snoozed_notifications
fitler_read_notifications
end
def find_all_notifications
@notifications = current_user.notifications.where(account_id: @current_account.id)
end
def filter_by_status
@notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed'
def filter_snoozed_notifications
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
end
def filter_by_read_status
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
def fitler_read_notifications
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
end
def type_included?(type)
(params[:includes] || []).include?(type)
end
def current_page
params[:page] || 1
end
def notifications
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc)
def sort_order
params[:sort_order] || :desc
end
end
+6 -2
View File
@@ -8,8 +8,8 @@
>
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<pending-email-verification-banner />
<payment-pending-banner />
<pending-email-verification-banner v-if="hideOnOnboardingView" />
<payment-pending-banner v-if="hideOnOnboardingView" />
<upgrade-banner />
</template>
<transition name="fade" mode="out-in">
@@ -38,6 +38,7 @@ import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -79,6 +80,9 @@ export default {
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
},
},
watch: {
+8
View File
@@ -1,9 +1,17 @@
/* global axios */
import ApiClient from './ApiClient';
class Agents extends ApiClient {
constructor() {
super('agents', { accountScoped: true });
}
bulkInvite({ emails }) {
return axios.post(`${this.url}/bulk_create`, {
emails,
});
}
}
export default new Agents();
@@ -7,12 +7,13 @@ class NotificationsAPI extends ApiClient {
}
get({ page, status, type, sortOrder }) {
const includesFilter = [status, type].filter(value => !!value);
return axios.get(this.url, {
params: {
page,
status,
type,
sort_order: sortOrder,
includes: includesFilter,
},
});
}
@@ -10,4 +10,29 @@ describe('#AgentAPI', () => {
expect(agents).toHaveProperty('update');
expect(agents).toHaveProperty('delete');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#bulkInvite', () => {
agents.bulkInvite({ emails: ['hello@hi.com'] });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/agents/bulk_create',
{
emails: ['hello@hi.com'],
}
);
});
});
});
@@ -27,20 +27,36 @@ describe('#NotificationAPI', () => {
window.axios = originalAxios;
});
it('#get', () => {
notificationsAPI.get({
page: 1,
status: 'read',
type: 'Conversation',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
describe('#get', () => {
it('generates the API call if both params are available', () => {
notificationsAPI.get({
page: 1,
status: 'read',
type: 'Conversation',
sort_order: 'desc',
},
status: 'snoozed',
type: 'read',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
sort_order: 'desc',
includes: ['snoozed', 'read'],
},
});
});
it('generates the API call if one of the params are available', () => {
notificationsAPI.get({
page: 1,
type: 'read',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
page: 1,
sort_order: 'desc',
includes: ['read'],
},
});
});
});
@@ -753,7 +753,7 @@ export default {
}
.ProseMirror-prompt {
@apply z-50 bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800;
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
h5 {
@apply dark:text-slate-25 text-slate-800;
@@ -8,7 +8,7 @@
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
@@ -35,7 +35,10 @@
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<empty-state v-else :is-on-expanded-layout="isOnExpandedLayout" />
<empty-state
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div
v-show="showContactPanel"
class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0"
@@ -3,11 +3,10 @@
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
>
<div
class="flex-1 w-full min-w-0 flex flex-col md:flex-row items-center justify-center"
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center gap-4"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
>
<div
class="flex justify-start items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0 w-[inherit]"
>
<div class="flex justify-start items-center min-w-0 w-fit max-w-full">
<back-button
v-if="showBackButton"
:back-url="backButtonUrl"
@@ -20,9 +19,11 @@
:status="currentContact.availability_status"
/>
<div
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-[inherit] overflow-hidden"
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden"
>
<div class="flex items-center flex-row gap-1 m-0 p-0 w-[inherit]">
<div
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full"
>
<woot-button
variant="link"
color-scheme="secondary"
@@ -66,7 +67,7 @@
</div>
</div>
<div
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0 rtl:relative rtl:left-6"
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<more-actions :conversation-id="currentChat.id" />
@@ -87,7 +88,6 @@ import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -128,9 +128,6 @@ export default {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
if (this.isInboxView) {
return frontendURL(`accounts/${accountId}/inbox`);
}
return conversationListPageURL({
accountId,
inboxId,
@@ -79,7 +79,13 @@
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
"ERROR_MESSAGE": "There was an error, please try again"
"ERROR_MESSAGE": "There was an error, please try again",
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
},
"DELETE_NOTE": {
"CONFIRM": {
@@ -4,7 +4,6 @@
"TITLE": "Inbox",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group.",
"NO_NOTIFICATIONS": "No notifications",
"NOTE": "Notifications from all subscribed inboxes",
@@ -14,7 +13,8 @@
},
"ACTION_HEADER": {
"SNOOZE": "Snooze notification",
"DELETE": "Delete notification"
"DELETE": "Delete notification",
"BACK": "Back"
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
@@ -18,3 +18,4 @@ export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
// Inbox Commands (Notifications)
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
export const CMD_TOGGLE_SNOOZE_NOTIFICATION = 'CMD_TOGGLE_SNOOZE_NOTIFICATION';
@@ -6,11 +6,12 @@
hideBreadcrumbs
:placeholder="placeholder"
@selected="onSelected"
@closed="onClosed"
/>
</template>
<script>
import 'ninja-keys';
import '@chatwoot/ninja-keys';
import conversationHotKeysMixin from './conversationHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
@@ -32,6 +33,11 @@ export default {
appearanceHotKeys,
goToCommandHotKeys,
],
data() {
return {
modalObserver: null,
};
},
computed: {
placeholder() {
return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER');
@@ -55,6 +61,10 @@ export default {
routeName() {
this.setCommandbarData();
},
hotKeys() {
// This is to update the command bar data when the hot keys are updated dynamically
this.setCommandbarData();
},
},
mounted() {
this.setCommandbarData();
@@ -72,6 +82,9 @@ export default {
});
this.setCommandbarData();
},
onClosed() {
this.showSnoozeNotificationItems = false;
},
},
};
</script>
@@ -1,6 +1,9 @@
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import {
CMD_SNOOZE_NOTIFICATION,
CMD_TOGGLE_SNOOZE_NOTIFICATION,
} from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
@@ -61,14 +64,28 @@ const INBOX_SNOOZE_EVENTS = [
},
];
export default {
data() {
return {
showSnoozeNotificationItems: false,
};
},
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
if (
isAInboxViewRoute(this.$route.name) ||
this.showSnoozeNotificationItems
) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
mounted() {
bus.$on(CMD_TOGGLE_SNOOZE_NOTIFICATION, this.toggleSnoozeOptions);
},
destroyed() {
bus.$off(CMD_TOGGLE_SNOOZE_NOTIFICATION, this.toggleSnoozeOptions);
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
@@ -77,5 +94,9 @@ export default {
section: this.$t(action.section),
}));
},
toggleSnoozeOptions() {
// Used to show/hide snooze notification items in cmd bar dynamically
this.showSnoozeNotificationItems = true;
},
},
};
@@ -113,6 +113,13 @@
</woot-button>
</div>
</div>
<woot-confirm-modal
ref="confirmExportContactsDialog"
:title="$t('EXPORT_CONTACTS.CONFIRM.TITLE')"
:description="$t('EXPORT_CONTACTS.CONFIRM.MESSAGE')"
:confirm-label="$t('EXPORT_CONTACTS.CONFIRM.YES')"
:cancel-label="$t('EXPORT_CONTACTS.CONFIRM.NO')"
/>
</header>
</template>
@@ -175,8 +182,13 @@ export default {
toggleImport() {
this.$emit('on-toggle-import');
},
submitExport() {
this.$emit('on-export-submit');
async submitExport() {
const ok =
await this.$refs.confirmExportContactsDialog.showConfirmation();
if (ok) {
this.$emit('on-export-submit');
}
},
submitSearch() {
this.$emit('on-search-submit');
@@ -1,28 +1,9 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
const InboxView = () => import('../inbox/InboxView.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/inbox-view'),
name: 'inbox_view',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {
return { inboxId: 0 };
},
},
{
path: frontendURL('accounts/:accountId/inbox-view/:conversation_id'),
name: 'inbox_view_conversation',
roles: ['administrator', 'agent'],
component: InboxView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
},
},
{
path: frontendURL('accounts/:accountId/dashboard'),
name: 'home',
@@ -3,6 +3,7 @@ import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
@@ -16,6 +17,7 @@ export default {
path: frontendURL('accounts/:account_id'),
component: AppContainer,
children: [
...inboxRoutes,
...conversation.routes,
...settings.routes,
...contactRoutes,
@@ -0,0 +1,153 @@
<template>
<div class="flex flex-col h-full w-full md:w-[calc(100%-360px)]">
<inbox-item-header
:total-length="totalNotifications"
:current-index="activeNotificationPosition"
:active-notification="activeNotification"
@next="onClickNext"
@prev="onClickPrev"
/>
<conversation-box
class="h-[calc(100%-56px)]"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="false"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
InboxItemHeader,
ConversationBox,
},
mixins: [uiSettingsMixin],
computed: {
...mapGetters({
notifications: 'notifications/getNotifications',
activeNotificationById: 'notifications/getActiveNotificationById',
currentChat: 'getSelectedChat',
conversationById: 'getConversationById',
uiFlags: 'notifications/getUIFlags',
}),
inboxId() {
return Number(this.$route.params.inboxId);
},
notificationId() {
return Number(this.$route.params.notification_id);
},
activeNotification() {
return this.activeNotificationById(this.notificationId);
},
conversationId() {
return this.activeNotification?.primary_actor?.id;
},
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationPosition() {
const notificationIndex = this.notifications.findIndex(
n => n.id === this.notificationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler(newVal, oldVal) {
if (newVal !== oldVal) {
this.fetchConversationById();
}
},
},
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
if (!this.notificationId || !this.conversationId) return;
this.$store.dispatch('clearSelectedState');
const existingChat = this.findConversation();
if (existingChat) {
this.setActiveChat(existingChat);
return;
}
await this.$store.dispatch('getConversation', this.conversationId);
this.setActiveChat();
},
setActiveChat(conversation = null) {
const selectedConversation = conversation || this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
return this.conversationById(this.conversationId);
},
navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2;
const targetNotification = this.notifications[activeIndex + indexOffset];
if (targetNotification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { inbox_id: inboxId, meta: { unreadCount } = {} },
notification_type: notificationType,
} = targetNotification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, notification_id: id },
});
}
},
onClickNext() {
this.navigateToConversation(this.activeNotificationPosition, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationPosition, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
@@ -0,0 +1,28 @@
<template>
<div
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full hidden md:flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
uiFlags: 'notifications/getUIFlags',
}),
},
};
</script>
@@ -1,9 +1,13 @@
<template>
<div
class="flex flex-col h-full w-full ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="isOnExpandedLayout ? '' : 'min-w-[360px] max-w-[360px]'"
class="flex-col h-full w-full md:min-w-[360px] md:max-w-[360px] ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="!notificationId ? 'flex' : 'hidden md:flex'"
>
<inbox-list-header @filter="onFilterChange" />
<inbox-list-header
:is-context-menu-open="isInboxContextMenuOpen"
@filter="onFilterChange"
@redirect="redirectToInbox"
/>
<div
ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
@@ -15,6 +19,9 @@
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification"
@snooze-notification="openSnoozeNotificationModal"
@context-menu-open="isInboxContextMenuOpen = true"
@context-menu-close="isInboxContextMenuOpen = false"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
@@ -25,18 +32,21 @@
>
{{ $t('INBOX.LIST.NO_NOTIFICATIONS') }}
</p>
<p
v-if="showEndOfListMessage"
class="text-center text-slate-400 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
</div>
</template>
@@ -46,26 +56,25 @@ import wootConstants from 'dashboard/constants/globals';
import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CMD_TOGGLE_SNOOZE_NOTIFICATION,
CMD_SNOOZE_NOTIFICATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import alertMixin from 'shared/mixins/alertMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
CustomSnoozeModal,
},
mixins: [alertMixin, uiSettingsMixin],
props: {
conversationId: {
type: [String, Number],
default: 0,
},
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
data() {
return {
infiniteLoaderOptions: {
@@ -76,6 +85,9 @@ export default {
status: '',
type: '',
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
isInboxContextMenuOpen: false,
showCustomSnoozeModal: false,
notificationIdToSnooze: null,
};
},
computed: {
@@ -85,6 +97,9 @@ export default {
uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications',
}),
notificationId() {
return Number(this.$route.params.notification_id);
},
inboxFilters() {
return {
page: this.page,
@@ -102,13 +117,14 @@ export default {
showEmptyState() {
return !this.uiFlags.isFetching && !this.notifications.length;
},
showEndOfListMessage() {
return this.showEndOfList && this.notifications.length;
},
},
mounted() {
this.setSavedFilter();
this.fetchNotifications();
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
fetchNotifications() {
@@ -119,17 +135,15 @@ export default {
this.$store.dispatch('notifications/index', filter);
},
redirectToInbox() {
if (!this.conversationId) return;
if (!this.notificationId) return;
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', {
...this.inboxFilters,
page: this.page + 1,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
});
this.page += 1;
},
@@ -195,6 +209,47 @@ export default {
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
},
openSnoozeNotificationModal(notificationItem) {
this.notificationIdToSnooze = notificationItem.id;
// There is an bus event to toggle snooze modal, because
// We need to show the snooze notification items in the command bar Dynamically
// If cmd + k is pressed from inbox view (In list screen) and search snooze notification items then we no need to show,
// snooze notification because we don't get notification id.
// Only show when the snooze button is clicked from the notification item card context menu
if (!this.notificationId) bus.$emit(CMD_TOGGLE_SNOOZE_NOTIFICATION);
this.$nextTick(() => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
});
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
snoozeNotification(snoozedUntil) {
this.$store
.dispatch('notifications/snooze', {
id: this.notificationId || this.notificationIdToSnooze,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
});
},
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
}
},
scheduleCustomSnooze(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
this.snoozeNotification(snoozedUntil);
}
},
},
};
</script>
@@ -1,144 +1,25 @@
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<inbox-list
v-show="showConversationList"
:conversation-id="conversationId"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div
v-if="showInboxMessageView"
class="flex flex-col h-full"
:class="isOnExpandedLayout ? 'w-full' : 'w-[calc(100%-360px)]'"
>
<inbox-item-header
:total-length="totalNotifications"
:current-index="activeNotificationIndex"
:active-notification="activeNotification"
@next="onClickNext"
@prev="onClickPrev"
/>
<conversation-box
class="h-[calc(100%-56px)]"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
<div
v-if="!showInboxMessageView && !isOnExpandedLayout"
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
</div>
<router-view name="default" />
<router-view name="detailView" />
</section>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
InboxList,
InboxItemHeader,
ConversationBox,
},
mixins: [uiSettingsMixin],
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications',
currentChat: 'getSelectedChat',
allConversation: 'getAllConversations',
uiFlags: 'notifications/getUIFlags',
}),
activeNotification() {
return this.notifications.find(
n => n.primary_actor.id === Number(this.conversationId)
);
},
isInboxViewEnabled() {
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
this.currentAccountId,
FEATURE_FLAGS.INBOX_VIEW
);
},
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
isFetchingInitialData() {
return this.uiFlags.isFetching && !this.notifications.length;
},
showInboxMessageView() {
return (
Boolean(this.conversationId) &&
Boolean(this.currentChat.id) &&
!this.isFetchingInitialData
);
},
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationIndex() {
const conversationId = Number(this.conversationId);
const notificationIndex = this.notifications.findIndex(
n => n.primary_actor.id === conversationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.fetchConversationById();
},
},
},
mounted() {
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
@@ -148,70 +29,6 @@ export default {
name: 'home',
});
}
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
if (!this.conversationId) return;
const chat = this.findConversation();
if (!chat) {
await this.$store.dispatch('getConversation', this.conversationId);
}
this.setActiveChat();
},
setActiveChat() {
const selectedConversation = this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
const conversationId = Number(this.conversationId);
return this.allConversation.find(c => c.id === conversationId);
},
navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2;
const targetNotification = this.notifications[activeIndex + indexOffset];
if (targetNotification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, meta: { unreadCount } = {} },
notification_type: notificationType,
} = targetNotification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { conversation_id: conversationId },
});
}
},
onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationIndex, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
@@ -89,6 +89,7 @@ export default {
return {
isContextMenuOpen: false,
contextMenuPosition: { x: null, y: null },
activeNotificationId: null,
};
},
computed: {
@@ -96,7 +97,9 @@ export default {
return this.notificationItem?.primary_actor;
},
isInboxCardActive() {
return this.$route.params.conversation_id === this.primaryActor?.id;
return (
Number(this.activeNotificationId) === Number(this.notificationItem?.id)
);
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
@@ -109,6 +112,9 @@ export default {
meta() {
return this.primaryActor?.meta;
},
isNotSnoozed() {
return !this.notificationItem?.snoozed_until;
},
assigneeMeta() {
return this.meta?.assignee;
},
@@ -131,6 +137,12 @@ export default {
},
];
if (this.isNotSnoozed) {
items.push({
key: 'snooze',
label: this.$t('INBOX.MENU_ITEM.SNOOZE'),
});
}
if (!this.isUnread) {
items.push({
key: 'mark_as_unread',
@@ -157,6 +169,14 @@ export default {
return '';
},
},
watch: {
'$route.params.notification_id': {
immediate: true,
handler(newVal) {
this.activeNotificationId = newVal;
},
},
},
unmounted() {
this.closeContextMenu();
},
@@ -166,11 +186,11 @@ export default {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, inbox_id: inboxId },
primary_actor: { inbox_id: inboxId },
notification_type: notificationType,
} = notification;
if (this.$route.params.conversation_id !== conversationId) {
if (this.$route.params.notification_id !== id) {
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
@@ -184,13 +204,14 @@ export default {
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, conversation_id: conversationId },
params: { inboxId, notification_id: id },
});
}
},
closeContextMenu() {
this.isContextMenuOpen = false;
this.contextMenuPosition = { x: null, y: null };
this.$emit('context-menu-close');
},
openContextMenu(e) {
this.closeContextMenu();
@@ -200,6 +221,7 @@ export default {
y: e.pageY || e.clientY,
};
this.isContextMenuOpen = true;
this.$emit('context-menu-open');
},
handleAction(key) {
switch (key) {
@@ -212,6 +234,9 @@ export default {
case 'delete':
this.$emit('delete-notification', this.notificationItem);
break;
case 'snooze':
this.$emit('snooze-notification', this.notificationItem);
break;
default:
}
},
@@ -2,6 +2,16 @@
<div
class="flex gap-2 py-2 ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
>
<woot-button
variant="clear link"
class="flex md:hidden !pt-1 !pb-1 rounded-md ltr:pr-1 rtl:pl-1 !no-underline"
size="medium"
color-scheme="primary"
icon="chevron-left"
@click="onClickGoToInboxList"
>
{{ $t('INBOX.ACTION_HEADER.BACK') }}
</woot-button>
<pagination-button
v-if="totalLength > 1"
:total-length="totalLength"
@@ -12,10 +22,12 @@
<div v-else />
<div class="flex items-center gap-2">
<woot-button
v-if="!isNotificationAlreadySnoozed"
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
class="[&>span]:hidden md:[&>span]:inline-flex"
@click="openSnoozeNotificationModal"
>
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
@@ -25,37 +37,23 @@
size="small"
color-scheme="secondary"
variant="hollow"
class="[&>span]:hidden md:[&>span]:inline-flex"
@click="deleteNotification"
>
{{ $t('INBOX.ACTION_HEADER.DELETE') }}
</woot-button>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { getUnixTime } from 'date-fns';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import PaginationButton from './PaginationButton.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
PaginationButton,
CustomSnoozeModal,
},
mixins: [alertMixin],
props: {
@@ -72,55 +70,19 @@ export default {
default: null,
},
},
data() {
return {
showCustomSnoozeModal: false,
};
},
computed: {
...mapGetters({
meta: 'notifications/getMeta',
}),
},
mounted() {
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
isNotificationAlreadySnoozed() {
return this.activeNotification?.snoozed_until;
},
},
methods: {
openSnoozeNotificationModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
snoozeNotification(snoozedUntil) {
this.$store
.dispatch('notifications/snooze', {
id: this.activeNotification?.id,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
});
},
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
}
},
scheduleCustomSnooze(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
this.snoozeNotification(snoozedUntil);
}
},
deleteNotification() {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.$store
@@ -140,6 +102,9 @@ export default {
onClickPrev() {
this.$emit('prev');
},
onClickGoToInboxList() {
this.$router.push({ name: 'inbox_view' });
},
},
};
</script>
@@ -49,7 +49,7 @@
<inbox-option-menu
v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9"
class="absolute top-9 ltr:right-0 ltr:md:right-[unset] rtl:left-0 rtl:md:left-[unset]"
@option-click="onInboxOptionMenuClick"
/>
</div>
@@ -69,12 +69,29 @@ export default {
InboxDisplayMenu,
},
mixins: [clickaway, alertMixin],
props: {
isContextMenuOpen: {
type: Boolean,
default: false,
},
},
data() {
return {
showInboxDisplayMenu: false,
showInboxOptionMenu: false,
};
},
watch: {
isContextMenuOpen: {
handler(val) {
if (val) {
this.showInboxDisplayMenu = false;
this.showInboxOptionMenu = false;
}
},
immediate: true,
},
},
methods: {
markAllRead() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
@@ -100,21 +117,19 @@ export default {
},
onInboxOptionMenuClick(key) {
this.showInboxOptionMenu = false;
if (key === 'mark_all_read') {
this.markAllRead();
}
if (key === 'delete_all') {
this.deleteAll();
}
if (key === 'delete_all_read') {
this.deleteAllRead();
}
const actions = {
mark_all_read: () => this.markAllRead(),
delete_all: () => this.deleteAll(),
delete_all_read: () => this.deleteAllRead(),
};
const action = actions[key];
if (action) action();
this.$emit('redirect');
},
onFilterChange(option) {
this.$emit('filter', option);
this.showInboxDisplayMenu = false;
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
this.$emit('redirect');
},
},
};
@@ -19,7 +19,7 @@
/>
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-gray-600">
<span class="text-sm font-medium text-gray-600 tabular-nums">
{{ totalLength <= 1 ? '1' : currentIndex }}
</span>
<span
@@ -28,7 +28,7 @@
>
/
</span>
<span v-if="totalLength > 1" class="text-sm text-slate-400">
<span v-if="totalLength > 1" class="text-sm text-slate-400 tabular-nums">
{{ totalLength }}
</span>
</div>
@@ -0,0 +1,32 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
const InboxView = () => import('./InboxView.vue');
const InboxListView = () => import('./InboxList.vue');
const InboxDetailView = () => import('./InboxDetail.vue');
const InboxEmptyStateView = () => import('./InboxEmptyState.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/inbox-view'),
component: InboxView,
children: [
{
path: '',
name: 'inbox_view',
components: {
default: InboxListView,
detailView: InboxEmptyStateView,
},
roles: ['administrator', 'agent'],
},
{
path: ':notification_id',
name: 'inbox_view_conversation',
components: {
default: InboxListView,
detailView: InboxDetailView,
},
roles: ['administrator', 'agent'],
},
],
},
];
@@ -1,4 +1,4 @@
import { applyInboxPageFilters, sortComparator } from './helpers';
import { sortComparator } from './helpers';
export const getters = {
getNotifications($state) {
@@ -6,14 +6,14 @@ export const getters = {
},
getFilteredNotifications: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const filteredNotifications = Object.values($state.records).filter(
notification => applyInboxPageFilters(notification, filters)
);
const sortedNotifications = filteredNotifications.sort((a, b) =>
sortComparator(a, b, sortOrder)
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
);
return sortedNotifications;
},
getActiveNotificationById: $state => id => {
return $state.records[id] || {};
},
getUIFlags($state) {
return $state.uiFlags;
},
@@ -1,31 +1,3 @@
export const filterByStatus = (snoozedUntil, filterStatus) =>
filterStatus === 'snoozed' ? !!snoozedUntil : !snoozedUntil;
export const filterByType = (readAt, filterType) =>
filterType === 'read' ? !!readAt : !readAt;
export const filterByTypeAndStatus = (
readAt,
snoozedUntil,
filterType,
filterStatus
) => {
const shouldFilterByStatus = filterByStatus(snoozedUntil, filterStatus);
const shouldFilterByType = filterByType(readAt, filterType);
return shouldFilterByStatus && shouldFilterByType;
};
export const applyInboxPageFilters = (notification, filters) => {
const { status, type } = filters;
const { read_at: readAt, snoozed_until: snoozedUntil } = notification;
if (status && type)
return filterByTypeAndStatus(readAt, snoozedUntil, type, status);
if (status && !type) return filterByStatus(snoozedUntil, status);
if (!status && type) return filterByType(readAt, type);
return true;
};
const INBOX_SORT_OPTIONS = {
newest: 'desc',
oldest: 'asc',
@@ -34,6 +34,8 @@ describe('#getters', () => {
sortOrder: 'desc',
};
expect(getters.getFilteredNotifications(state)(filters)).toEqual([
{ id: 1, read_at: '2024-02-07T11:42:39.988Z', snoozed_until: null },
{ id: 2, read_at: null, snoozed_until: null },
{
id: 3,
read_at: '2024-02-07T11:42:39.988Z',
@@ -42,6 +44,16 @@ describe('#getters', () => {
]);
});
it('getActiveNotificationById', () => {
const state = {
records: {
1: { id: 1 },
},
};
expect(getters.getActiveNotificationById(state)(1)).toEqual({ id: 1 });
expect(getters.getActiveNotificationById(state)(2)).toEqual({});
});
it('getUIFlags', () => {
const state = {
uiFlags: {
@@ -1,10 +1,4 @@
import {
filterByStatus,
filterByType,
filterByTypeAndStatus,
applyInboxPageFilters,
sortComparator,
} from '../../notifications/helpers';
import { sortComparator } from '../../notifications/helpers';
const notifications = [
{
@@ -45,126 +39,6 @@ const notifications = [
},
];
describe('#filterByStatus', () => {
it('returns the notifications with snoozed status', () => {
const filters = { status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByStatus(notification.snoozed_until, filters.status)
).toEqual(notification.snoozed_until !== null);
});
});
it('returns true if the notification is snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[3].snoozed_until, filters.status)
).toEqual(true);
});
it('returns false if the notification is not snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[2].snoozed_until, filters.status)
).toEqual(false);
});
});
describe('#filterByType', () => {
it('returns the notifications with read status', () => {
const filters = { type: 'read' };
notifications.forEach(notification => {
expect(filterByType(notification.read_at, filters.type)).toEqual(
notification.read_at !== null
);
});
});
it('returns true if the notification is read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[0].read_at, filters.type)).toEqual(true);
});
it('returns false if the notification is not read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[1].read_at, filters.type)).toEqual(false);
});
});
describe('#filterByTypeAndStatus', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
).toEqual(
notification.read_at !== null && notification.snoozed_until !== null
);
});
});
it('returns true if the notification is read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[4].read_at,
notifications[4].snoozed_until,
filters.type,
filters.status
)
).toEqual(true);
});
it('returns false if the notification is not read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[3].read_at,
notifications[3].snoozed_until,
filters.type,
filters.status
)
).toEqual(false);
});
});
describe('#applyInboxPageFilters', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
);
});
});
it('returns the notifications with type only', () => {
const filters = { type: 'read', status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByType(notification.read_at, filters.type)
);
});
});
it('returns the notifications with status only', () => {
const filters = { type: null, status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByStatus(notification.snoozed_until, filters.status)
);
});
});
it('returns true if there are no filters', () => {
const filters = { type: null, status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(true);
});
});
});
describe('#sortComparator', () => {
it('returns the notifications sorted by newest', () => {
const sortOrder = 'newest';
@@ -0,0 +1,83 @@
<template>
<with-label
:label="label"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<textarea
:id="name"
:name="name"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:rows="rows"
:class="{
'focus:outline-red-600 outline-red-600': hasError,
'dark:outline-slate-600 dark:focus:outline-woot-500 outline-slate-200 focus:outline-woot-500':
!hasError,
'resize-none': !allowResize,
}"
class="block w-full p-3 border-none rounded-xl shadow-sm appearance-none outline outline-1 focus:outline focus:outline-2 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 bg-white dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
rows: {
type: Number,
default: 3,
},
allowResize: {
type: Boolean,
default: true,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
dataTestid: {
type: String,
default: '',
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
@@ -9,9 +9,7 @@
<slot name="label">
{{ label }}
</slot>
<slot name="rightOfLabel">
{{ label }}
</slot>
<slot name="rightOfLabel" />
</label>
<div class="w-full">
<div class="flex items-center relative w-full">
+10
View File
@@ -48,3 +48,13 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
next();
};
export const isOnOnboardingView = route => {
const { name = '' } = route || {};
if (!name) {
return false;
}
return name.includes('onboarding_');
};
@@ -1,4 +1,4 @@
import { validateRouteAccess } from '../RouteHelper';
import { validateRouteAccess, isOnOnboardingView } from '../RouteHelper';
import { clearBrowserSessionCookies } from 'dashboard/store/utils/api';
import { replaceRouteWithReload } from '../CommonHelper';
import Cookies from 'js-cookie';
@@ -67,3 +67,24 @@ describe('#validateRouteAccess', () => {
expect(next).toHaveBeenCalledWith();
});
});
describe('isOnOnboardingView', () => {
test('returns true for a route with onboarding name', () => {
const route = { name: 'onboarding_welcome' };
expect(isOnOnboardingView(route)).toBe(true);
});
test('returns false for a route without onboarding name', () => {
const route = { name: 'home' };
expect(isOnOnboardingView(route)).toBe(false);
});
test('returns false for a route with null name', () => {
const route = { name: null };
expect(isOnOnboardingView(route)).toBe(false);
});
test('returns false for an undefined route object', () => {
expect(isOnOnboardingView()).toBe(false);
});
});
+2 -2
View File
@@ -1,13 +1,13 @@
class Account::ContactsExportJob < ApplicationJob
queue_as :low
def perform(account_id, column_names)
def perform(account_id, column_names, email_to)
account = Account.find(account_id)
headers = valid_headers(column_names)
generate_csv(account, headers)
file_url = account_contact_export_url(account)
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url, email_to)&.deliver_later
end
def generate_csv(account, headers)
@@ -2,9 +2,24 @@ class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
queue_as :low
def perform
# rubocop:disable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current)
.update_all(snoozed_until: nil, updated_at: Time.current, last_activity_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current).find_in_batches(batch_size: 100) do |notifications_batch|
notifications_batch.each do |notification|
update_notification(notification)
end
end
end
private
def update_notification(notification)
updated_meta = (notification.meta || {}).merge('last_snoozed_at' => notification.snoozed_until)
notification.update!(
snoozed_until: nil,
updated_at: Time.current,
last_activity_at: Time.current,
meta: updated_meta,
read_at: nil
)
end
end
+14
View File
@@ -37,10 +37,24 @@ class ApplicationMailbox < ActionMailbox::Base
# checks if follow this pattern send it to reply_mailbox
# reply+<conversation-uuid>@<mailer-domain.com>
def reply_uuid_mail?(inbound_mail)
validate_to_address(inbound_mail)
inbound_mail.mail.to&.any? do |email|
conversation_uuid = email.split('@')[0]
conversation_uuid.match?(REPLY_EMAIL_UUID_PATTERN)
end
end
# if mail.to returns a string, then it is a malformed `to` header
# valid `to` header will be of type Mail::AddressContainer
# validate if the to address is of type string
def validate_to_address(inbound_mail)
to_address_class = inbound_mail.mail.to&.class
return if to_address_class == Mail::AddressContainer
Rails.logger.error "Email to address header is malformed `#{inbound_mail.mail.to}`"
raise StandardError, "Invalid email to address header #{inbound_mail.mail.to}"
end
end
end
@@ -60,12 +60,13 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url)
def contact_export_complete(file_url, email_to)
return unless smtp_config_set_or_development?
@action_url = file_url
subject = "Your contact's export file is available to download."
send_mail_with_liquid(to: admin_emails, subject: subject) and return
send_mail_with_liquid(to: email_to, subject: subject) and return
end
private
+3 -2
View File
@@ -111,11 +111,12 @@ class Account < ApplicationRecord
end
def inbound_email_domain
domain || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', false)
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
false)
end
def support_email
super || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
end
def usage_limits
+3 -9
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# last_activity_at :datetime
# meta :jsonb
# notification_type :integer not null
# primary_actor_type :string not null
# read_at :datetime
@@ -63,24 +64,17 @@ class Notification < ApplicationRecord
created_at: created_at.to_i,
last_activity_at: last_activity_at.to_i,
snoozed_until: snoozed_until,
meta: meta,
account_id: account_id
}
if primary_actor.present?
payload[:primary_actor] = primary_actor_data
payload[:primary_actor] = primary_actor&.push_event_data
payload[:push_message_title] = push_message_title
end
payload
end
def primary_actor_data
{
id: primary_actor.push_event_data[:id],
meta: primary_actor.push_event_data[:meta],
inbox_id: primary_actor.push_event_data[:inbox_id]
}
end
def fcm_push_data
{
id: id,
+1
View File
@@ -3,6 +3,7 @@ class ActionService
def initialize(conversation)
@conversation = conversation.reload
@account = @conversation.account
end
def mute_conversation(_params)
@@ -21,6 +21,7 @@ json.data do
json.created_at notification.created_at.to_i
json.last_activity_at notification.last_activity_at.to_i
json.snoozed_until notification.snoozed_until
json.meta notification.meta
end
end
end
@@ -6,6 +6,11 @@ if resource.custom_attributes.present?
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
end
end
json.domain @account.domain
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.5.2'
version: '3.6.0'
development:
<<: *shared
+5
View File
@@ -110,6 +110,11 @@
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
- name: FACEBOOK_API_VERSION
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
value: 'v17.0'
locked: false
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
display_title: 'Enable human agent'
value: false
@@ -0,0 +1,5 @@
class AddMetaToNotifications < ActiveRecord::Migration[7.0]
def change
add_column :notifications, :meta, :jsonb, default: {}
end
end
@@ -0,0 +1,8 @@
class AddUniqueIndexToAppliedSlas < ActiveRecord::Migration[7.0]
def change
add_index :applied_slas,
[:account_id, :sla_policy_id, :conversation_id],
unique: true,
name: 'index_applied_slas_on_account_sla_policy_conversation'
end
end
+3 -1
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: 2024_02_07_103014) do
ActiveRecord::Schema[7.0].define(version: 2024_02_16_055809) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -122,6 +122,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "sla_status", default: 0
t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true
t.index ["account_id"], name: "index_applied_slas_on_account_id"
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
@@ -750,6 +751,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
t.datetime "updated_at", null: false
t.datetime "snoozed_until"
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }
t.jsonb "meta", default: {}
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at"
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
@@ -2,12 +2,11 @@ module Enterprise::Api::V2::AccountsController
private
def fetch_account_and_user_info
data = fetch_from_clearbit
@data = fetch_from_clearbit
return if data.blank?
return if @data.blank?
update_user_info(data)
update_account_info(data)
update_user_info
end
def fetch_from_clearbit
@@ -17,19 +16,25 @@ module Enterprise::Api::V2::AccountsController
nil
end
def update_user_info(data)
@user.update!(name: data[:name])
def update_user_info
@user.update!(name: @data[:name]) if @data[:name].present?
end
def update_account_info(data)
@account.update!(
name: data[:company_name],
custom_attributes: @account.custom_attributes.merge(
'industry' => data[:industry],
'company_size' => data[:company_size],
'timezone' => data[:timezone],
'logo' => data[:logo]
)
def data_from_clearbit
return {} if @data.blank?
{ name: @data[:company_name],
custom_attributes: {
'industry' => @data[:industry],
'company_size' => @data[:company_size],
'timezone' => @data[:timezone],
'logo' => @data[:logo]
} }
end
def account_attributes
super.deep_merge(
data_from_clearbit
)
end
end
@@ -2,7 +2,7 @@ class Sla::TriggerSlasForAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_each do |account|
Account.joins(:sla_policies).distinct.find_each do |account|
Rails.logger.info "Enqueuing ProcessAccountAppliedSlasJob for account #{account.id}"
Sla::ProcessAccountAppliedSlasJob.perform_later(account)
end
+6 -3
View File
@@ -12,14 +12,17 @@
#
# Indexes
#
# index_applied_slas_on_account_id (account_id)
# index_applied_slas_on_conversation_id (conversation_id)
# index_applied_slas_on_sla_policy_id (sla_policy_id)
# index_applied_slas_on_account_id (account_id)
# index_applied_slas_on_account_sla_policy_conversation (account_id,sla_policy_id,conversation_id) UNIQUE
# index_applied_slas_on_conversation_id (conversation_id)
# index_applied_slas_on_sla_policy_id (sla_policy_id)
#
class AppliedSla < ApplicationRecord
belongs_to :account
belongs_to :sla_policy
belongs_to :conversation
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
enum sla_status: { active: 0, hit: 1, missed: 2 }
end
@@ -1,10 +1,18 @@
module Enterprise::ActionService
def add_sla(sla_policy)
def add_sla(sla_policy_id)
return if sla_policy_id.blank?
sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first)
return if sla_policy.nil?
return if @conversation.sla_policy.present?
Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}"
@conversation.update!(sla_policy_id: sla_policy.id)
create_applied_sla(sla_policy)
end
def create_applied_sla(sla_policy)
Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}"
AppliedSla.create!(
account_id: @conversation.account_id,
sla_policy_id: sla_policy.id,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.5.2",
"version": "3.6.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -67,7 +67,7 @@
"markdown-it": "^13.0.1",
"markdown-it-link-attributes": "^4.0.1",
"md5": "^2.3.0",
"ninja-keys": "^1.2.2",
"@chatwoot/ninja-keys": "1.2.3",
"opus-recorder": "^8.0.5",
"postcss": "^8.4.31",
"postcss-loader": "^4.2.0",
@@ -50,8 +50,11 @@ RSpec.describe 'Contacts API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first['email']).to eq(contact.email)
expect(response_body['payload'].first['contact_inboxes'].blank?).to be(true)
contact_emails = response_body['payload'].pluck('email')
contact_inboxes = response_body['payload'].pluck('contact_inboxes').flatten.compact
expect(contact_emails).to include(contact.email)
expect(contact_inboxes).to eq([])
end
it 'returns all contacts with company name desc order' do
@@ -194,7 +197,7 @@ RSpec.describe 'Contacts API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'enqueues a contact export job' do
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, nil).once
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, nil, admin.email).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
@@ -204,7 +207,7 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'enqueues a contact export job with sent_columns' do
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, %w[phone_number email]).once
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, %w[phone_number email], admin.email).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
@@ -29,6 +29,7 @@ RSpec.describe 'Notifications API', type: :request do
expect(response_json['data']['meta']['count']).to eq 2
# notification appear in descending order
expect(response_json['data']['payload'].first['id']).to eq notification2.id
expect(response_json['data']['payload'].first['primary_actor']).not_to be_nil
end
end
end
@@ -178,6 +179,7 @@ RSpec.describe 'Notifications API', type: :request do
expect(response).to have_http_status(:success)
expect(notification.reload.snoozed_until).not_to eq('')
expect(notification.reload.meta['last_snoozed_at']).to be_nil
end
end
end
@@ -213,6 +213,25 @@ RSpec.describe 'Accounts API', type: :request do
end
end
it 'updates onboarding step to invite_team if onboarding step is present in account custom attributes' do
account.update(custom_attributes: { onboarding_step: 'account_update' })
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to be_nil
end
it 'Throws error 422' do
params[:name] = 'test' * 999
@@ -30,6 +30,20 @@ RSpec.describe 'Accounts API', type: :request do
end
end
it 'updates the onboarding step in custom attributes' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('profile_update')
end
end
it 'calls ChatwootCaptcha' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
captcha = double
@@ -41,5 +41,15 @@ RSpec.describe 'Agents API', type: :request do
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
end
end
context 'when onboarding step is present in account custom attributes' do
it 'removes onboarding step from account custom attributes' do
account.update(custom_attributes: { onboarding_step: 'completed' })
post "/api/v1/accounts/#{account.id}/agents/bulk_create", params: bulk_create_params, headers: admin.create_new_auth_token
expect(account.reload.custom_attributes).not_to include('onboarding_step')
end
end
end
end
@@ -51,6 +51,22 @@ RSpec.describe Enterprise::Api::V2::AccountsController, type: :request do
end
end
it 'updates the onboarding step in custom attributes' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
custom_attributes = account.custom_attributes
expect(custom_attributes['onboarding_step']).to eq('profile_update')
end
end
it 'handles errors when fetching data from clearbit' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
@@ -1,16 +1,25 @@
require 'rails_helper'
RSpec.describe Sla::TriggerSlasForAccountsJob do
context 'when perform is called' do
let(:account) { create(:account) }
let(:account_with_sla) { create(:account) }
let(:account_without_sla) { create(:account) }
before do
create(:sla_policy, account: account_with_sla)
end
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
it 'calls the ProcessAccountAppliedSlasJob' do
expect(Sla::ProcessAccountAppliedSlasJob).to receive(:perform_later).with(account).and_call_original
it 'calls the ProcessAccountAppliedSlasJob for accounts with SLA' do
expect(Sla::ProcessAccountAppliedSlasJob).to receive(:perform_later).with(account_with_sla).and_call_original
described_class.perform_now
end
it 'does not call the ProcessAccountAppliedSlasJob for accounts without SLA' do
expect(Sla::ProcessAccountAppliedSlasJob).not_to receive(:perform_later).with(account_without_sla)
described_class.perform_now
end
end
@@ -8,16 +8,42 @@ describe ActionService do
let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) }
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla(sla_policy)
expect(conversation.reload.sla_policy_id).to eq(sla_policy.id)
context 'when sla_policy_id is present' do
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla([sla_policy.id])
expect(conversation.reload.sla_policy_id).to eq(sla_policy.id)
# check if appliedsla table entry is created with matching attributes
applied_sla = AppliedSla.last
expect(applied_sla.account_id).to eq(account.id)
expect(applied_sla.sla_policy_id).to eq(sla_policy.id)
expect(applied_sla.conversation_id).to eq(conversation.id)
expect(applied_sla.sla_status).to eq('active')
# check if appliedsla table entry is created with matching attributes
applied_sla = AppliedSla.last
expect(applied_sla.account_id).to eq(account.id)
expect(applied_sla.sla_policy_id).to eq(sla_policy.id)
expect(applied_sla.conversation_id).to eq(conversation.id)
expect(applied_sla.sla_status).to eq('active')
end
end
context 'when sla_policy_id is not present' do
it 'does not add the sla policy to the conversation' do
action_service.add_sla(nil)
expect(conversation.reload.sla_policy_id).to be_nil
end
end
context 'when conversation already has a sla policy' do
it 'does not add the new sla policy to the conversation' do
existing_sla_policy = sla_policy
new_sla_policy = create(:sla_policy, account: account)
conversation.update!(sla_policy_id: existing_sla_policy.id)
action_service.add_sla([new_sla_policy.id])
expect(conversation.reload.sla_policy_id).to eq(existing_sla_policy.id)
end
end
context 'when sla_policy is not found' do
it 'does not add the sla policy to the conversation' do
action_service.add_sla([sla_policy.id + 1])
expect(conversation.reload.sla_policy_id).to be_nil
end
end
end
end
+10
View File
@@ -6,5 +6,15 @@ FactoryBot.define do
notification_type { 'conversation_assignment' }
user
account
read_at { nil }
snoozed_until { nil }
end
trait :read do
read_at { DateTime.now.utc - 3.days }
end
trait :snoozed do
snoozed_until { DateTime.now.utc + 3.days }
end
end
+24
View File
@@ -146,6 +146,30 @@ describe ConversationFinder do
end
end
context 'with updated_within' do
let(:params) { { updated_within: 20, assignee_type: 'unassigned', sort_by: 'created_at_asc' } }
it 'filters based on params, sort order but returns all conversations without pagination with in time range' do
# value of updated_within is in seconds
# write spec based on that
conversations = create_list(:conversation, 50, account: account,
inbox: inbox, assignee: nil,
updated_at: Time.now.utc - 30.seconds,
created_at: Time.now.utc - 30.seconds)
# update updated_at of 27 conversations to be with in 20 seconds
conversations[0..27].each do |conversation|
conversation.update(updated_at: Time.now.utc - 10.seconds)
end
result = conversation_finder.perform
# pagination is not applied
# filters are applied
# modified conversations + 1 conversation created during set up
expect(result[:conversations].length).to be 29
# ensure that the conversations are sorted by created_at
expect(result[:conversations].first.created_at).to be < result[:conversations].last.created_at
end
end
context 'with pagination' do
let(:params) { { status: 'open', assignee_type: 'me', page: 1 } }
+49 -73
View File
@@ -1,103 +1,79 @@
require 'rails_helper'
describe NotificationFinder do
subject(:notification_finder) { described_class.new(user, account, params) }
RSpec.describe NotificationFinder do
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let(:notification_finder) { described_class.new(user, account, params) }
before do
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 1.day, last_activity_at: DateTime.now.utc + 1.day,
read_at: DateTime.now.utc)
create(:notification, account: account, user: user, snoozed_until: DateTime.now.utc + 3.days, updated_at: DateTime.now.utc,
last_activity_at: DateTime.now.utc)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 2.days, last_activity_at: DateTime.now.utc + 2.days)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 4.days, last_activity_at: DateTime.now.utc + 4.days,
notification_type: :conversation_creation, read_at: DateTime.now.utc)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 5.days, last_activity_at: DateTime.now.utc + 5.days,
notification_type: :conversation_mention)
create(:notification, account: account, user: user, updated_at: DateTime.now.utc + 6.days, last_activity_at: DateTime.now.utc + 6.days,
notification_type: :participating_conversation_new_message)
create(:notification, :snoozed, account: account, user: user)
create_list(:notification, 2, :read, account: account, user: user)
create_list(:notification, 3, account: account, user: user)
end
describe '#perform' do
context 'when params are empty' do
describe '#notifications' do
subject { notification_finder.notifications }
context 'with default params (empty)' do
let(:params) { {} }
it 'returns all the notifications' do
result = notification_finder.perform
expect(result.length).to be 6
end
it 'orders notifications by last activity at' do
result = notification_finder.perform
expect(result.first.last_activity_at).to be > result.last.last_activity_at
end
it 'returns unread count' do
result = notification_finder.unread_count
expect(result).to be 4
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 6
it 'returns all unread and unsnoozed notifications, ordered by last activity' do
expect(subject.size).to eq(3)
expect(subject).to match_array(subject.sort_by(&:last_activity_at).reverse)
end
end
context 'when snoozed param is passed' do
let(:params) { { status: 'snoozed' } }
context 'with params including read and snoozed statuses' do
let(:params) { { includes: %w[read snoozed] } }
it 'returns only snoozed notifications' do
result = notification_finder.perform
expect(result.length).to be 1
end
it 'returns unread count' do
result = notification_finder.unread_count
expect(result).to be 1
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 1
it 'returns all notifications, including read and snoozed' do
expect(subject.size).to eq(6)
end
end
context 'when type read param is passed' do
let(:params) { { type: 'read' } }
context 'with params including only read status' do
let(:params) { { includes: ['read'] } }
it 'returns only read notifications' do
result = notification_finder.perform
expect(result.length).to be 2
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 2
it 'returns all notifications expect the snoozed' do
expect(subject.size).to eq(5)
end
end
context 'when type read and snoozed param is passed' do
let(:params) { { type: 'read', status: 'snoozed' } }
context 'with params including only snoozed status' do
let(:params) { { includes: ['snoozed'] } }
it 'returns only read notifications' do
result = notification_finder.perform
expect(result.length).to be 0
end
it 'returns count' do
result = notification_finder.count
expect(result).to be 0
it 'rreturns all notifications only expect the read' do
expect(subject.size).to eq(4)
end
end
context 'when sort order is passed' do
context 'with ascending sort order' do
let(:params) { { sort_order: :asc } }
it 'returns notifications in ascending order' do
result = notification_finder.perform
expect(result.first.last_activity_at).to be < result.last.last_activity_at
it 'returns notifications in ascending order by last activity' do
expect(subject.first.last_activity_at).to be < subject.last.last_activity_at
end
end
end
describe 'counts' do
subject { notification_finder }
context 'without specific filters' do
let(:params) { {} }
it 'correctly reports unread and total counts' do
expect(subject.unread_count).to eq(3)
expect(subject.count).to eq(3)
end
end
context 'with filters applied' do
let(:params) { { includes: %w[read snoozed] } }
it 'adjusts counts based on included statuses' do
expect(subject.unread_count).to eq(4)
expect(subject.count).to eq(6)
end
end
end
+29
View File
@@ -0,0 +1,29 @@
X-Original-To: bd84c730a1ac7833e4d27253804516f7@reply.chatwoot.com
Received: from mail.planetmars.com (mxd [192.168.1.1]) by mx.sendgrid.net with ESMTP id AAAA-bCCCCCC5DeeeFFgg for <bd84c730a1ac7833e4d27253804516f7@reply.chatwoot.com>; Sun, 31 Dec 2023 22:32:23.586 +0000 (UTC)
From: "Mark Whatney" <mark@planetmars.com>
To: <vishnu@chatwoot.com>vishnu@chatwoot.com
Subject: stranded in mars
Date: Mon, 1 Jan 2024 06:31:44 +0800
Message-ID: <1234560e0123c05b4bbf83c828b1688a93c7@com>
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary=15688136a4ad411d82b004fae6e46549
X-Exim-Id: 1234560e0123c05b4bbf83c828b1688a93c7
This is a multipart message in MIME format.
--15688136a4ad411d82b004fae6e46549
Content-Type: text/plain;
charset="us-ascii"
Content-Transfer-Encoding: 7bit
hey v, can i get some help over here?
--15688136a4ad411d82b004fae6e46549
Content-Type: text/html;
charset="utf-8"
Content-Transfer-Encoding: quoted-printable
hey v, can i get some help over here?
--15688136a4ad411d82b004fae6e46549--
+29
View File
@@ -0,0 +1,29 @@
X-Original-To: bd84c730a1ac7833e4d27253804516f7@reply.chatwoot.com
Received: from mail.planetmars.com (mxd [192.168.1.1]) by mx.sendgrid.net with ESMTP id AAAA-bCCCCCC5DeeeFFgg for <bd84c730a1ac7833e4d27253804516f7@reply.chatwoot.com>; Sun, 31 Dec 2023 22:32:23.586 +0000 (UTC)
From: "Mark Whatney" <mark@planetmars.com>
To: vishnu@chatwoot.com www.chatwoot.com
Subject: stranded in mars
Date: Mon, 1 Jan 2024 06:31:44 +0800
Message-ID: <1234560e0123c05b4bbf83c828b1688a93c7@com>
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary=15688136a4ad411d82b004fae6e46549
X-Exim-Id: 1234560e0123c05b4bbf83c828b1688a93c7
This is a multipart message in MIME format.
--15688136a4ad411d82b004fae6e46549
Content-Type: text/plain;
charset="us-ascii"
Content-Transfer-Encoding: 7bit
hey v, can i get some help over here?
--15688136a4ad411d82b004fae6e46549
Content-Type: text/html;
charset="utf-8"
Content-Transfer-Encoding: quoted-printable
hey v, can i get some help over here?
--15688136a4ad411d82b004fae6e46549--
@@ -24,17 +24,17 @@ RSpec.describe Account::ContactsExportJob do
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).with(account: account).and_return(mailer)
allow(mailer).to receive(:contact_export_complete)
described_class.perform_now(account.id, [])
described_class.perform_now(account.id, [], 'test@test.com')
file_url = Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export)
expect(account.contacts_export).to be_present
expect(file_url).to be_present
expect(mailer).to have_received(:contact_export_complete).with(file_url)
expect(mailer).to have_received(:contact_export_complete).with(file_url, 'test@test.com')
end
it 'generates valid data export file' do
described_class.perform_now(account.id, [])
described_class.perform_now(account.id, [], 'test@test.com')
csv_data = CSV.parse(account.contacts_export.download, headers: true)
emails = csv_data.pluck('email')
@@ -14,9 +14,13 @@ RSpec.describe Notification::ReopenSnoozedNotificationsJob do
it 'reopens snoozed notifications whose snooze until has passed' do
described_class.perform_now
snoozed_until = snoozed_till_5_minutes_ago.reload.snoozed_until
expect(snoozed_till_5_minutes_ago.reload.snoozed_until).to be_nil
expect(snoozed_till_tomorrow.reload.snoozed_until.to_date).to eq 1.day.from_now.to_date
expect(snoozed_indefinitely.reload.snoozed_until).to be_nil
expect(snoozed_indefinitely.reload.read_at).to be_nil
expect(snoozed_until).to eq(snoozed_till_5_minutes_ago.reload.meta['snoozed_until'])
end
end
end
@@ -10,6 +10,8 @@ RSpec.describe ApplicationMailbox do
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply.eml') }
let(:reply_mail_with_in_reply_to) { create_inbound_email_from_fixture('in_reply_to.eml') }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:mail_with_invalid_to_address) { create_inbound_email_from_fixture('mail_with_invalid_to.eml') }
let(:mail_with_invalid_to_address_2) { create_inbound_email_from_fixture('mail_with_invalid_to_2.eml') }
describe 'Default' do
it 'catchall mails route to Default Mailbox' do
@@ -65,5 +67,21 @@ RSpec.describe ApplicationMailbox do
described_class.route reply_cc_mail
end
end
describe 'Invalid Mail To Address' do
it 'raises error when mail.to header is malformed' do
expect do
described_class.route mail_with_invalid_to_address
end.to raise_error(StandardError,
'Invalid email to address header <vishnu@chatwoot.com>vishnu@chatwoot.com')
end
it 'raises another error when mail.to header is malformed' do
expect do
described_class.route mail_with_invalid_to_address_2
end.to raise_error(StandardError,
'Invalid email to address header vishnu@chatwoot.com www.chatwoot.com')
end
end
end
end
@@ -10,7 +10,6 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
Account::ContactsExportJob.perform_now(account.id, [])
end
describe 'slack_disconnect' do
@@ -92,8 +91,8 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
end
describe 'contact_export_complete' do
let!(:file_url) { Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export) }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url).deliver_now }
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, administrator.email).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
+50
View File
@@ -47,6 +47,56 @@ RSpec.describe Account do
end
end
describe 'inbound_email_domain' do
let(:account) { create(:account) }
it 'returns the domain from inbox if inbox value is present' do
account.update(domain: 'test.com')
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test2.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
it 'returns the domain from ENV if inbox value is nil' do
account.update(domain: nil)
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
it 'returns the domain from ENV if inbox value is empty string' do
account.update(domain: '')
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
end
describe 'support_email' do
let(:account) { create(:account) }
it 'returns the support email from inbox if inbox value is present' do
account.update(support_email: 'support@chatwoot.com')
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('support@chatwoot.com')
end
end
it 'returns the support email from ENV if inbox value is nil' do
account.update(support_email: nil)
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('hello@chatwoot.com')
end
end
it 'returns the support email from ENV if inbox value is empty string' do
account.update(support_email: '')
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('hello@chatwoot.com')
end
end
end
context 'when after_destroy is called' do
it 'conv_dpid_seq and camp_dpid_seq_ are deleted' do
account = create(:account)
+15 -15
View File
@@ -3156,6 +3156,15 @@
"@braid/vue-formulate-i18n" "^1.16.0"
is-plain-object "^3.0.1"
"@chatwoot/ninja-keys@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@chatwoot/ninja-keys/-/ninja-keys-1.2.3.tgz#3c3f2b505f091ef4707fd1da39bb2ec6d12e7824"
integrity sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==
dependencies:
"@material/mwc-icon" "0.25.3"
hotkeys-js "3.8.7"
lit "2.2.6"
"@chatwoot/prosemirror-schema@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.5.tgz#d6053692beae59d466ac0b04128fa157f59eb176"
@@ -12597,14 +12606,14 @@ ip-regex@^2.1.0:
integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=
ip@^1.1.0, ip@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
version "1.1.9"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.9.tgz#8dfbcc99a754d07f425310b86a99546b1151e396"
integrity sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==
ip@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da"
integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==
version "2.0.1"
resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.1.tgz#e8f3595d33a3ea66490204234b77636965307105"
integrity sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==
ipaddr.js@1.9.1, ipaddr.js@^1.9.0:
version "1.9.1"
@@ -15077,15 +15086,6 @@ nice-try@^1.0.4:
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
ninja-keys@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/ninja-keys/-/ninja-keys-1.2.2.tgz#c1e1ec1a98aee3a977ee77157ac4aa865348be88"
integrity sha512-ylo8jzKowi3XBHkgHRjBJaKQkl32WRLr7kRiA0ajiku11vHRDJ2xANtTScR5C7XlDwKEOYvUPesCKacUeeLAYw==
dependencies:
"@material/mwc-icon" "0.25.3"
hotkeys-js "3.8.7"
lit "2.2.6"
no-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d"