diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 8d719e46c..d360723a6 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -1,5 +1,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController include Events::Types + include DateRangeHelper before_action :conversation, except: [:index, :meta, :search, :create] before_action :contact_inbox, only: [:create] @@ -49,9 +50,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def toggle_status if params[:status] - status = params[:status] == 'bot' ? 'pending' : params[:status] - @conversation.status = status - @status = @conversation.save + set_conversation_status + @status = @conversation.save! else @status = @conversation.toggle_status end @@ -74,6 +74,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro 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) user = current_user.presence || @resource 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, contact_id: @contact_inbox.contact_id, contact_inbox_id: @contact_inbox.id, - additional_attributes: additional_attributes + additional_attributes: additional_attributes, + snoozed_until: params[:snoozed_until] }.merge(status) end diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 9388fc3bb..d5599ade0 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -28,9 +28,10 @@ class ConversationApi extends ApiClient { }); } - toggleStatus({ conversationId, status }) { + toggleStatus({ conversationId, status, snoozedUntil = null }) { return axios.post(`${this.url}/${conversationId}/toggle_status`, { status, + snoozed_until: snoozedUntil, }); } diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index b4951804a..f068b4a4f 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -69,6 +69,7 @@ describe('#ConversationAPI', () => { `/api/v1/conversations/12/toggle_status`, { status: 'online', + snoozed_until: null, } ); }); diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 2ab3ad83b..cf984c531 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -24,7 +24,7 @@ {{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }} @@ -67,20 +101,29 @@ import { mixin as clickaway } from 'vue-clickaway'; import alertMixin from 'shared/mixins/alertMixin'; 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 wootConstants from '../../constants'; +import { + getUnixTime, + addHours, + addWeeks, + startOfTomorrow, + startOfWeek, +} from 'date-fns'; export default { components: { WootDropdownItem, WootDropdownMenu, + WootDropdownSubMenu, }, mixins: [clickaway, alertMixin], props: { conversationId: { type: [String, Number], required: true } }, data() { return { isLoading: false, - showDropdown: false, + showActionsDropdown: false, STATUS_TYPE: wootConstants.STATUS_TYPE, }; }, @@ -97,30 +140,47 @@ export default { isResolved() { return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED; }, + isSnoozed() { + return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED; + }, buttonClass() { if (this.isPending) return 'primary'; if (this.isOpen) return 'success'; if (this.isResolved) return 'warning'; return ''; }, - showDropDown() { - return !this.isPending; + showAdditionalActions() { + 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: { + showOpenButton() { + return this.isResolved || this.isSnoozed; + }, closeDropdown() { - this.showDropdown = false; + this.showActionsDropdown = false; }, openDropdown() { - this.showDropdown = true; + this.showActionsDropdown = true; }, - toggleStatus(status) { + toggleStatus(status, snoozedUntil) { this.closeDropdown(); this.isLoading = true; this.$store .dispatch('toggleStatus', { conversationId: this.currentChat.id, status, + snoozedUntil, }) .then(() => { this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS')); diff --git a/app/javascript/dashboard/constants.js b/app/javascript/dashboard/constants.js index b981fd31d..58fdabdc1 100644 --- a/app/javascript/dashboard/constants.js +++ b/app/javascript/dashboard/constants.js @@ -9,6 +9,7 @@ export default { OPEN: 'open', RESOLVED: 'resolved', PENDING: 'pending', + SNOOZED: 'snoozed', }, }; export const DEFAULT_REDIRECT_URL = '/app/'; diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index a8847e758..f30c89196 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -49,6 +49,10 @@ { "TEXT": "Pending", "VALUE": "pending" + }, + { + "TEXT": "Snoozed", + "VALUE": "snoozed" } ], "ATTACHMENTS": { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 831bad781..4e7a9701d 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -41,7 +41,13 @@ "DETAILS": "details" }, "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": { "MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 042c3e2a1..6030b7b8d 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -135,11 +135,15 @@ const actions = { commit(types.default.ASSIGN_TEAM, team); }, - toggleStatus: async ({ commit }, { conversationId, status }) => { + toggleStatus: async ( + { commit }, + { conversationId, status, snoozedUntil = null } + ) => { try { const response = await ConversationApi.toggleStatus({ conversationId, status, + snoozedUntil, }); commit( types.default.RESOLVE_CONVERSATION, diff --git a/app/javascript/shared/components/ui/dropdown/DropdownSubMenu.vue b/app/javascript/shared/components/ui/dropdown/DropdownSubMenu.vue new file mode 100644 index 000000000..95813f68b --- /dev/null +++ b/app/javascript/shared/components/ui/dropdown/DropdownSubMenu.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/jobs/conversations/reopen_snoozed_conversations_job.rb b/app/jobs/conversations/reopen_snoozed_conversations_job.rb new file mode 100644 index 000000000..c11298585 --- /dev/null +++ b/app/jobs/conversations/reopen_snoozed_conversations_job.rb @@ -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 diff --git a/app/jobs/trigger_scheduled_items_job.rb b/app/jobs/trigger_scheduled_items_job.rb index 6bf85e1e4..16094dc4a 100644 --- a/app/jobs/trigger_scheduled_items_job.rb +++ b/app/jobs/trigger_scheduled_items_job.rb @@ -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| Campaigns::TriggerOneoffCampaignJob.perform_later(campaign) end + + # Job to reopen snoozed conversations + Conversations::ReopenSnoozedConversationsJob.perform_later end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 865ff0b56..f60551a87 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -8,6 +8,7 @@ # contact_last_seen_at :datetime # identifier :string # last_activity_at :datetime not null +# snoozed_until :datetime # status :integer default("open"), not null # uuid :uuid not null # created_at :datetime not null @@ -45,7 +46,7 @@ class Conversation < ApplicationRecord validates :inbox_id, presence: true 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 :unassigned, -> { where(assignee_id: nil) } @@ -64,6 +65,7 @@ class Conversation < ApplicationRecord has_one :csat_survey_response, dependent: :destroy has_many :notifications, as: :primary_actor, dependent: :destroy + before_save :ensure_snooze_until_reset before_create :mark_conversation_pending_if_bot # 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 # FIXME: implement state machine with aasm self.status = open? ? :resolved : :open - self.status = :open if pending? + self.status = :open if pending? || snoozed? save end @@ -140,6 +142,10 @@ class Conversation < ApplicationRecord private + def ensure_snooze_until_reset + self.snoozed_until = nil unless snoozed? + end + def validate_additional_attributes self.additional_attributes = {} unless additional_attributes.is_a?(Hash) end diff --git a/app/models/message.rb b/app/models/message.rb index 0f2ec21c5..f9c473dbe 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -164,7 +164,10 @@ class Message < ApplicationRecord end 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 def execute_message_template_hooks diff --git a/config/locales/en.yml b/config/locales/en.yml index cae92afb8..4ef8178d4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -64,6 +64,7 @@ en: resolved: "Conversation was marked resolved by %{user_name}" open: "Conversation was reopened 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" assignee: self_assigned: "%{user_name} self-assigned this conversation" diff --git a/db/migrate/20210721182458_add_snoozed_until_to_conversations.rb b/db/migrate/20210721182458_add_snoozed_until_to_conversations.rb new file mode 100644 index 000000000..9e4fbca41 --- /dev/null +++ b/db/migrate/20210721182458_add_snoozed_until_to_conversations.rb @@ -0,0 +1,5 @@ +class AddSnoozedUntilToConversations < ActiveRecord::Migration[6.0] + def change + add_column :conversations, :snoozed_until, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 3eb64e8f8..b8e10c455 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 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.bigint "team_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"], name: "index_conversations_on_account_id" t.index ["campaign_id"], name: "index_conversations_on_campaign_id" diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index bb75ba54e..7b66656c2 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -306,6 +306,19 @@ RSpec.describe 'Conversations API', type: :request do expect(conversation.reload.status).to eq('pending') 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 # Added for backwards compatibility for bot status it 'toggles the conversation status to pending status when parameter bot is passed' do diff --git a/spec/jobs/conversations/reopen_snoozed_conversations_job_spec.rb b/spec/jobs/conversations/reopen_snoozed_conversations_job_spec.rb new file mode 100644 index 000000000..d58b13d52 --- /dev/null +++ b/spec/jobs/conversations/reopen_snoozed_conversations_job_spec.rb @@ -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 diff --git a/spec/jobs/trigger_scheduled_items_job_spec.rb b/spec/jobs/trigger_scheduled_items_job_spec.rb index 4ec0d331e..2851996b6 100644 --- a/spec/jobs/trigger_scheduled_items_job_spec.rb +++ b/spec/jobs/trigger_scheduled_items_job_spec.rb @@ -20,5 +20,10 @@ RSpec.describe TriggerScheduledItemsJob, type: :job do expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once described_class.perform_now end + + it 'triggers Conversations::ReopenSnoozedConversationsJob' do + expect(Conversations::ReopenSnoozedConversationsJob).to receive(:perform_later).once + described_class.perform_now + end end end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index c21db3044..08f9b001e 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -181,14 +181,38 @@ RSpec.describe Conversation, type: :model do end describe '#toggle_status' do - subject(:toggle_status) { conversation.toggle_status } - - let(:conversation) { create(:conversation, status: :open) } - - it 'toggles conversation status' do - expect(toggle_status).to eq(true) + it 'toggles conversation status to resolved when open' do + conversation = create(:conversation, status: 'open') + expect(conversation.toggle_status).to eq(true) expect(conversation.reload.status).to eq('resolved') 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 describe '#mute!' do diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index e08a152d9..43b997b47 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -9,6 +9,30 @@ RSpec.describe Message, type: :model do it { is_expected.to validate_presence_of(:account_id) } 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 let(:message) { build(:message, account: create(:account)) }