diff --git a/.env.example b/.env.example index d76338e28..177cb7fc8 100644 --- a/.env.example +++ b/.env.example @@ -254,7 +254,6 @@ AZURE_APP_SECRET= # Sentiment analysis model file path SENTIMENT_FILE_PATH= - # Housekeeping/Performance related configurations # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..1015fe997 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +## All javascript files should be reviewed by pranav before merging +*.js @pranavrajs +*.vue @pranavrajs + + +## All enterprise related files should be reviewed by sojan before merging +/enterprise/* @sojan-official diff --git a/.github/workflows/logging_percentage_check.yml b/.github/workflows/logging_percentage_check.yml index 7b5627d45..e9f84c313 100644 --- a/.github/workflows/logging_percentage_check.yml +++ b/.github/workflows/logging_percentage_check.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Check for log lines and calculate percentage run: | diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index 034c08e8c..d11fe6401 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -4,7 +4,7 @@ # # This action will try to install and setup # # chatwoot on an Ubuntu 20.04 machine using # # the linux installer script. -# # +# # # # This is set to run daily at midnight. # # @@ -35,7 +35,7 @@ jobs: run: | sudo ./install.sh --install < input - # disabling http verify for now as http + # disabling http verify for now as http # access to port 3000 fails in gh action env # - name: Verify # if: always() @@ -45,7 +45,7 @@ jobs: # curl http://localhost:3000/api - name: Upload chatwoot setup log file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: chatwoot-setup-log-file diff --git a/.github/workflows/publish_codespace_image.yml b/.github/workflows/publish_codespace_image.yml index 60632d906..647608473 100644 --- a/.github/workflows/publish_codespace_image.yml +++ b/.github/workflows/publish_codespace_image.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v1 diff --git a/.github/workflows/publish_foss_docker.yml b/.github/workflows/publish_foss_docker.yml index 2ddaba7e5..d48b82a58 100644 --- a/.github/workflows/publish_foss_docker.yml +++ b/.github/workflows/publish_foss_docker.yml @@ -21,7 +21,7 @@ jobs: GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v1 diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 7a32f3fd1..b0b2372ae 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -41,7 +41,7 @@ jobs: options: --entrypoint redis-server steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -50,7 +50,7 @@ jobs: with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 20 cache: yarn @@ -76,11 +76,11 @@ jobs: - name: Run backend tests run: | bundle exec rspec --profile=10 --format documentation - env: + env: NODE_OPTIONS: --openssl-legacy-provider - name: Upload rails log folder - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: rails-log-folder diff --git a/.github/workflows/run_response_bot_spec.yml b/.github/workflows/run_response_bot_spec.yml index 6fd6a7b22..77b96c48a 100644 --- a/.github/workflows/run_response_bot_spec.yml +++ b/.github/workflows/run_response_bot_spec.yml @@ -40,7 +40,7 @@ jobs: options: --entrypoint redis-server steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -49,7 +49,7 @@ jobs: with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 20 cache: yarn @@ -77,7 +77,7 @@ jobs: --format documentation - name: Upload rails log folder - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: rails-log-folder diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 01d72339d..0526aeedb 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -19,7 +19,7 @@ jobs: with: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 20 cache: 'yarn' @@ -31,7 +31,7 @@ jobs: run: | rm -rf enterprise rm -rf spec/enterprise - + - name: Run asset compile run: bundle exec rake assets:precompile env: diff --git a/.rubocop.yml b/.rubocop.yml index 484e424ed..5add6e26a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,7 +2,6 @@ require: - rubocop-performance - rubocop-rails - rubocop-rspec -inherit_from: .rubocop_todo.yml Layout/LineLength: Max: 150 @@ -12,7 +11,8 @@ Metrics/ClassLength: Exclude: - 'app/models/message.rb' - 'app/models/conversation.rb' - +Metrics/MethodLength: + Max: 19 RSpec/ExampleLength: Max: 25 Style/Documentation: @@ -50,6 +50,7 @@ Lint/OrAssignmentToConstant: Exclude: - 'lib/redis/config.rb' Metrics/BlockLength: + Max: 30 Exclude: - spec/**/* - '**/routes.rb' @@ -102,84 +103,31 @@ RSpec/FactoryBot/SyntaxMethods: Enabled: false Naming/VariableNumber: Enabled: false -Metrics/MethodLength: +Naming/MemoizedInstanceVariableName: Exclude: - - 'db/migrate/20161123131628_devise_token_auth_create_users.rb' - - 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb' -Rails/CreateTableWithTimestamps: - Exclude: - - 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb' + - 'app/models/message.rb' Style/GuardClause: Exclude: - 'app/builders/account_builder.rb' - 'app/models/attachment.rb' - 'app/models/message.rb' - - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' Metrics/AbcSize: + Max: 26 Exclude: - 'app/controllers/concerns/auth_helper.rb' - - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' - - 'db/migrate/20161123131628_devise_token_auth_create_users.rb' - - 'app/controllers/api/v1/accounts/inboxes_controller.rb' - - 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb' -Metrics/CyclomaticComplexity: - Max: 7 - Exclude: - - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' -Rails/ReversibleMigration: - Exclude: - - 'db/migrate/20161025070152_removechannelsfrommodels.rb' - - 'db/migrate/20161025070645_remchannel.rb' - - 'db/migrate/20161025070645_remchannel.rb' - - 'db/migrate/20161110102609_removeinboxid.rb' - - 'db/migrate/20170519091539_add_avatar_to_fb.rb' - - 'db/migrate/20191020085608_rename_old_tables.rb' - - 'db/migrate/20191126185833_update_user_invite_foreign_key.rb' - - 'db/migrate/20191130164019_add_template_type_to_messages.rb' - - 'db/migrate/20210513083044_remove_not_null_from_webhook_url_channel_api.rb' -Rails/BulkChangeTable: - Exclude: - - 'db/migrate/20161025070152_removechannelsfrommodels.rb' - - 'db/migrate/20200121190901_create_account_users.rb' - - 'db/migrate/20170211092540_notnullableusers.rb' - - 'db/migrate/20170403095203_contactadder.rb' - - 'db/migrate/20170406104018_add_default_status_conv.rb' - - 'db/migrate/20170511134418_latlong.rb' - - 'db/migrate/20191027054756_create_contact_inboxes.rb' - - 'db/migrate/20191130164019_add_template_type_to_messages.rb' - - 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb' Rails/UniqueValidationWithoutIndex: Exclude: - 'app/models/channel/twitter_profile.rb' - 'app/models/webhook.rb' - 'app/models/contact.rb' - 'app/models/integrations/hook.rb' + - 'app/models/canned_response.rb' + - 'app/models/telegram_bot.rb' Rails/RenderInline: Exclude: - 'app/controllers/swagger_controller.rb' -Performance/CollectionLiteralInLoop: - Exclude: - - 'db/migrate/20210315101919_enable_email_channel.rb' Rails/ThreeStateBooleanColumn: Exclude: - - 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb' - - 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb' - - 'db/migrate/20200606132552_create_labels.rb' - - 'db/migrate/20201027135006_create_working_hours.rb' - - 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb' - - 'db/migrate/20210114202310_create_teams.rb' - - 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb' - - 'db/migrate/20210428135041_add_campaigns.rb' - - 'db/migrate/20210602182058_add_hmac_to_api_channel.rb' - - 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb' - - 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb' - - 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb' - - 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb' - - 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb' - - 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb' - - 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb' - - 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb' - - 'db/migrate/20220511072655_add_archive_column_to_portal.rb' - 'db/migrate/20230503101201_create_sla_policies.rb' RSpec/IndexedLet: Enabled: false @@ -187,6 +135,8 @@ RSpec/NamedSubject: Enabled: false # we should bring this down +RSpec/MultipleExpectations: + Max: 7 RSpec/MultipleMemoizedHelpers: Max: 14 @@ -203,13 +153,4 @@ AllCops: - 'config/environments/**/*' - 'tmp/**/*' - 'storage/**/*' - - 'db/migrate/20200225162150_init_schema.rb' - - 'db/migrate/20210611180222_create_active_storage_variant_records.active_storage.rb' - - 'db/migrate/20210611180221_add_service_name_to_active_storage_blobs.active_storage.rb' - - db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb - - db/migrate/20200331095710_add_identifier_to_contact.rb - - db/migrate/20200429082655_add_medium_to_twilio_sms.rb - - db/migrate/20200503151130_add_account_feature_flag.rb - - db/migrate/20200927135222_add_last_activity_at_to_conversation.rb - - db/migrate/20210306170117_add_last_activity_at_to_contacts.rb - - db/migrate/20220809104508_revert_cascading_indexes.rb + - 'db/migrate/20230426130150_init_schema.rb' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index 48e714dd6..000000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,287 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2019-10-23 16:47:02 +0530 using RuboCop version 0.73.0. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 1 -Lint/DuplicateMethods: - Exclude: - - 'app/controllers/api/v1/reports_controller.rb' - -# Offense count: 1 -Lint/RescueException: - Exclude: - - 'app/builders/messages/message_builder.rb' - -# Offense count: 4 -Lint/ShadowingOuterLocalVariable: - Exclude: - - 'app/controllers/api/v1/reports_controller.rb' - -# Offense count: 3 -# Configuration parameters: AllowKeywordBlockArguments. -Lint/UnderscorePrefixedVariableName: - Exclude: - - 'app/models/account.rb' - - 'deploy/before_symlink.rb' - -# Offense count: 18 -Lint/UselessAssignment: - Exclude: - - 'app/controllers/api/v1/callbacks_controller.rb' - - 'app/controllers/api/v1/facebook_indicators_controller.rb' - - 'app/listeners/action_cable_listener.rb' - - 'app/listeners/reporting_listener.rb' - - 'app/models/channel/facebook_page.rb' - - 'app/models/facebook_page.rb' - -# Offense count: 14 -Metrics/AbcSize: - Max: 26 - -# Offense count: 1 -# Configuration parameters: CountComments, ExcludedMethods. -# ExcludedMethods: refine -Metrics/BlockLength: - Max: 30 - -# Offense count: 2 -Metrics/CyclomaticComplexity: - Max: 7 - -# Offense count: 10 -# Configuration parameters: CountComments, ExcludedMethods. -Metrics/MethodLength: - Max: 19 - -# Offense count: 1 -Metrics/PerceivedComplexity: - Max: 8 - -# Offense count: 6 -Naming/AccessorMethodName: - Exclude: - - 'app/builders/report_builder.rb' - - 'app/controllers/api/v1/accounts_controller.rb' - - 'app/controllers/api/v1/callbacks_controller.rb' - - 'app/controllers/api/v1/conversations_controller.rb' - -# Offense count: 9 -# Configuration parameters: EnforcedStyleForLeadingUnderscores. -# SupportedStylesForLeadingUnderscores: disallowed, required, optional -Naming/MemoizedInstanceVariableName: - Exclude: - - 'app/controllers/api/base_controller.rb' - - 'app/controllers/api/v1/conversations_controller.rb' - - 'app/controllers/api/v1/webhooks_controller.rb' - - 'app/controllers/application_controller.rb' - - 'app/models/message.rb' - - 'lib/integrations/widget/outgoing_message_builder.rb' - -# Offense count: 4 -# Cop supports --auto-correct. -# Configuration parameters: MaxKeyValuePairs. -Performance/RedundantMerge: - Exclude: - - 'app/controllers/api/v1/callbacks_controller.rb' - - 'app/models/message.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -Performance/StringReplacement: - Exclude: - - 'lib/events/base.rb' - -# Offense count: 4 -# Configuration parameters: Prefixes. -# Prefixes: when, with, without -RSpec/ContextWording: - Exclude: - - 'spec/models/contact_spec.rb' - - 'spec/models/user_spec.rb' - -# Offense count: 1 -RSpec/DescribeClass: - Exclude: - - 'spec/mailers/confirmation_instructions_spec.rb' - -# Offense count: 1 -RSpec/DescribeSymbol: - Exclude: - - 'spec/mailers/confirmation_instructions_spec.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: AllowConsecutiveOneLiners. -RSpec/EmptyLineAfterExample: - Exclude: - - 'spec/models/user_spec.rb' - -# Offense count: 1 -# Configuration parameters: Max. -RSpec/ExampleLength: - Exclude: - - 'spec/models/conversation_spec.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: single_line_only, single_statement_only, disallow -RSpec/ImplicitSubject: - Exclude: - - 'spec/models/user_spec.rb' - -# Offense count: 7 -# Configuration parameters: AggregateFailuresByDefault. -RSpec/MultipleExpectations: - Max: 7 - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: not_to, to_not -RSpec/NotToNot: - Exclude: - - 'spec/mailers/confirmation_instructions_spec.rb' - -# Offense count: 1 -# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. -RSpec/VerifiedDoubles: - Exclude: - - 'spec/models/conversation_spec.rb' - -# Offense count: 4 -# Cop supports --auto-correct. -Rails/ActiveRecordAliases: - Exclude: - - 'app/controllers/api/v1/agents_controller.rb' - - 'app/controllers/api/v1/callbacks_controller.rb' - - 'app/controllers/api/v1/canned_responses_controller.rb' - - 'app/controllers/api/v1/contacts_controller.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -Rails/BelongsTo: - Exclude: - - 'app/models/message.rb' - - 'app/models/user.rb' - -# Offense count: 6 -# Cop supports --auto-correct. -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/EnumHash: - Exclude: - - 'app/models/attachment.rb' - - 'app/models/conversation.rb' - - 'app/models/message.rb' - - 'app/models/user.rb' - -# Offense count: 1 -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/HasManyOrHasOneDependent: - Exclude: - - 'app/models/user.rb' - -# Offense count: 1 -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/InverseOf: - Exclude: - - 'app/models/user.rb' - -# Offense count: 1 -# Configuration parameters: Include. -# Include: app/controllers/**/*.rb -Rails/LexicallyScopedActionFilter: - Exclude: - - 'app/controllers/home_controller.rb' - -# Offense count: 2 -# Configuration parameters: Include. -# Include: app/**/*.rb, config/**/*.rb, db/**/*.rb, lib/**/*.rb -Rails/Output: - Exclude: - - 'app/bot/bot.rb' - - 'app/builders/account_builder.rb' - -# Offense count: 7 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: strict, flexible -Rails/TimeZone: - Exclude: - - 'app/builders/report_builder.rb' - - 'lib/reports/update_account_identity.rb' - - 'lib/reports/update_agent_identity.rb' - - 'lib/reports/update_identity.rb' - - 'spec/models/conversation_spec.rb' - -# Offense count: 8 -# Cop supports --auto-correct. -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/Validation: - Exclude: - - 'app/models/canned_response.rb' - - 'app/models/channel/facebook_page.rb' - - 'app/models/facebook_page.rb' - - 'app/models/telegram_bot.rb' - - 'app/models/user.rb' - -# Offense count: 15 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle. -# SupportedStyles: nested, compact -Style/ClassAndModuleChildren: - Exclude: - - 'app/builders/messages/message_builder.rb' - - 'app/controllers/api/v1/inbox_members_controller.rb' - - 'app/models/channel/facebook_page.rb' - - 'app/models/channel/web_widget.rb' - - 'app/presenters/conversations/event_data_presenter.rb' - - 'app/services/facebook/send_reply_service.rb' - - 'lib/integrations/facebook/delivery_status.rb' - - 'lib/integrations/facebook/message_creator.rb' - - 'lib/integrations/facebook/message_parser.rb' - - 'lib/integrations/widget/incoming_message_builder.rb' - -# Offense count: 4 -Style/CommentedKeyword: - Exclude: - - 'app/controllers/api/v1/callbacks_controller.rb' - - 'app/controllers/api/v1/conversations/assignments_controller.rb' - - 'app/controllers/api/v1/conversations/labels_controller.rb' - - 'app/controllers/api/v1/labels_controller.rb' - -# Offense count: 1 -# Configuration parameters: AllowIfModifier. -Style/IfInsideElse: - Exclude: - - 'app/finders/conversation_finder.rb' - -# Offense count: 1 -Style/MixinUsage: - Exclude: - - 'app/bot/bot.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle, IgnoredMethods. -# SupportedStyles: predicate, comparison -Style/NumericPredicate: - Exclude: - - 'spec/**/*' - - 'app/controllers/api/v1/callbacks_controller.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: implicit, explicit -Style/RescueStandardError: - Exclude: - - 'app/models/channel/facebook_page.rb' diff --git a/Gemfile b/Gemfile index 718e90979..a21af8c01 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,8 @@ gem 'rack-attack', '>= 6.7.0' gem 'down' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' +# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 +gem 'net-smtp', '~> 0.3.4' # Prevent CSV injection gem 'csv-safe' @@ -74,7 +76,7 @@ gem 'devise_token_auth' gem 'jwt' gem 'pundit' # super admin -gem 'administrate', '>= 0.19.0' +gem 'administrate', '>= 0.20.1' gem 'administrate-field-active_storage', '>= 1.0.1' gem 'administrate-field-belongs_to_search', '>= 0.9.0' @@ -114,7 +116,7 @@ gem 'sentry-ruby', require: false gem 'sentry-sidekiq', '>= 5.14.0', require: false ##-- background job processing --## -gem 'sidekiq', '>= 7.1.3' +gem 'sidekiq', '>= 7.2.1' # We want cron jobs gem 'sidekiq-cron', '>= 1.12.0' diff --git a/Gemfile.lock b/Gemfile.lock index 8d8714fd8..35e1e2a48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,12 +105,12 @@ GEM activerecord (>= 6.0, < 7.1) addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) - administrate (0.19.0) - actionpack (>= 5.0) - actionview (>= 5.0) - activerecord (>= 5.0) - jquery-rails (>= 4.0) - kaminari (>= 1.0) + administrate (0.20.1) + actionpack (>= 6.0, < 8.0) + actionview (>= 6.0, < 8.0) + activerecord (>= 6.0, < 8.0) + jquery-rails (~> 4.6.0) + kaminari (~> 1.2.2) sassc-rails (~> 2.1) selectize-rails (~> 0.6) administrate-field-active_storage (1.0.1) @@ -169,7 +169,7 @@ GEM climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) connection_pool (2.4.1) crack (0.4.5) rexml @@ -277,7 +277,7 @@ GEM gli (2.21.1) globalid (1.2.1) activesupport (>= 6.1) - gmail_xoauth (0.4.2) + gmail_xoauth (0.4.3) oauth (>= 0.3.6) google-apis-core (0.11.0) addressable (~> 2.5, >= 2.5.1) @@ -316,16 +316,16 @@ GEM google-cloud-translate-v3 (0.6.0) gapic-common (>= 0.17.1, < 2.a) google-cloud-errors (~> 1.0) - google-protobuf (3.22.3) - google-protobuf (3.22.3-arm64-darwin) - google-protobuf (3.22.3-x86_64-darwin) - google-protobuf (3.22.3-x86_64-linux) + google-protobuf (3.25.2) + google-protobuf (3.25.2-arm64-darwin) + google-protobuf (3.25.2-x86_64-darwin) + google-protobuf (3.25.2-x86_64-linux) googleapis-common-protos (1.4.0) google-protobuf (~> 3.14) googleapis-common-protos-types (~> 1.2) grpc (~> 1.27) - googleapis-common-protos-types (1.6.0) - google-protobuf (~> 3.14) + googleapis-common-protos-types (1.11.0) + google-protobuf (~> 3.18) googleauth (1.5.2) faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) @@ -335,13 +335,13 @@ GEM signet (>= 0.16, < 2.a) groupdate (6.2.1) activesupport (>= 5.2) - grpc (1.54.0) + grpc (1.54.3) google-protobuf (~> 3.21) googleapis-common-protos-types (~> 1.0) - grpc (1.54.0-x86_64-darwin) + grpc (1.54.3-x86_64-darwin) google-protobuf (~> 3.21) googleapis-common-protos-types (~> 1.0) - grpc (1.54.0-x86_64-linux) + grpc (1.54.3-x86_64-linux) google-protobuf (~> 3.21) googleapis-common-protos-types (~> 1.0) haikunator (1.1.1) @@ -461,7 +461,7 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.5) - minitest (5.20.0) + minitest (5.21.2) mock_redis (0.36.0) ruby2_keywords msgpack (1.7.0) @@ -479,7 +479,7 @@ GEM net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0) + net-smtp (0.3.4) net-protocol netrc (0.11.0) newrelic-sidekiq-metrics (1.6.2) @@ -488,14 +488,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.0) - nokogiri (1.16.0) + nokogiri (1.16.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.0-arm64-darwin) + nokogiri (1.16.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.0-x86_64-darwin) + nokogiri (1.16.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.0-x86_64-linux) + nokogiri (1.16.2-x86_64-linux) racc (~> 1.4) numo-narray (0.9.2.1) oauth (1.1.0) @@ -610,7 +610,7 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.19.0) + redis-client (0.19.1) connection_pool redis-namespace (1.10.0) redis (>= 4) @@ -720,11 +720,11 @@ GEM sexp_processor (4.17.0) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.2.0) + sidekiq (7.2.1) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) - redis-client (>= 0.14.0) + redis-client (>= 0.19.0) sidekiq-cron (1.12.0) fugit (~> 1.8) globalid (>= 1.0.1) @@ -794,7 +794,7 @@ GEM valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) - version_gem (1.1.2) + version_gem (1.1.3) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -840,7 +840,7 @@ DEPENDENCIES active_record_query_trace activerecord-import acts-as-taggable-on - administrate (>= 0.19.0) + administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.1) administrate-field-belongs_to_search (>= 0.9.0) annotate @@ -903,6 +903,7 @@ DEPENDENCIES meta_request mock_redis neighbor + net-smtp (~> 0.3.4) newrelic-sidekiq-metrics (>= 1.6.2) newrelic_rpm omniauth (>= 2.1.2) @@ -939,7 +940,7 @@ DEPENDENCIES sentry-ruby sentry-sidekiq (>= 5.14.0) shoulda-matchers - sidekiq (>= 7.1.3) + sidekiq (>= 7.2.1) sidekiq-cron (>= 1.12.0) simplecov (= 0.17.1) slack-ruby-client (~> 2.2.0) diff --git a/LICENSE b/LICENSE index f36fc7c53..8f80f35be 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2021 Chatwoot Inc. +Copyright (c) 2017-2024 Chatwoot Inc. Portions of this software are licensed as follows: diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 9d58ef207..650dbe89f 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -12,6 +12,7 @@ class ContactMergeAction merge_conversations merge_messages merge_contact_inboxes + merge_contact_notes merge_and_remove_mergee_contact end @base_contact @@ -33,6 +34,10 @@ class ContactMergeAction Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) end + def merge_contact_notes + Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id) + end + def merge_messages Message.where(sender: @mergee_contact).update(sender: @base_contact) end diff --git a/app/assets/stylesheets/administrate/custom_styles.scss b/app/assets/stylesheets/administrate/custom_styles.scss index 859a35689..5e6d803d8 100644 --- a/app/assets/stylesheets/administrate/custom_styles.scss +++ b/app/assets/stylesheets/administrate/custom_styles.scss @@ -9,7 +9,7 @@ padding: 4px 12px; .icon-container { - margin-right: 4px; + margin-right: 2px; } diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index 3e2ac9d6e..f179c6405 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -2,7 +2,7 @@ class AccountBuilder include CustomExceptions::Account - pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale] + pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale] def perform if @user.nil? @@ -15,12 +15,22 @@ class AccountBuilder end [@user, @account] rescue StandardError => e - puts e.inspect + Rails.logger.debug e.inspect raise e end private + def user_full_name + # the empty string ensures that not-null constraint is not violated + @user_full_name || '' + end + + def account_name + # the empty string ensures that not-null constraint is not violated + @account_name || '' + end + def validate_email address = ValidEmail2::Address.new(@email) if address.valid? # && !address.disposable? @@ -39,7 +49,7 @@ class AccountBuilder end def create_account - @account = Account.create!(name: @account_name, locale: I18n.locale) + @account = Account.create!(name: account_name, locale: I18n.locale) Current.account = @account end @@ -64,7 +74,7 @@ class AccountBuilder @user = User.new(email: @email, password: user_password, password_confirmation: user_password, - name: @user_full_name) + name: user_full_name) @user.type = 'SuperAdmin' if @super_admin @user.confirm if @confirmed @user.save! diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb new file mode 100644 index 000000000..6ea68821d --- /dev/null +++ b/app/builders/agent_builder.rb @@ -0,0 +1,60 @@ +# The AgentBuilder class is responsible for creating a new agent. +# It initializes with necessary attributes and provides a perform method +# to create a user and account user in a transaction. +class AgentBuilder + # Initializes an AgentBuilder with necessary attributes. + # @param email [String] the email of the user. + # @param name [String] the name of the user. + # @param role [String] the role of the user, defaults to 'agent' if not provided. + # @param inviter [User] the user who is inviting the agent (Current.user in most cases). + # @param availability [String] the availability status of the user, defaults to 'offline' if not provided. + # @param auto_offline [Boolean] the auto offline status of the user. + pattr_initialize [:email, { name: '' }, :inviter, :account, { role: :agent }, { availability: :offline }, { auto_offline: false }] + + # Creates a user and account user in a transaction. + # @return [User] the created user. + def perform + ActiveRecord::Base.transaction do + @user = find_or_create_user + send_confirmation_if_required + create_account_user + end + @user + end + + private + + # Finds a user by email or creates a new one with a temporary password. + # @return [User] the found or created user. + def find_or_create_user + user = User.find_by(email: email) + return user if user + + temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" + User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) + end + + # Sends confirmation instructions if the user is persisted and not confirmed. + def send_confirmation_if_required + @user.send_confirmation_instructions if user_needs_confirmation? + end + + # Checks if the user needs confirmation. + # @return [Boolean] true if the user is persisted and not confirmed, false otherwise. + def user_needs_confirmation? + @user.persisted? && !@user.confirmed? + end + + # Creates an account user linking the user to the current account. + def create_account_user + AccountUser.create!({ + account_id: account.id, + user_id: @user.id, + inviter_id: inviter.id + }.merge({ + role: role, + availability: availability, + auto_offline: auto_offline + }.compact)) + end +end diff --git a/app/builders/v2/reports/agent_summary_builder.rb b/app/builders/v2/reports/agent_summary_builder.rb new file mode 100644 index 000000000..382c67bf5 --- /dev/null +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -0,0 +1,53 @@ +class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder + pattr_initialize [:account!, :params!] + + def build + set_grouped_conversations_count + set_grouped_avg_reply_time + set_grouped_avg_first_response_time + set_grouped_avg_resolution_time + prepare_report + end + + private + + def set_grouped_conversations_count + @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count + end + + def set_grouped_avg_resolution_time + @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved')) + end + + def set_grouped_avg_first_response_time + @grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response')) + end + + def set_grouped_avg_reply_time + @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time')) + end + + def group_by_key + :user_id + end + + def reporting_events + @reporting_events ||= Current.account.reporting_events.where(created_at: range) + end + + def prepare_report + account.account_users.each_with_object([]) do |account_user, arr| + arr << { + id: account_user.user_id, + conversations_count: @grouped_conversations_count[account_user.user_id], + avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id], + avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id], + avg_reply_time: @grouped_avg_reply_time[account_user.user_id] + } + end + end + + def average_value_key + ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value + end +end diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb new file mode 100644 index 000000000..33fe5e400 --- /dev/null +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -0,0 +1,17 @@ +class V2::Reports::BaseSummaryBuilder + include DateRangeHelper + + private + + def group_by_key + # Override this method + end + + def get_grouped_average(events) + events.group(group_by_key).average(average_value_key) + end + + def average_value_key + params[:business_hours].present? ? :value_in_business_hours : :value + end +end diff --git a/app/builders/v2/reports/team_summary_builder.rb b/app/builders/v2/reports/team_summary_builder.rb new file mode 100644 index 000000000..3d7e765b3 --- /dev/null +++ b/app/builders/v2/reports/team_summary_builder.rb @@ -0,0 +1,49 @@ +class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder + pattr_initialize [:account!, :params!] + + def build + set_grouped_conversations_count + set_grouped_avg_reply_time + set_grouped_avg_first_response_time + set_grouped_avg_resolution_time + prepare_report + end + + private + + def set_grouped_conversations_count + @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count + end + + def set_grouped_avg_resolution_time + @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved')) + end + + def set_grouped_avg_first_response_time + @grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response')) + end + + def set_grouped_avg_reply_time + @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time')) + end + + def reporting_events + @reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation) + end + + def group_by_key + 'conversations.team_id' + end + + def prepare_report + account.teams.each_with_object([]) do |team, arr| + arr << { + id: team.id, + conversations_count: @grouped_conversations_count[team.id], + avg_resolution_time: @grouped_avg_resolution_time[team.id], + avg_first_response_time: @grouped_avg_first_response_time[team.id], + avg_reply_time: @grouped_avg_reply_time[team.id] + } + end + end +end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 9a089f242..221c96b85 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -1,16 +1,26 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController - before_action :fetch_agent, except: [:create, :index] + before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :check_authorization - before_action :find_user, only: [:create] before_action :validate_limit, only: [:create] - before_action :create_user, only: [:create] - before_action :save_account_user, only: [:create] + before_action :validate_limit_for_bulk_create, only: [:bulk_create] def index @agents = agents end - def create; end + def create + builder = AgentBuilder.new( + email: new_agent_params['email'], + name: new_agent_params['name'], + role: new_agent_params['role'], + availability: new_agent_params['availability'], + auto_offline: new_agent_params['auto_offline'], + inviter: current_user, + account: Current.account + ) + + builder.perform + end def update @agent.update!(agent_params.slice(:name).compact) @@ -23,6 +33,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController head :ok end + def bulk_create + emails = params[:emails] + + emails.each do |email| + builder = AgentBuilder.new( + email: email, + name: email.split('@').first, + inviter: current_user, + account: Current.account + ) + begin + builder.perform + rescue ActiveRecord::RecordInvalid => e + Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" + end + end + head :ok + end + private def check_authorization @@ -33,47 +62,34 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController @agent = agents.find(params[:id]) end - def find_user - @user = User.find_by(email: new_agent_params[:email]) - end - - # TODO: move this to a builder and combine the save account user method into a builder - # ensure the account user association is also created in a single transaction - def create_user - return @user.send_confirmation_instructions if @user - - @user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation)) - end - - def save_account_user - AccountUser.create!({ - account_id: Current.account.id, - user_id: @user.id, - inviter_id: current_user.id - }.merge({ - role: new_agent_params[:role], - availability: new_agent_params[:availability], - auto_offline: new_agent_params[:auto_offline] - }.compact)) - end - def agent_params params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline) end def new_agent_params - # intial string ensures the password requirements are met - temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) - .merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user) end def agents @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) end + def validate_limit_for_bulk_create + limit_available = params[:emails].count <= available_agent_count + + render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available + end + def validate_limit - render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents] + render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent? + end + + def available_agent_count + Current.account.usage_limits[:agents] - agents.count + end + + def can_add_agent? + available_agent_count.positive? end def delete_user_record(agent) diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 3840644ce..69df99e14 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -39,6 +39,8 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account :attribute_display_type, :attribute_key, :attribute_model, + :regex_pattern, + :regex_cue, attribute_values: [] ) end diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index a3d8df27e..3ac0568e3 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -2,13 +2,13 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro RESULTS_PER_PAGE = 15 include DateRangeHelper - before_action :fetch_notification, only: [:update, :destroy, :snooze] + before_action :fetch_notification, only: [:update, :destroy, :snooze, :unread] before_action :set_primary_actor, only: [:read_all] 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 @@ -29,11 +29,25 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro render json: @notification end + def unread + @notification.update(read_at: nil) + render json: @notification + end + def destroy @notification.destroy head :ok end + def destroy_all + if params[:type] == 'read' + ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read) + else + ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all) + end + head :ok + end + def unread_count @unread_count = notification_finder.unread_count render json: @unread_count diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index ef0e0c777..481594ee4 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -5,11 +5,13 @@ class Api::V1::AccountsController < Api::BaseController skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, only: [:create], raise: false before_action :check_signup_enabled, only: [:create] + before_action :ensure_account_name, only: [:create] before_action :validate_captcha, only: [:create] before_action :fetch_account, except: [:create] before_action :check_authorization, except: [:create] rescue_from CustomExceptions::Account::InvalidEmail, + CustomExceptions::Account::InvalidParams, CustomExceptions::Account::UserExists, CustomExceptions::Account::UserErrors, with: :render_error_response @@ -38,11 +40,13 @@ class Api::V1::AccountsController < Api::BaseController def cache_keys expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes - render json: { cache_keys: get_cache_keys }, status: :ok + render json: { cache_keys: cache_keys_for_account }, status: :ok end def update - @account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration)) + @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration)) + @account.custom_attributes.merge!(custom_attributes_params) + @account.save! end def update_active_at @@ -53,7 +57,18 @@ class Api::V1::AccountsController < Api::BaseController private - def get_cache_keys + def ensure_account_name + # ensure that account_name and user_full_name is present + # this is becuase the account builder and the models validations are not triggered + # this change is to align the behaviour with the v2 accounts controller + # since these values are not required directly there + return if account_params[:account_name].present? + return if account_params[:user_full_name].present? + + raise CustomExceptions::Account::InvalidParams.new({}) + end + + def cache_keys_for_account { label: fetch_value_for_key(params[:id], Label.name.underscore), inbox: fetch_value_for_key(params[:id], Inbox.name.underscore), @@ -70,6 +85,10 @@ class Api::V1::AccountsController < Api::BaseController params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name) end + def custom_attributes_params + params.permit(:industry, :company_size, :timezone) + end + def check_signup_enabled raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' end diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index cbf801e82..bd3c2673c 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -10,7 +10,9 @@ class Api::V1::ProfilesController < Api::BaseController @user.update!(password_params.except(:current_password)) end - @user.update!(profile_params) + @user.assign_attributes(profile_params) + @user.custom_attributes.merge!(custom_attributes_params) + @user.save! end def avatar @@ -31,6 +33,11 @@ class Api::V1::ProfilesController < Api::BaseController head :ok end + def resend_confirmation + @user.send_confirmation_instructions unless @user.confirmed? + head :ok + end + private def set_user @@ -57,6 +64,10 @@ class Api::V1::ProfilesController < Api::BaseController ) end + def custom_attributes_params + params.require(:profile).permit(:phone_number) + end + def password_params params.require(:profile).permit( :current_password, diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb new file mode 100644 index 000000000..0cbd6dd8e --- /dev/null +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -0,0 +1,36 @@ +class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController + before_action :check_authorization + before_action :prepare_builder_params, only: [:agent, :team] + + def agent + render_report_with(V2::Reports::AgentSummaryBuilder) + end + + def team + render_report_with(V2::Reports::TeamSummaryBuilder) + end + + private + + def check_authorization + authorize :report, :view? + end + + def prepare_builder_params + @builder_params = { + since: permitted_params[:since], + until: permitted_params[:until], + business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours]) + } + end + + def render_report_with(builder_class) + builder = builder_class.new(account: Current.account, params: @builder_params) + data = builder.build + render json: data + end + + def permitted_params + params.permit(:since, :until, :business_hours) + end +end diff --git a/app/controllers/api/v2/accounts_controller.rb b/app/controllers/api/v2/accounts_controller.rb new file mode 100644 index 000000000..ea19727ad --- /dev/null +++ b/app/controllers/api/v2/accounts_controller.rb @@ -0,0 +1,56 @@ +class Api::V2::AccountsController < Api::BaseController + include AuthHelper + + skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, + only: [:create], raise: false + before_action :check_signup_enabled, only: [:create] + before_action :validate_captcha, only: [:create] + before_action :fetch_account, except: [:create] + before_action :check_authorization, except: [:create] + + rescue_from CustomExceptions::Account::InvalidEmail, + CustomExceptions::Account::UserExists, + CustomExceptions::Account::UserErrors, + with: :render_error_response + + def create + @user, @account = AccountBuilder.new( + email: account_params[:email], + user_password: account_params[:password], + locale: account_params[:locale], + user: current_user + ).perform + + fetch_account_and_user_info + + if @user + send_auth_headers(@user) + render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + else + render_error_response(CustomExceptions::Account::SignupFailed.new({})) + end + end + + private + + def fetch_account_and_user_info; end + + def fetch_account + @account = current_user.accounts.find(params[:id]) + @current_account_user = @account.account_users.find_by(user_id: current_user.id) + end + + def account_params + params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name) + end + + def check_signup_enabled + raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + end + + def validate_captcha + raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? + end +end + +Api::V2::AccountsController.prepend_mod_with('Api::V2::AccountsController') diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 0a6c89860..cd760f7ce 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,7 +1,8 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status create], - 'api/v1/accounts/conversations/messages' => ['create'] + 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create], + 'api/v1/accounts/conversations/messages' => ['create'], + 'api/v1/accounts/conversations/assignments' => ['create'] }.freeze def ensure_access_token diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index 5d02e96c8..3baf9ee1e 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -25,6 +25,6 @@ module EnsureCurrentAccountHelper end def account_accessible_for_bot?(account) - render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) + render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) end end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 97caad42c..a31d01675 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -22,19 +22,24 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController i.value = value i.save! end - # rubocop:disable Rails/I18nLocaleTexts - redirect_to super_admin_settings_path, notice: 'App Configs updated successfully' - # rubocop:enable Rails/I18nLocaleTexts + redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" end private def set_config - @config = params[:config] + @config = params[:config] || 'general' end def allowed_configs - @allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET] + @allowed_configs = case @config + when 'facebook' + %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT] + when 'email' + ['MAILER_INBOUND_EMAIL_DOMAIN'] + else + %w[ENABLE_ACCOUNT_SIGNUP] + end end end diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index a1dc458ae..b0e97b95d 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -28,8 +28,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController end def sha - sha = `git rev-parse HEAD` - @metrics['Git SHA'] = sha.presence || 'n/a' + @metrics['Git SHA'] = GIT_HASH end def postgres_status diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 0cc5e52d8..f3c85be7a 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -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 diff --git a/app/finders/notification_finder.rb b/app/finders/notification_finder.rb index 7559d6ef9..ccfe470a0 100644 --- a/app/finders/notification_finder.rb +++ b/app/finders/notification_finder.rb @@ -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,22 +26,31 @@ class NotificationFinder def set_up find_all_notifications - 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 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: :desc) + def sort_order + params[:sort_order] || :desc end end diff --git a/app/helpers/api/v2/accounts/heatmap_helper.rb b/app/helpers/api/v2/accounts/heatmap_helper.rb index 597135f47..58dade28d 100644 --- a/app/helpers/api/v2/accounts/heatmap_helper.rb +++ b/app/helpers/api/v2/accounts/heatmap_helper.rb @@ -15,8 +15,8 @@ module Api::V2::Accounts::HeatmapHelper dates = data.pluck(:date).uniq.sort # add the dates as the first row, leave an empty cell for the hour column - # e.g. [nil, '2023-01-01', '2023-1-02', '2023-01-03'] - result_arr << ([nil] + dates) + # e.g. ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03'] + result_arr << (['Start of the hour'] + dates) # group the data by hour, we do not need to sort it, because the data is already sorted # given it starts from the beginning of the day @@ -25,7 +25,7 @@ module Api::V2::Accounts::HeatmapHelper # value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...] data.group_by { |d| d[:hour] }.each do |hour, items| # create a new row for each hour - row = [hour] + row = [format('%02d:00', hour)] # group the items by date, so we can easily access the value for each date # grouped values will be a hasg with the date as the key, and the value as the value @@ -37,7 +37,7 @@ module Api::V2::Accounts::HeatmapHelper row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array)) end - # row will look like [22, 0, 0, 1, 4, 6, 7, 4] + # row will look like ['22:00', 0, 0, 1, 4, 6, 7, 4] # add the row to the result array result_arr << row @@ -46,12 +46,12 @@ module Api::V2::Accounts::HeatmapHelper # return the resultant array # the result looks like this # [ - # [nil, '2023-01-01', '2023-1-02', '2023-01-03'], - # [0, 0, 0, 0], - # [1, 0, 0, 0], - # [2, 0, 0, 0], - # [3, 0, 0, 0], - # [4, 0, 0, 0], + # ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03'], + # ['00:00', 0, 0, 0], + # ['01:00', 0, 0, 0], + # ['02:00', 0, 0, 0], + # ['03:00', 0, 0, 0], + # ['04:00', 0, 0, 0], # ] result_arr end diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 3d87154fd..74c7af43e 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -45,12 +45,8 @@ module Api::V2::Accounts::ReportsHelper def generate_readable_report_metrics(report_metric) [ report_metric[:conversations_count], - time_to_minutes(report_metric[:avg_first_response_time]), - time_to_minutes(report_metric[:avg_resolution_time]) + Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format, + Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format ] end - - def time_to_minutes(time_in_seconds) - (time_in_seconds / 60).to_i - end end diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb new file mode 100644 index 000000000..759aec134 --- /dev/null +++ b/app/helpers/super_admin/account_features_helper.rb @@ -0,0 +1,9 @@ +module SuperAdmin::AccountFeaturesHelper + def self.account_features + YAML.safe_load(Rails.root.join('config/features.yml').read).freeze + end + + def self.account_premium_features + account_features.filter { |feature| feature['premium'] }.pluck('name') + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 6fd470fcf..49fe325f6 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -2,12 +2,13 @@
@@ -32,6 +33,7 @@ import NetworkNotification from './components/NetworkNotification.vue'; import UpdateBanner from './components/app/UpdateBanner.vue'; import UpgradeBanner from './components/app/UpgradeBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; +import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import vueActionCable from './helper/actionCable'; import WootSnackbarBox from './components/SnackbarContainer.vue'; import rtlMixin from 'shared/mixins/rtlMixin'; @@ -52,6 +54,7 @@ export default { PaymentPendingBanner, WootSnackbarBox, UpgradeBanner, + PendingEmailVerificationBanner, }, mixins: [rtlMixin], diff --git a/app/javascript/dashboard/api/agents.js b/app/javascript/dashboard/api/agents.js index 7cc5e6d0c..cfc6b36ff 100644 --- a/app/javascript/dashboard/api/agents.js +++ b/app/javascript/dashboard/api/agents.js @@ -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(); diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 48e7d7085..dde817866 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -29,11 +29,12 @@ export default { return fetchPromise; }, hasAuthCookie() { - return !!Cookies.getJSON('cw_d_session_info'); + return !!Cookies.get('cw_d_session_info'); }, getAuthData() { if (this.hasAuthCookie()) { - return Cookies.getJSON('cw_d_session_info'); + const savedAuthInfo = Cookies.get('cw_d_session_info'); + return JSON.parse(savedAuthInfo || '{}'); } return false; }, @@ -97,4 +98,8 @@ export default { }, }); }, + resendConfirmation() { + const urlData = endPoints('resendConfirmation'); + return axios.post(urlData.url); + }, }; diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 678386d50..31337b7fc 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -47,6 +47,10 @@ const endPoints = { setActiveAccount: { url: '/api/v1/profile/set_active_account', }, + + resendConfirmation: { + url: '/api/v1/profile/resend_confirmation', + }, }; export default page => { diff --git a/app/javascript/dashboard/api/notifications.js b/app/javascript/dashboard/api/notifications.js index e13bc78a6..65284623f 100644 --- a/app/javascript/dashboard/api/notifications.js +++ b/app/javascript/dashboard/api/notifications.js @@ -6,8 +6,16 @@ class NotificationsAPI extends ApiClient { super('notifications', { accountScoped: true }); } - get(page) { - return axios.get(`${this.url}?page=${page}`); + get({ page, status, type, sortOrder }) { + const includesFilter = [status, type].filter(value => !!value); + + return axios.get(this.url, { + params: { + page, + sort_order: sortOrder, + includes: includesFilter, + }, + }); } getNotifications(contactId) { @@ -25,9 +33,29 @@ class NotificationsAPI extends ApiClient { }); } + unRead(id) { + return axios.post(`${this.url}/${id}/unread`); + } + readAll() { return axios.post(`${this.url}/read_all`); } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } + + deleteAll({ type = 'all' }) { + return axios.post(`${this.url}/destroy_all`, { + type, + }); + } + + snooze({ id, snoozedUntil = null }) { + return axios.post(`${this.url}/${id}/snooze`, { + snoozed_until: snoozedUntil, + }); + } } export default new NotificationsAPI(); diff --git a/app/javascript/dashboard/api/specs/agents.spec.js b/app/javascript/dashboard/api/specs/agents.spec.js index 7cf1bdd0e..20dd36688 100644 --- a/app/javascript/dashboard/api/specs/agents.spec.js +++ b/app/javascript/dashboard/api/specs/agents.spec.js @@ -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'], + } + ); + }); + }); }); diff --git a/app/javascript/dashboard/api/specs/notifications.spec.js b/app/javascript/dashboard/api/specs/notifications.spec.js index 5bdf88d7f..fe748fe19 100644 --- a/app/javascript/dashboard/api/specs/notifications.spec.js +++ b/app/javascript/dashboard/api/specs/notifications.spec.js @@ -27,11 +27,37 @@ describe('#NotificationAPI', () => { window.axios = originalAxios; }); - it('#get', () => { - notificationsAPI.get(1); - expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/notifications?page=1' - ); + describe('#get', () => { + it('generates the API call if both params are available', () => { + notificationsAPI.get({ + page: 1, + 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'], + }, + }); + }); }); it('#getNotifications', () => { @@ -65,5 +91,30 @@ describe('#NotificationAPI', () => { '/api/v1/notifications/read_all' ); }); + + it('#snooze', () => { + notificationsAPI.snooze({ id: 1, snoozedUntil: 12332211 }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/notifications/1/snooze', + { + snoozed_until: 12332211, + } + ); + }); + + it('#delete', () => { + notificationsAPI.delete(1); + expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/notifications/1'); + }); + + it('#deleteAll', () => { + notificationsAPI.deleteAll({ type: 'all' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/notifications/destroy_all', + { + type: 'all', + } + ); + }); }); }); diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 31fe6110d..7fcc58127 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue index 5517d12ae..d85fedfa5 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue @@ -56,7 +56,7 @@ class="bg-slate-50 dark:bg-slate-700 rounded text-xxs font-medium mx-1 py-0 px-1" :class=" isCountZero - ? `text-slate-300 dark:text-slate-700` + ? `text-slate-300 dark:text-slate-500` : `text-slate-700 dark:text-slate-50` " > diff --git a/app/javascript/dashboard/components/ui/Banner.vue b/app/javascript/dashboard/components/ui/Banner.vue index 5d680fe48..3473111ae 100644 --- a/app/javascript/dashboard/components/ui/Banner.vue +++ b/app/javascript/dashboard/components/ui/Banner.vue @@ -1,6 +1,6 @@