From 65ed4c78a42889803691dd631b05c0d15e21a6be Mon Sep 17 00:00:00 2001 From: Adam Zysko <1789131+azyzio@users.noreply.github.com> Date: Sat, 31 Oct 2020 19:44:33 +0100 Subject: [PATCH] feat: Business hours Data models and APIs for business hours ref: #234 --- .gitignore | 2 +- .../api/v1/accounts/inboxes_controller.rb | 1 + .../v1/accounts/working_hours_controller.rb | 18 +++++ app/models/account.rb | 2 + app/models/channel/web_widget.rb | 2 +- app/models/concerns/out_of_offisable.rb | 30 ++++++++ app/models/inbox.rb | 3 + app/models/message.rb | 1 + app/models/working_hour.rb | 71 +++++++++++++++++++ .../hook_execution_service.rb | 10 ++- .../template/out_of_office.rb | 28 ++++++++ config/routes.rb | 1 + .../20201027135006_create_working_hours.rb | 40 +++++++++++ db/schema.rb | 20 +++++- lib/current.rb | 5 ++ spec/factories/messages.rb | 2 +- spec/factories/working_hours.rb | 12 ++++ spec/models/concerns/out_of_offisable_spec.rb | 19 +++++ spec/models/inbox_spec.rb | 5 ++ spec/models/working_hour_spec.rb | 29 ++++++++ spec/rails_helper.rb | 1 + .../hook_execution_service_spec.rb | 21 ++++++ .../template/out_of_office_spec.rb | 13 ++++ 23 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 app/controllers/api/v1/accounts/working_hours_controller.rb create mode 100644 app/models/concerns/out_of_offisable.rb create mode 100644 app/models/working_hour.rb create mode 100644 app/services/message_templates/template/out_of_office.rb create mode 100644 db/migrate/20201027135006_create_working_hours.rb create mode 100644 spec/factories/working_hours.rb create mode 100644 spec/models/concerns/out_of_offisable_spec.rb create mode 100644 spec/models/working_hour_spec.rb create mode 100644 spec/services/message_templates/template/out_of_office_spec.rb diff --git a/.gitignore b/.gitignore index 7c63cfa98..0c71b2055 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,4 @@ package-lock.json # cypress -test/cypress/videos/* \ No newline at end of file +test/cypress/videos/* diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 41b64b76d..a2a879376 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -80,6 +80,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def inbox_update_params params.permit(:enable_auto_assignment, :name, :avatar, :greeting_message, :greeting_enabled, + :working_hours_enabled, :out_of_office_message, channel: [ :website_url, :widget_color, diff --git a/app/controllers/api/v1/accounts/working_hours_controller.rb b/app/controllers/api/v1/accounts/working_hours_controller.rb new file mode 100644 index 000000000..96d98293a --- /dev/null +++ b/app/controllers/api/v1/accounts/working_hours_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController + before_action :check_authorization + before_action :fetch_webhook, only: [:update] + + def update + @working_hour.update!(working_hour_params) + end + + private + + def working_hour_params + params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day) + end + + def fetch_working_hour + @working_hour = Current.account.working_hours.find(params[:id]) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 6df7e2e03..d977235e1 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -9,6 +9,7 @@ # name :string not null # settings_flags :integer default(0), not null # support_email :string(100) +# timezone :string default("UTC") # created_at :datetime not null # updated_at :datetime not null # @@ -48,6 +49,7 @@ class Account < ApplicationRecord has_many :labels, dependent: :destroy has_many :notification_settings, dependent: :destroy has_many :hooks, dependent: :destroy, class_name: 'Integrations::Hook' + has_many :working_hours, dependent: :destroy has_many :kbase_portals, dependent: :destroy, class_name: '::Kbase::Portal' has_many :kbase_categories, dependent: :destroy, class_name: '::Kbase::Category' has_many :kbase_articles, dependent: :destroy, class_name: '::Kbase::Article' diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index c840441ac..637403f64 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -4,7 +4,7 @@ # # id :integer not null, primary key # feature_flags :integer default(3), not null -# reply_time :integer default(0) +# reply_time :integer default("in_a_few_minutes") # website_token :string # website_url :string # welcome_tagline :string diff --git a/app/models/concerns/out_of_offisable.rb b/app/models/concerns/out_of_offisable.rb new file mode 100644 index 000000000..0bfcbde32 --- /dev/null +++ b/app/models/concerns/out_of_offisable.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module OutOfOffisable + extend ActiveSupport::Concern + + included do + has_many :working_hours, dependent: :destroy + after_create :create_default_working_hours + end + + def out_of_office? + working_hours_enabled? && working_hours.today.closed_now? + end + + def working_now? + !out_of_office? + end + + private + + def create_default_working_hours + working_hours.create!(day_of_week: 1, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + working_hours.create!(day_of_week: 2, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + working_hours.create!(day_of_week: 3, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + working_hours.create!(day_of_week: 4, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + working_hours.create!(day_of_week: 5, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + working_hours.create!(day_of_week: 6, closed_all_day: true) + working_hours.create!(day_of_week: 7, closed_all_day: true) + end +end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 51dc47da7..663078e70 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -11,6 +11,8 @@ # greeting_enabled :boolean default(FALSE) # greeting_message :string # name :string not null +# out_of_office_message :string +# working_hours_enabled :boolean default(FALSE) # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null @@ -24,6 +26,7 @@ class Inbox < ApplicationRecord include Reportable include Avatarable + include OutOfOffisable validates :account_id, presence: true diff --git a/app/models/message.rb b/app/models/message.rb index c1348a75d..bae73fc1e 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -62,6 +62,7 @@ class Message < ApplicationRecord # .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) } scope :chat, -> { where.not(message_type: :activity).where(private: false) } + scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) } default_scope { order(created_at: :asc) } belongs_to :account diff --git a/app/models/working_hour.rb b/app/models/working_hour.rb new file mode 100644 index 000000000..e73759ac0 --- /dev/null +++ b/app/models/working_hour.rb @@ -0,0 +1,71 @@ +# == Schema Information +# +# Table name: working_hours +# +# id :bigint not null, primary key +# close_hour :integer +# close_minutes :integer +# closed_all_day :boolean default(FALSE) +# day_of_week :integer not null +# open_hour :integer +# open_minutes :integer +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint +# inbox_id :bigint +# +# Indexes +# +# index_working_hours_on_account_id (account_id) +# index_working_hours_on_inbox_id (inbox_id) +# +class WorkingHour < ApplicationRecord + belongs_to :inbox + + before_save :assign_account + + validates :open_hour, presence: true, unless: :closed_all_day? + validates :open_minutes, presence: true, unless: :closed_all_day? + validates :close_hour, presence: true, unless: :closed_all_day? + validates :close_minutes, presence: true, unless: :closed_all_day? + + validates :open_hour, inclusion: 0..23, unless: :closed_all_day? + validates :close_hour, inclusion: 0..23, unless: :closed_all_day? + validates :open_minutes, inclusion: 0..59, unless: :closed_all_day? + validates :close_minutes, inclusion: 0..59, unless: :closed_all_day? + + validate :close_after_open, unless: :closed_all_day? + + def self.today + find_by(day_of_week: Date.current.cwday) + end + + def open_at?(time) + return false if closed_all_day? + + time.hour >= open_hour && + time.min >= open_minutes && + time.hour <= close_hour && + time.min <= close_minutes + end + + def open_now? + open_at?(Time.zone.now) + end + + def closed_now? + !open_now? + end + + private + + def assign_account + self.account_id = inbox.account_id + end + + def close_after_open + return unless open_hour.hours + open_minutes.minutes >= close_hour.hours + close_minutes.minutes + + errors.add(:close_hour, 'Closing time cannot be before opening time') + end +end diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index 7fd41ab61..f8371aad9 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -4,8 +4,8 @@ class MessageTemplates::HookExecutionService def perform return if inbox.agent_bot_inbox&.active? + ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? ::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting? - ::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if should_send_email_collect? end @@ -14,12 +14,16 @@ class MessageTemplates::HookExecutionService delegate :inbox, :conversation, to: :message delegate :contact, to: :conversation + def should_send_out_of_office_message? + inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present? + end + def first_message_from_contact? conversation.messages.outgoing.count.zero? && conversation.messages.template.count.zero? end def should_send_greeting? - first_message_from_contact? && conversation.inbox.greeting_enabled? && conversation.inbox.greeting_message.present? + first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present? end def email_collect_was_sent? @@ -27,7 +31,7 @@ class MessageTemplates::HookExecutionService end def should_send_email_collect? - !contact_has_email? && conversation.inbox.web_widget? && !email_collect_was_sent? + !contact_has_email? && inbox.web_widget? && !email_collect_was_sent? end def contact_has_email? diff --git a/app/services/message_templates/template/out_of_office.rb b/app/services/message_templates/template/out_of_office.rb new file mode 100644 index 000000000..eb0a19c7f --- /dev/null +++ b/app/services/message_templates/template/out_of_office.rb @@ -0,0 +1,28 @@ +class MessageTemplates::Template::OutOfOffice + pattr_initialize [:conversation!] + + def perform + ActiveRecord::Base.transaction do + conversation.messages.create!(out_of_office_message_params) + end + rescue StandardError => e + Raven.capture_exception(e) + true + end + + private + + delegate :contact, :account, to: :conversation + delegate :inbox, to: :message + + def out_of_office_message_params + content = @conversation.inbox&.out_of_office_message + + { + account_id: @conversation.account_id, + inbox_id: @conversation.inbox_id, + message_type: :template, + content: content + } + end +end diff --git a/config/routes.rb b/config/routes.rb index f0dfd57ff..291373ca1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -100,6 +100,7 @@ Rails.application.routes.draw do resources :apps, only: [:index, :show] resource :slack, only: [:create, :update, :destroy], controller: 'slack' end + resources :working_hours, only: [:update] namespace :kbase do resources :portals do diff --git a/db/migrate/20201027135006_create_working_hours.rb b/db/migrate/20201027135006_create_working_hours.rb new file mode 100644 index 000000000..6d06e385f --- /dev/null +++ b/db/migrate/20201027135006_create_working_hours.rb @@ -0,0 +1,40 @@ +class CreateWorkingHours < ActiveRecord::Migration[6.0] + def change + create_table :working_hours do |t| + t.belongs_to :inbox + t.belongs_to :account + + t.integer :day_of_week, null: false + t.boolean :closed_all_day, default: false + t.integer :open_hour + t.integer :open_minutes + t.integer :close_hour + t.integer :close_minutes + + t.timestamps + end + + add_column :accounts, :timezone, :string, default: 'UTC' + + change_table :inboxes, bulk: true do |t| + t.boolean :working_hours_enabled, default: false + t.string :out_of_office_message + end + + Inbox.where.not(id: WorkingHour.select(:inbox_id)).each do |inbox| + create_working_hours_for_inbox(inbox) + end + end + + private + + def create_working_hours_for_inbox(inbox) + inbox.working_hours.create!(day_of_week: 1, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + inbox.working_hours.create!(day_of_week: 2, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + inbox.working_hours.create!(day_of_week: 3, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + inbox.working_hours.create!(day_of_week: 4, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + inbox.working_hours.create!(day_of_week: 5, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0) + inbox.working_hours.create!(day_of_week: 6, closed_all_day: true) + inbox.working_hours.create!(day_of_week: 7, closed_all_day: true) + end +end diff --git a/db/schema.rb b/db/schema.rb index d50af9ed3..37aa4dc3e 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: 2020_10_19_173944) do +ActiveRecord::Schema.define(version: 2020_10_27_135006) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -49,6 +49,7 @@ ActiveRecord::Schema.define(version: 2020_10_19_173944) do t.string "support_email", limit: 100 t.integer "settings_flags", default: 0, null: false t.integer "feature_flags", default: 0, null: false + t.string "timezone", default: "UTC" end create_table "action_mailbox_inbound_emails", force: :cascade do |t| @@ -280,6 +281,8 @@ ActiveRecord::Schema.define(version: 2020_10_19_173944) do t.boolean "greeting_enabled", default: false t.string "greeting_message" t.string "email_address" + t.boolean "working_hours_enabled", default: false + t.string "out_of_office_message" t.index ["account_id"], name: "index_inboxes_on_account_id" end @@ -513,6 +516,21 @@ ActiveRecord::Schema.define(version: 2020_10_19_173944) do t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true end + create_table "working_hours", force: :cascade do |t| + t.bigint "inbox_id" + t.bigint "account_id" + t.integer "day_of_week", null: false + t.boolean "closed_all_day", default: false + t.integer "open_hour" + t.integer "open_minutes" + t.integer "close_hour" + t.integer "close_minutes" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id"], name: "index_working_hours_on_account_id" + t.index ["inbox_id"], name: "index_working_hours_on_inbox_id" + end + add_foreign_key "account_users", "accounts" add_foreign_key "account_users", "users" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" diff --git a/lib/current.rb b/lib/current.rb index 71e69ead7..cbc07cbed 100644 --- a/lib/current.rb +++ b/lib/current.rb @@ -2,4 +2,9 @@ module Current thread_mattr_accessor :user thread_mattr_accessor :account thread_mattr_accessor :account_user + + def account=(account) + super + Time.zone = account.timezone + end end diff --git a/spec/factories/messages.rb b/spec/factories/messages.rb index ed9574e8b..9a0d3be47 100644 --- a/spec/factories/messages.rb +++ b/spec/factories/messages.rb @@ -10,7 +10,7 @@ FactoryBot.define do after(:build) do |message| message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account) - message.inbox ||= create(:inbox, account: message.account) + message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account) message.conversation ||= create(:conversation, account: message.account, inbox: message.inbox) end end diff --git a/spec/factories/working_hours.rb b/spec/factories/working_hours.rb new file mode 100644 index 000000000..156caedd2 --- /dev/null +++ b/spec/factories/working_hours.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :working_hour do + inbox + day_of_week { 1 } + open_hour { 9 } + open_minutes { 0 } + close_hour { 17 } + close_minutes { 0 } + end +end diff --git a/spec/models/concerns/out_of_offisable_spec.rb b/spec/models/concerns/out_of_offisable_spec.rb new file mode 100644 index 000000000..cbdef0e81 --- /dev/null +++ b/spec/models/concerns/out_of_offisable_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +shared_examples_for 'out_of_offisable' do + let(:obj) { create(described_class.to_s.underscore, working_hours_enabled: true, out_of_office_message: 'Message') } + + it 'has after create callback' do + expect(obj.working_hours.count).to eq(7) + end + + it 'is working on monday 10am' do + travel_to '26.10.2020 10:00'.to_datetime + expect(obj.working_now?).to be true + end + + it 'is out of office on sunday 1pm' do + travel_to '01.11.2020 13:00'.to_datetime + expect(obj.out_of_office?).to be true + end +end diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb index e494173e8..04dd265ce 100644 --- a/spec/models/inbox_spec.rb +++ b/spec/models/inbox_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rails_helper' +require Rails.root.join 'spec/models/concerns/out_of_offisable_spec.rb' RSpec.describe Inbox do describe 'validations' do @@ -33,6 +34,10 @@ RSpec.describe Inbox do it { is_expected.to have_many(:hooks) } end + describe 'concerns' do + it_behaves_like 'out_of_offisable' + end + describe '#add_member' do let(:inbox) { FactoryBot.create(:inbox) } let(:user) { FactoryBot.create(:user) } diff --git a/spec/models/working_hour_spec.rb b/spec/models/working_hour_spec.rb new file mode 100644 index 000000000..6222b8f7f --- /dev/null +++ b/spec/models/working_hour_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe WorkingHour do + context 'when on monday 10am' do + before do + Time.zone = 'UTC' + create(:working_hour) + travel_to '26.10.2020 10:00'.to_datetime + end + + it 'is considered working hour' do + expect(described_class.today.open_now?).to be true + end + end + + context 'when on sunday 1pm' do + before do + Time.zone = 'UTC' + create(:working_hour, day_of_week: 7, closed_all_day: true) + travel_to '01.11.2020 13:00'.to_datetime + end + + it 'is considered out of office' do + expect(described_class.today.closed_now?).to be true + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bb4475cca..b219a337c 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,6 +65,7 @@ RSpec.configure do |config| # config.filter_gems_from_backtrace("gem name") config.include SlackStubs config.include Devise::Test::IntegrationHelpers, type: :request + config.include ActiveSupport::Testing::TimeHelpers end Shoulda::Matchers.configure do |config| diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb index 07cd303d3..ce64d2bb2 100644 --- a/spec/services/message_templates/hook_execution_service_spec.rb +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -45,4 +45,25 @@ describe ::MessageTemplates::HookExecutionService do expect(email_collect_service).to have_received(:perform) end end + + context 'when it is after working hours' do + it 'calls ::MessageTemplates::Template::OutOfOffice' do + contact = create :contact + conversation = create :conversation, contact: contact + + conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + conversation.inbox.working_hours.today.update!(closed_all_day: true) + + out_of_office_service = double + + allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) + + # described class gets called in message after commit + message = create(:message, conversation: conversation) + + expect(::MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation) + expect(out_of_office_service).to have_received(:perform) + end + end end diff --git a/spec/services/message_templates/template/out_of_office_spec.rb b/spec/services/message_templates/template/out_of_office_spec.rb new file mode 100644 index 000000000..ee2050a70 --- /dev/null +++ b/spec/services/message_templates/template/out_of_office_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +describe ::MessageTemplates::Template::OutOfOffice do + context 'when this hook is called' do + let(:conversation) { create(:conversation) } + + it 'creates the out of office messages' do + described_class.new(conversation: conversation).perform + expect(conversation.messages.template.count).to eq(1) + expect(conversation.messages.template.first.content).to eq(conversation.inbox.out_of_office_message) + end + end +end