feat: Ability to snooze conversations (#2682)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sojan Jose
2021-07-23 15:24:07 +05:30
committed by GitHub
parent 4d45ac3bfc
commit d955d8e7dc
21 changed files with 270 additions and 26 deletions

View File

@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types include Events::Types
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create] before_action :conversation, except: [:index, :meta, :search, :create]
before_action :contact_inbox, only: [:create] before_action :contact_inbox, only: [:create]
@@ -49,9 +50,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_status def toggle_status
if params[:status] if params[:status]
status = params[:status] == 'bot' ? 'pending' : params[:status] set_conversation_status
@conversation.status = status @status = @conversation.save!
@status = @conversation.save
else else
@status = @conversation.toggle_status @status = @conversation.toggle_status
end end
@@ -74,6 +74,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private private
def set_conversation_status
status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = status
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def trigger_typing_event(event) def trigger_typing_event(event)
user = current_user.presence || @resource user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
@@ -115,7 +121,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
inbox_id: @contact_inbox.inbox_id, inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id, contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes additional_attributes: additional_attributes,
snoozed_until: params[:snoozed_until]
}.merge(status) }.merge(status)
end end

View File

@@ -28,9 +28,10 @@ class ConversationApi extends ApiClient {
}); });
} }
toggleStatus({ conversationId, status }) { toggleStatus({ conversationId, status, snoozedUntil = null }) {
return axios.post(`${this.url}/${conversationId}/toggle_status`, { return axios.post(`${this.url}/${conversationId}/toggle_status`, {
status, status,
snoozed_until: snoozedUntil,
}); });
} }

View File

@@ -69,6 +69,7 @@ describe('#ConversationAPI', () => {
`/api/v1/conversations/12/toggle_status`, `/api/v1/conversations/12/toggle_status`,
{ {
status: 'online', status: 'online',
snoozed_until: null,
} }
); );
}); });

View File

@@ -24,7 +24,7 @@
{{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }} {{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-else-if="isPending" v-else-if="showOpenButton"
class-names="resolve" class-names="resolve"
color-scheme="primary" color-scheme="primary"
icon="ion-person" icon="ion-person"
@@ -34,7 +34,7 @@
{{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }} {{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-if="showDropDown" v-if="showAdditionalActions"
:color-scheme="buttonClass" :color-scheme="buttonClass"
:disabled="isLoading" :disabled="isLoading"
icon="ion-arrow-down-b" icon="ion-arrow-down-b"
@@ -43,7 +43,7 @@
/> />
</div> </div>
<div <div
v-if="showDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open" class="dropdown-pane dropdown-pane--open"
> >
@@ -56,6 +56,40 @@
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }} {{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button> </woot-button>
</woot-dropdown-item> </woot-dropdown-item>
<woot-dropdown-sub-menu
v-if="isOpen"
:title="this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TITLE')"
>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="() => toggleStatus(STATUS_TYPE.SNOOZED, null)"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_REPLY') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.tomorrow)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TOMORROW') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.nextWeek)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_WEEK') }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-sub-menu>
</woot-dropdown-menu> </woot-dropdown-menu>
</div> </div>
</div> </div>
@@ -67,20 +101,29 @@ import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from '../../constants'; import wootConstants from '../../constants';
import {
getUnixTime,
addHours,
addWeeks,
startOfTomorrow,
startOfWeek,
} from 'date-fns';
export default { export default {
components: { components: {
WootDropdownItem, WootDropdownItem,
WootDropdownMenu, WootDropdownMenu,
WootDropdownSubMenu,
}, },
mixins: [clickaway, alertMixin], mixins: [clickaway, alertMixin],
props: { conversationId: { type: [String, Number], required: true } }, props: { conversationId: { type: [String, Number], required: true } },
data() { data() {
return { return {
isLoading: false, isLoading: false,
showDropdown: false, showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
}; };
}, },
@@ -97,30 +140,47 @@ export default {
isResolved() { isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED; return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
}, },
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() { buttonClass() {
if (this.isPending) return 'primary'; if (this.isPending) return 'primary';
if (this.isOpen) return 'success'; if (this.isOpen) return 'success';
if (this.isResolved) return 'warning'; if (this.isResolved) return 'warning';
return ''; return '';
}, },
showDropDown() { showAdditionalActions() {
return !this.isPending; return !this.isPending && !this.isSnoozed;
},
snoozeTimes() {
return {
// tomorrow = 9AM next day
tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)),
// next week = 9AM Monday, next week
nextWeek: getUnixTime(
addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9)
),
};
}, },
}, },
methods: { methods: {
showOpenButton() {
return this.isResolved || this.isSnoozed;
},
closeDropdown() { closeDropdown() {
this.showDropdown = false; this.showActionsDropdown = false;
}, },
openDropdown() { openDropdown() {
this.showDropdown = true; this.showActionsDropdown = true;
}, },
toggleStatus(status) { toggleStatus(status, snoozedUntil) {
this.closeDropdown(); this.closeDropdown();
this.isLoading = true; this.isLoading = true;
this.$store this.$store
.dispatch('toggleStatus', { .dispatch('toggleStatus', {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
status, status,
snoozedUntil,
}) })
.then(() => { .then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS')); this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));

View File

@@ -9,6 +9,7 @@ export default {
OPEN: 'open', OPEN: 'open',
RESOLVED: 'resolved', RESOLVED: 'resolved',
PENDING: 'pending', PENDING: 'pending',
SNOOZED: 'snoozed',
}, },
}; };
export const DEFAULT_REDIRECT_URL = '/app/'; export const DEFAULT_REDIRECT_URL = '/app/';

View File

@@ -49,6 +49,10 @@
{ {
"TEXT": "Pending", "TEXT": "Pending",
"VALUE": "pending" "VALUE": "pending"
},
{
"TEXT": "Snoozed",
"VALUE": "snoozed"
} }
], ],
"ATTACHMENTS": { "ATTACHMENTS": {

View File

@@ -41,7 +41,13 @@
"DETAILS": "details" "DETAILS": "details"
}, },
"RESOLVE_DROPDOWN": { "RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending" "MARK_PENDING": "Mark as pending",
"SNOOZE": {
"TITLE": "Snooze until",
"NEXT_REPLY": "Next reply",
"TOMORROW": "Tomorrow",
"NEXT_WEEK": "Next week"
}
}, },
"FOOTER": { "FOOTER": {
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", "MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",

View File

@@ -135,11 +135,15 @@ const actions = {
commit(types.default.ASSIGN_TEAM, team); commit(types.default.ASSIGN_TEAM, team);
}, },
toggleStatus: async ({ commit }, { conversationId, status }) => { toggleStatus: async (
{ commit },
{ conversationId, status, snoozedUntil = null }
) => {
try { try {
const response = await ConversationApi.toggleStatus({ const response = await ConversationApi.toggleStatus({
conversationId, conversationId,
status, status,
snoozedUntil,
}); });
commit( commit(
types.default.RESOLVE_CONVERSATION, types.default.RESOLVE_CONVERSATION,

View File

@@ -0,0 +1,46 @@
<template>
<li class="sub-menu-container">
<div class="sub-menu-title">
<span class="small">{{ title }}</span>
</div>
<ul class="sub-menu-li-container">
<slot></slot>
</ul>
</li>
</template>
<script>
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
props: {
title: {
type: String,
default: '',
},
},
};
</script>
<style lang="scss" scoped>
.sub-menu-container {
border-top: 1px solid var(--color-border);
margin-top: var(--space-micro);
&:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
}
.sub-menu-title {
padding: var(--space-one) var(--space-one) var(--space-smaller);
text-transform: uppercase;
.small {
color: var(--b-600);
font-weight: var(--font-weight-medium);
}
}
.sub-menu-li-container {
margin: 0;
}
</style>

View File

@@ -0,0 +1,7 @@
class Conversations::ReopenSnoozedConversationsJob < ApplicationJob
queue_as :low
def perform
Conversation.where(status: :snoozed).where(snoozed_until: 3.days.ago..Time.current).all.each(&:open!)
end
end

View File

@@ -6,5 +6,8 @@ class TriggerScheduledItemsJob < ApplicationJob
Campaign.where(campaign_type: :one_off, campaign_status: :active).where(scheduled_at: 3.days.ago..Time.current).all.each do |campaign| Campaign.where(campaign_type: :one_off, campaign_status: :active).where(scheduled_at: 3.days.ago..Time.current).all.each do |campaign|
Campaigns::TriggerOneoffCampaignJob.perform_later(campaign) Campaigns::TriggerOneoffCampaignJob.perform_later(campaign)
end end
# Job to reopen snoozed conversations
Conversations::ReopenSnoozedConversationsJob.perform_later
end end
end end

View File

@@ -8,6 +8,7 @@
# contact_last_seen_at :datetime # contact_last_seen_at :datetime
# identifier :string # identifier :string
# last_activity_at :datetime not null # last_activity_at :datetime not null
# snoozed_until :datetime
# status :integer default("open"), not null # status :integer default("open"), not null
# uuid :uuid not null # uuid :uuid not null
# created_at :datetime not null # created_at :datetime not null
@@ -45,7 +46,7 @@ class Conversation < ApplicationRecord
validates :inbox_id, presence: true validates :inbox_id, presence: true
before_validation :validate_additional_attributes before_validation :validate_additional_attributes
enum status: { open: 0, resolved: 1, pending: 2 } enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
scope :latest, -> { order(last_activity_at: :desc) } scope :latest, -> { order(last_activity_at: :desc) }
scope :unassigned, -> { where(assignee_id: nil) } scope :unassigned, -> { where(assignee_id: nil) }
@@ -64,6 +65,7 @@ class Conversation < ApplicationRecord
has_one :csat_survey_response, dependent: :destroy has_one :csat_survey_response, dependent: :destroy
has_many :notifications, as: :primary_actor, dependent: :destroy has_many :notifications, as: :primary_actor, dependent: :destroy
before_save :ensure_snooze_until_reset
before_create :mark_conversation_pending_if_bot before_create :mark_conversation_pending_if_bot
# wanted to change this to after_update commit. But it ended up creating a loop # wanted to change this to after_update commit. But it ended up creating a loop
@@ -91,7 +93,7 @@ class Conversation < ApplicationRecord
def toggle_status def toggle_status
# FIXME: implement state machine with aasm # FIXME: implement state machine with aasm
self.status = open? ? :resolved : :open self.status = open? ? :resolved : :open
self.status = :open if pending? self.status = :open if pending? || snoozed?
save save
end end
@@ -140,6 +142,10 @@ class Conversation < ApplicationRecord
private private
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
def validate_additional_attributes def validate_additional_attributes
self.additional_attributes = {} unless additional_attributes.is_a?(Hash) self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
end end

View File

@@ -164,7 +164,10 @@ class Message < ApplicationRecord
end end
def reopen_conversation def reopen_conversation
conversation.open! if incoming? && conversation.resolved? && !conversation.muted? return if conversation.muted?
return unless incoming?
conversation.open! if conversation.resolved? || conversation.snoozed?
end end
def execute_message_template_hooks def execute_message_template_hooks

View File

@@ -64,6 +64,7 @@ en:
resolved: "Conversation was marked resolved by %{user_name}" resolved: "Conversation was marked resolved by %{user_name}"
open: "Conversation was reopened by %{user_name}" open: "Conversation was reopened by %{user_name}"
pending: "Conversation was marked as pending by %{user_name}" pending: "Conversation was marked as pending by %{user_name}"
snoozed: "Conversation was snoozed by %{user_name}"
auto_resolved: "Conversation was marked resolved by system due to %{duration} days of inactivity" auto_resolved: "Conversation was marked resolved by system due to %{duration} days of inactivity"
assignee: assignee:
self_assigned: "%{user_name} self-assigned this conversation" self_assigned: "%{user_name} self-assigned this conversation"

View File

@@ -0,0 +1,5 @@
class AddSnoozedUntilToConversations < ActiveRecord::Migration[6.0]
def change
add_column :conversations, :snoozed_until, :datetime
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_07_14_110714) do ActiveRecord::Schema.define(version: 2021_07_21_182458) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@@ -267,6 +267,7 @@ ActiveRecord::Schema.define(version: 2021_07_14_110714) do
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id" t.bigint "team_id"
t.bigint "campaign_id" t.bigint "campaign_id"
t.datetime "snoozed_until"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id"], name: "index_conversations_on_account_id" t.index ["account_id"], name: "index_conversations_on_account_id"
t.index ["campaign_id"], name: "index_conversations_on_campaign_id" t.index ["campaign_id"], name: "index_conversations_on_campaign_id"

View File

@@ -306,6 +306,19 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.status).to eq('pending') expect(conversation.reload.status).to eq('pending')
end end
it 'toggles the conversation status to snoozed when parameter is passed' do
expect(conversation.status).to eq('open')
snoozed_until = (DateTime.now.utc + 2.days).to_i
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
params: { status: 'snoozed', snoozed_until: snoozed_until },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('snoozed')
expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until)
end
# TODO: remove this spec when we remove the condition check in controller # TODO: remove this spec when we remove the condition check in controller
# Added for backwards compatibility for bot status # Added for backwards compatibility for bot status
it 'toggles the conversation status to pending status when parameter bot is passed' do it 'toggles the conversation status to pending status when parameter bot is passed' do

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Conversations::ReopenSnoozedConversationsJob, type: :job do
let!(:snoozed_till_5_minutes_ago) { create(:conversation, status: :snoozed, snoozed_until: 5.minutes.ago) }
let!(:snoozed_till_tomorrow) { create(:conversation, status: :snoozed, snoozed_until: 1.day.from_now) }
let!(:snoozed_indefinitely) { create(:conversation, status: :snoozed) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called' do
it 'reopens snoozed conversations whose snooze until has passed' do
described_class.perform_now
expect(snoozed_till_5_minutes_ago.reload.status).to eq 'open'
expect(snoozed_till_tomorrow.reload.status).to eq 'snoozed'
expect(snoozed_indefinitely.reload.status).to eq 'snoozed'
end
end
end

View File

@@ -20,5 +20,10 @@ RSpec.describe TriggerScheduledItemsJob, type: :job do
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
described_class.perform_now described_class.perform_now
end end
it 'triggers Conversations::ReopenSnoozedConversationsJob' do
expect(Conversations::ReopenSnoozedConversationsJob).to receive(:perform_later).once
described_class.perform_now
end
end end
end end

View File

@@ -181,14 +181,38 @@ RSpec.describe Conversation, type: :model do
end end
describe '#toggle_status' do describe '#toggle_status' do
subject(:toggle_status) { conversation.toggle_status } it 'toggles conversation status to resolved when open' do
conversation = create(:conversation, status: 'open')
let(:conversation) { create(:conversation, status: :open) } expect(conversation.toggle_status).to eq(true)
it 'toggles conversation status' do
expect(toggle_status).to eq(true)
expect(conversation.reload.status).to eq('resolved') expect(conversation.reload.status).to eq('resolved')
end end
it 'toggles conversation status to open when resolved' do
conversation = create(:conversation, status: 'resolved')
expect(conversation.toggle_status).to eq(true)
expect(conversation.reload.status).to eq('open')
end
it 'toggles conversation status to open when pending' do
conversation = create(:conversation, status: 'pending')
expect(conversation.toggle_status).to eq(true)
expect(conversation.reload.status).to eq('open')
end
it 'toggles conversation status to open when snoozed' do
conversation = create(:conversation, status: 'snoozed')
expect(conversation.toggle_status).to eq(true)
expect(conversation.reload.status).to eq('open')
end
end
describe '#ensure_snooze_until_reset' do
it 'resets the snoozed_until when status is toggled' do
conversation = create(:conversation, status: 'snoozed', snoozed_until: 2.days.from_now)
expect(conversation.snoozed_until).not_to eq nil
expect(conversation.toggle_status).to eq(true)
expect(conversation.reload.snoozed_until).to eq(nil)
end
end end
describe '#mute!' do describe '#mute!' do

View File

@@ -9,6 +9,30 @@ RSpec.describe Message, type: :model do
it { is_expected.to validate_presence_of(:account_id) } it { is_expected.to validate_presence_of(:account_id) }
end end
describe '#reopen_conversation' do
let(:conversation) { create(:conversation) }
let(:message) { build(:message, message_type: :incoming, conversation: conversation) }
it 'reopens resolved conversation when the message is from a contact' do
conversation.resolved!
message.save!
expect(message.conversation.open?).to eq true
end
it 'reopens snoozed conversation when the message is from a contact' do
conversation.snoozed!
message.save!
expect(message.conversation.open?).to eq true
end
it 'will not reopen if the conversation is muted' do
conversation.resolved!
conversation.mute!
message.save!
expect(message.conversation.open?).to eq false
end
end
context 'when message is created' do context 'when message is created' do
let(:message) { build(:message, account: create(:account)) } let(:message) { build(:message, account: create(:account)) }