diff --git a/.env.example b/.env.example index 5a6ab50ba..405548032 100644 --- a/.env.example +++ b/.env.example @@ -52,11 +52,11 @@ SMTP_ENABLE_STARTTLS_AUTO= MAILER_INBOUND_EMAIL_DOMAIN= # Set this to appropriate ingress channel with regards to incoming emails # Possible values are : -# :relay for Exim, Postfix, Qmail -# :mailgun for Mailgun -# :mandrill for Mandrill -# :postmark for Postmark -# :sendgrid for Sendgrid +# relay for Exim, Postfix, Qmail +# mailgun for Mailgun +# mandrill for Mandrill +# postmark for Postmark +# sendgrid for Sendgrid RAILS_INBOUND_EMAIL_SERVICE= # Use one of the following based on the email ingress service # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html @@ -117,6 +117,17 @@ IOS_APP_ID=6C953F3RX2.com.chatwoot.app ## Bot Customizations USE_INBOX_AVATAR_FOR_BOT=true + + +## IP look up configuration +## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md +## works only on accounts with ip look up feature enabled +# IP_LOOKUP_SERVICE=geoip2 +# maxmindb api key to use geoip2 service +# IP_LOOKUP_API_KEY= + ## Development Only Config # if you want to use letter_opener for local emails # LETTER_OPENER=true + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6f380449e..4ce1b084c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,9 +24,9 @@ Please describe the tests that you ran to verify your changes. Provide instructi - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code -- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published in downstream modules \ No newline at end of file +- [ ] Any dependent changes have been merged and published in downstream modules diff --git a/.gitignore b/.gitignore index 146ae509f..0c71b2055 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ /tmp/* !/log/.keep !/tmp/.keep +*.mmdb # Ignore Byebug command history file. .byebug_history @@ -58,4 +59,4 @@ package-lock.json # cypress -test/cypress/videos/* \ No newline at end of file +test/cypress/videos/* diff --git a/.rubocop.yml b/.rubocop.yml index c27b9651b..9cd50a44f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -124,3 +124,4 @@ AllCops: - 'tmp/**/*' - 'storage/**/*' - 'db/migrate/20200225162150_init_schema.rb' + - 'config/initializers/azure_storage_service_patch.rb' diff --git a/Gemfile b/Gemfile index 32dab102c..ba24662fb 100644 --- a/Gemfile +++ b/Gemfile @@ -90,6 +90,12 @@ gem 'sidekiq' gem 'fcm' gem 'webpush' +##-- geocoding / parse location from ip --## +# http://www.rubygeocoder.com/ +gem 'geocoder' +# to parse maxmind db +gem 'maxminddb' + group :development do gem 'annotate' gem 'bullet' diff --git a/Gemfile.lock b/Gemfile.lock index a616cdf68..8b9ea88b3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -201,6 +201,7 @@ GEM ffi (1.13.1) flag_shih_tzu (0.3.23) foreman (0.87.2) + geocoder (1.6.3) gli (2.19.2) globalid (0.4.2) activesupport (>= 4.2.0) @@ -290,6 +291,7 @@ GEM mini_mime (>= 0.1.1) marcel (0.3.3) mimemagic (~> 0.3.2) + maxminddb (0.1.22) memoist (0.16.2) method_source (1.0.0) mime-types (3.3.1) @@ -578,6 +580,7 @@ DEPENDENCIES fcm flag_shih_tzu foreman + geocoder google-cloud-storage groupdate haikunator @@ -590,6 +593,7 @@ DEPENDENCIES letter_opener liquid listen + maxminddb mini_magick mock_redis! pg diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 1b9fa24cc..0cfd7205b 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -10,4 +10,10 @@ class Api::BaseController < ApplicationController def authenticate_by_access_token? request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present? end + + def check_authorization(model = nil) + model ||= controller_name.classify.constantize + + authorize(model) + end end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index dacd66e46..83f1ad186 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -27,7 +27,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController private def check_authorization - authorize(User) + super(User) end def fetch_agent @@ -64,6 +64,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def agents - @agents ||= Current.account.users.order_by_full_name + @agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] }) end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index a82a30414..d476c744c 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -1,17 +1,30 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController + RESULTS_PER_PAGE = 15 protect_from_forgery with: :null_session before_action :check_authorization + before_action :set_current_page, only: [:index, :active, :search] before_action :fetch_contact, only: [:show, :update] def index - @contacts = Current.account.contacts + @contacts_count = resolved_contacts.count + @contacts = fetch_contact_last_seen_at(resolved_contacts) + end + + def search + render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return + + contacts = resolved_contacts.where('name LIKE :search OR email LIKE :search', search: "%#{params[:q]}%") + @contacts_count = contacts.count + @contacts = fetch_contact_last_seen_at(contacts) end # returns online contacts def active - @contacts = Current.account.contacts.where(id: ::OnlineStatusTracker + contacts = Current.account.contacts.where(id: ::OnlineStatusTracker .get_available_contact_ids(Current.account.id)) + @contacts_count = contacts.count + @contacts = contacts.page(@current_page) end def show; end @@ -19,13 +32,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def create ActiveRecord::Base.transaction do @contact = Current.account.contacts.new(contact_params) + set_ip @contact.save! @contact_inbox = build_contact_inbox end end def update - @contact.update!(contact_update_params) + @contact.assign_attributes(contact_update_params) + set_ip + @contact.save! rescue ActiveRecord::RecordInvalid => e render json: { message: e.record.errors.full_messages.join(', '), @@ -33,16 +49,25 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController }, status: :unprocessable_entity end - def search - render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return - - @contacts = Current.account.contacts.where('name LIKE :search OR email LIKE :search', search: "%#{params[:q]}%") - end - private - def check_authorization - authorize(Contact) + def resolved_contacts + @resolved_contacts ||= Current.account.contacts + .where.not(email: [nil, '']) + .or(Current.account.contacts.where.not(phone_number: [nil, ''])) + .order('LOWER(name)') + end + + def set_current_page + @current_page = params[:page] || 1 + end + + def fetch_contact_last_seen_at(contacts) + contacts.left_outer_joins(:conversations) + .select('contacts.*, COUNT(conversations.id) as conversations_count, MAX(conversations.contact_last_seen_at) as last_seen_at') + .group('contacts.id') + .includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }]) + .page(@current_page).per(RESULTS_PER_PAGE) end def build_contact_inbox @@ -71,4 +96,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def fetch_contact @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) end + + def set_ip + return if @contact.account.feature_enabled?('ip_lookup') + + @contact[:additional_attributes][:created_at_ip] ||= request.remote_ip + @contact[:additional_attributes][:updated_at_ip] = request.remote_ip + end end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 98163bae7..0f573bbb9 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController before_action :check_authorization def index - @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, :avatar_attachment)) + @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) end def create @@ -55,10 +55,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end - def check_authorization - authorize(Inbox) - end - def create_channel case permitted_params[:channel][:type] when 'web_widget' @@ -84,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/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 12c026e66..547b9e6d6 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -28,10 +28,6 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController @label = Current.account.labels.find(params[:id]) end - def check_authorization - authorize(Label) - end - def permitted_params params.require(:label).permit(:title, :description, :color, :show_on_sidebar) end diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 9e61904d6..58f9b21a0 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -29,8 +29,4 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController def fetch_webhook @webhook = Current.account.webhooks.find(params[:id]) end - - def check_authorization - authorize(Webhook) - end end 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/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 933bd5a3a..51dcb1a57 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -33,7 +33,7 @@ class Api::V1::AccountsController < Api::BaseController end def update - @account.update!(account_params.slice(:name, :locale, :domain, :support_email)) + @account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration)) end def update_active_at @@ -44,10 +44,6 @@ class Api::V1::AccountsController < Api::BaseController private - def check_authorization - authorize(Account) - end - def confirmed? super_admin? && params[:confirmed] end @@ -58,7 +54,7 @@ class Api::V1::AccountsController < Api::BaseController end def account_params - params.permit(:account_name, :email, :name, :locale, :domain, :support_email) + params.permit(:account_name, :email, :name, :locale, :domain, :support_email, :auto_resolve_duration) end def check_signup_enabled diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index c4f563d5d..3203f34e9 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -9,6 +9,18 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: account_summary_metrics end + def agents + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=agents_report.csv' + render layout: false, template: 'api/v2/accounts/reports/agents.csv.erb', format: 'csv' + end + + def inboxes + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=inboxes_report.csv' + render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv' + end + private def account_summary_params diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index f4bbcc149..2aa7cb321 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -25,7 +25,7 @@ class ConversationFinder set_assignee_type find_all_conversations - filter_by_status + filter_by_status unless params[:q] filter_by_labels if params[:labels] filter_by_query if params[:q] @@ -62,9 +62,7 @@ class ConversationFinder end def find_all_conversations - @conversations = current_account.conversations.includes( - :assignee, :inbox, :taggings, contact: [:avatar_attachment] - ).where(inbox_id: @inbox_ids) + @conversations = current_account.conversations.where(inbox_id: @inbox_ids) end def filter_by_assignee_type @@ -78,9 +76,11 @@ class ConversationFinder end def filter_by_query - @conversations = @conversations.joins(:messages).where('messages.content LIKE :search', - search: "%#{params[:q]}%").includes(:messages).where('messages.content LIKE :search', - search: "%#{params[:q]}%") + allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]] + @conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%") + .where(messages: { message_type: allowed_message_types }).includes(:messages) + .where('messages.content ILIKE :search', search: "%#{params[:q]}%") + .where(messages: { message_type: allowed_message_types }) end def filter_by_status @@ -104,6 +104,9 @@ class ConversationFinder end def conversations + @conversations = @conversations.includes( + :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } } + ) current_page ? @conversations.latest.page(current_page) : @conversations.latest end end diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 5295dce74..63c72bd8e 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -11,7 +11,7 @@ class MessageFinder private def conversation_messages - @conversation.messages.includes(:attachments, :sender) + @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] }) end def messages diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 0988141d3..6429c53d2 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -6,9 +6,17 @@ class ContactAPI extends ApiClient { super('contacts', { accountScoped: true }); } + get(page) { + return axios.get(`${this.url}?page=${page}`); + } + getConversations(contactId) { return axios.get(`${this.url}/${contactId}/conversations`); } + + search(search = '', page = 1) { + return axios.get(`${this.url}/search?q=${search}&page=${page}`); + } } export default new ContactAPI(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 6e5c72ca0..9f87d2aa5 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -18,6 +18,15 @@ class ConversationApi extends ApiClient { }); } + search({ q }) { + return axios.get(`${this.url}/search`, { + params: { + q, + page: 1, + }, + }); + } + toggleStatus(conversationId) { return axios.post(`${this.url}/${conversationId}/toggle_status`, {}); } diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 8dedcc5d2..0773c769f 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -12,8 +12,8 @@ class ReportsAPI extends ApiClient { }); } - getAccountSummary(accountId, since, until) { - return axios.get(`${this.url}/${accountId}/account_summary`, { + getAccountSummary(since, until) { + return axios.get(`${this.url}/account_summary`, { params: { since, until }, }); } diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 7f5ef39c9..7f9041b93 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -26,3 +26,4 @@ @import 'views/signup'; @import 'plugins/multiselect'; +@import 'plugins/dropdown'; diff --git a/app/javascript/dashboard/assets/scss/app.scss b/app/javascript/dashboard/assets/scss/app.scss index 92edc8e42..3220f0326 100644 --- a/app/javascript/dashboard/assets/scss/app.scss +++ b/app/javascript/dashboard/assets/scss/app.scss @@ -2,6 +2,7 @@ @import 'shared/assets/stylesheets/colors'; @import 'shared/assets/stylesheets/spacing'; @import 'shared/assets/stylesheets/font-size'; +@import 'shared/assets/stylesheets/font-weights'; @import 'variables'; @import '~spinkit/scss/spinners/7-three-bounce'; diff --git a/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss new file mode 100644 index 000000000..b4dada351 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/plugins/_dropdown.scss @@ -0,0 +1,27 @@ +.dropdown-pane.sleek { + @include elegant-card; + @include border-light; + padding-left: 0; + padding-right: 0; + right: -12px; + top: 48px; + width: auto; + + &::before { + @include arrow(top, var(--color-border-light), 14px); + position: absolute; + right: 6px; + top: -14px; + } + + &::after { + @include arrow(top, $color-white, var(--space-slab)); + position: absolute; + right: var(--space-small); + top: -12px; + } + + .dropdown>li>a:hover { + background: var(--color-background); + } +} diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss index 7e141d30e..280997559 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss @@ -38,6 +38,11 @@ &.round { border-radius: $space-larger; } + + &.compact { + padding-bottom: 0; + padding-top: 0; + } } .button--fixed-right-top { diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index dde956eaa..ae89fe9a2 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -34,7 +34,7 @@ } .modal-image { - max-width: 80%; + max-width: 85%; } &::before { @@ -72,7 +72,7 @@ .chat-list__top { @include flex; - @include padding($space-normal $zero $space-small $zero); + @include padding($zero $zero $space-small $zero); align-items: center; justify-content: space-between; diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 5ca82a407..27f89b550 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,5 +1,6 @@ + + diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index b60932c5f..d19e3ba0e 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -2,13 +2,11 @@
- {{ currentUserAvailabilityStatus }} + {{ availabilityDisplayLabel }}
@@ -20,7 +18,13 @@ class="dropdown-pane top" >