Compare commits

...
Author SHA1 Message Date
Sojan cb2b75ca17 Merge branch 'release/3.6.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-02-19 15:59:20 +05:30
Sojan 71ee10c889 Bump version to 3.6.0 2024-02-19 15:58:21 +05:30
Sojan JoseandGitHub 978a8a4cb2 fix: support_email and inbound_email_domain returning empty string (#8963)
chore: Fix for inbound email domain being nil
2024-02-19 15:43:35 +05:30
cd06b2b337 fix: Inbox view Read/Snoozed display filters (#8907)
* fix: Notification filters

* Update notification_finder.rb

* Update notification_finder.rb

* Update notification_finder.rb

* fix: spec

* fix: specs

* Update notification_finder.rb

* fix: add more fixes

* Update notification_finder.rb

* fix: specs

* chore: better comments

* chore: removed filtering

* chore: refactoring

* fix: review fixes

* fix: API call

* chore: Minor fix

* Rename spec

* Fix params getting undefined

* Fix finder

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-17 13:59:25 +05:30
6eb06377cc feat: Add textarea component to use in forms (#8932)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-16 12:19:08 -08:00
Sojan JoseandGitHub 94892e7168 chore: Fix flaky spec relying on unguaranteed order (#8956) 2024-02-16 12:11:39 -08:00
Muhsin KelothandGitHub fe744abe26 fix: Mark notification as unread after the un-snooze (#8917) 2024-02-16 17:30:54 +05:30
Nithin David ThomasandGitHub a902b49bc5 feat: Adds bulk_invite api for onboarding view (#8931)
- New API for bulk email invite
2024-02-16 17:01:27 +05:30
Sojan JoseandGitHub 9c07b6dd46 chore: Support for updated_within in ConverationFinder (#8947)
- `updated_within' accepts value in seconds and returns all conversations updated in the given period with out pagination. This API will assist in our refetch logic on socket disconnect

ref: #8888
2024-02-16 16:44:55 +05:30
Muhsin KelothandGitHub 3b7b06dbec feat: Add meta to notification model (#8941)
* feat: Add meta and description to notification model

* chore: remove description
2024-02-16 14:38:02 +05:30
Sivin VargheseandGitHub 597a4164e8 fix: Editor hyperlink popup is hidden under modal (#8950) 2024-02-16 08:19:06 +05:30
Nithin David ThomasandGitHub 678c00f254 fix: Removes default slot for right of label (#8936) 2024-02-14 10:48:54 -08:00
Nithin David ThomasandGitHub 7298002da7 fix: Adds forgot password link for login form (#8927)
- focus ring not appearing issue
- missing forgot password link

Fixes: https://linear.app/chatwoot/issue/CW-3090/fix-fixes-issues-with-login-form
2024-02-14 16:31:32 +05:30
Vishnu NarayananandGitHub 96b781b7fd fix: throttle contact search endpoint to prevent abuse (#8919)
Throttle contact_search endpoint to prevent abuse/maintain db perf
ref: https://discord.com/channels/897869226579222540/899920096972111883/1206919316402999326
2024-02-13 18:48:11 +05:30
Pranav Raj SandGitHub 1ce5cbe275 feat: Add endpoints to retrieve summary of team/agents over a period of time (#8916)
- Internal APIs to prototype reporting improvements.
2024-02-13 15:44:40 +05:30
Pranav Raj SandGitHub c607f09be0 feat: Improve the rendering of CSV exports (#8914)
Improve the rendering of the datestamp
2024-02-12 20:01:58 -08:00
Shivam MishraandGitHub 33a6ad9f7e chore: add more logging to mailbox helpers [CW-3071] (#8909)
* chore: add more logging to mailbox helpers

* fix: deleted entries

* fix: log order

* refactor: log using `processed_mail.message_id`
2024-02-13 08:51:50 +05:30
Shivam MishraandGitHub e662336eaf feat: handle already invited user (#8912) 2024-02-13 08:51:42 +05:30
Shivam MishraandGitHub 0158a55845 fix: Update outline color on login page (#8913) 2024-02-12 11:03:09 -08:00
657843960c feat: account onboarding with clearbit (#8857)
* feat: add clearbit lookup

* chore: fix typo in .env.example

* refactor: split lookup to reduce cognitive complexity

* feat: add more fields to lookup

* feat: extend accounts controller

* feat: save extra data to custom_attributes

* feat: allow v2 update with custom_attributes

* feat: add update route

* refactor: reduce complexity

* feat: move update to v1 controller

* test: add locale test

* feat: remove update from routes

* test: update API for custom attributes

* test: all custom attributes

* fix: v2 tests

* test: enterprise accounts controller

* fix: clearbit payload

* fix: with modified env

* feat: allow custom attributes updates to profile

* refactor: reduce complexity

* feat: allow clearbit api key in installation config

* refactor: move clearbit to internal

* feat: allow clearbit

* chore: add display_title for June

* feat: allow more internal options

* refactor: use globalconfig to fetch clearbit token

* test: move response body to a factory

* refactor: update ops

* chore: remove clearbit from .env.example

* chore: apply suggestions from code review

Co-authored-by: sojan-official <sojan@chatwoot.com>

---------

Co-authored-by: sojan-official <sojan@chatwoot.com>
2024-02-12 23:21:42 +05:30
Sai WarangandGitHub fc6a22b072 fix:Avoid adding all new conversations when on a custom view (#8905) 2024-02-12 07:10:53 -08:00
227d99934e feat: Agent bot cant assign conversations to teams (#8015)
Implemented so that the API can process priority and agent/team changes per Agent Bot.

Fixes:  #7474

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-12 15:51:22 +05:30
Vishnu NarayananandGitHub 5036b28e45 chore: fix git_sha in instance health (#8893)
- Fix git-hash in super admin instance health page for Heroku installations
- Fix git boundary not set error for docker/heroku deployments which remove .git directory
2024-02-12 14:33:15 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8b5735c915 chore(deps): bump grpc from 1.54.0 to 1.54.3 (#8906)
Bumps [grpc](https://github.com/google/grpc) from 1.54.0 to 1.54.3.
- [Release notes](https://github.com/google/grpc/releases)
- [Changelog](https://github.com/grpc/grpc/blob/master/doc/grpc_release_schedule.md)
- [Commits](https://github.com/google/grpc/compare/v1.54.0...v1.54.3)

---
updated-dependencies:
- dependency-name: grpc
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 12:52:45 +05:30
Pranav Raj SandGitHub eb379e1849 fix(refactor): Cleanup the specs and the logic for FetchIMAP job (#8766) 2024-02-10 14:03:50 -08:00
Pranav Raj SandGitHub 6a630bc489 chore: Render timestamp in a better format in conversation traffic export (#8897)
chore: Render timestamp in a better format in conversation traffic reports
2024-02-09 00:48:10 -08:00
Vishnu NarayananandGitHub 0d1b474e60 chore: upgrade to latest versions of gh helper actions (#8896)
https://github.blog/changelog/2023-09-22-github-actions-transitioning-from-node-16-to-node-20/

Fixes: https://github.blog/changelog/2023-09-22-github-actions-transitioning-from-node-16-to-node-20/
2024-02-09 10:56:36 +04:00
Sojan JoseandGitHub 84f6cadae7 chore: Remove unused builders for widget (#8876)
- These builders are not used in our code base, hence removing them
2024-02-08 21:12:55 +04:00
Sojan JoseandGitHub 372e740c38 chore: Add CODEOWNERS (#8889) 2024-02-08 07:44:08 -08:00
57dd979a14 feat: Inbox list filter (#8880)
* feat: Inbox list filter

* fix: routes after delete/unread

* fix: Specs

* feat: Handle sort in frontend

* chore: Minor fixes

* chore: Minor fix

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-02-08 12:11:01 +05:30
c1d07a5471 feat: Add sidekiq jobs to monitor applied SLAs (#8828)
Fixes: https://linear.app/chatwoot/issue/CW-2983/sidekiq-jobservice-to-monitor-sla-breach

Co-authored-by: Sojan <sojan@pepalo.com>
2024-02-07 21:44:56 +04:00
98eddd0532 fix: Send push notification on bot_handoff (#7636)
Send a 'Conversation Created' push notification when the bot does a hand_off.
fixes: #7587

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-02-07 17:45:08 +04:00
Muhsin KelothandGitHub 1b21e0d429 feat: Add notification.updated event (#8871) 2024-02-07 18:15:51 +05:30
Sojan JoseandGitHub 7776b74126 chore: Apply fixes for items in rubocop_todo [CW-1806] (#8864)
This PR addresses several items listed in our rubocop_todo by implementing the necessary corrections and enhancements. As a result, we are now able to remove the rubocop_todo file entirely, streamlining our codebase and ensuring adherence to our coding standards.

fixes: https://linear.app/chatwoot/issue/CW-1806/chore-rubocop-audit
2024-02-07 13:36:04 +04:00
Muhsin KelothandGitHub 3abb887445 fix: Change the route from inbox to inbox-view (#8874)
* fix: Change inbox to inbox-view

* fix: route fixes

* Update ContactInfo.vue

* Update conversation.routes.js
2024-02-07 14:34:18 +05:30
a9ec3ae098 test: ensure that timestamp is in correct range (#8868)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-02-07 13:35:31 +05:30
53927b5a72 chore: Inbox view improvements (#8866)
* chore: Inbox view improvements

* chore: Review fix

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-02-07 13:14:58 +05:30
Muhsin KelothandGitHub d67b91d2b0 feat: Sort Notification API changes (#8865)
* feat: Inbox sort API changes

* Update notification_finder.rb

* Update notification_finder.rb

* Update notification_finder.rb
2024-02-06 21:03:22 +05:30
Sojan JoseandGitHub a4fc28abc0 chore: Squash migrations till 05/2023 (#8862)
- Squash the old migrations up 05/2023 to a single file
2024-02-06 17:00:38 +04:00
bee2a14620 feat: Creates radio select form input for onboarding form (#8860)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-02-06 03:21:46 -08:00
168a4dc323 fix: build error during development using docker-compose (#8714)
* Fix ssl error in final build stage

* add env to docker compose

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2024-02-06 12:56:53 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
47c0decf1e chore(deps): bump nokogiri from 1.16.0 to 1.16.2 (#8861)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.16.0 to 1.16.2.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.16.0...v1.16.2)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-06 09:47:16 +04:00
8acc818f68 feat: allow bulk invite create via email (#8853)
* feat: add agent builder

* feat: use new agent builder

* refactor: validate limit

* test: agent limits

* feat: allow bulk create

* feat: allow bulk create

* refactor: rename current_user to inviter in AgentBuilder

* refactor: move limits tests to enterprise

* test: send correct params

* refactor: account builder returns both user and account_user

* chore: Revert "refactor: account builder returns both user and account_user"

This reverts commit 1419789871e8a3b8ff57af27fe53925b1486a839.

* feat: return user as is

* Update agent_builder.rb

- minor update

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-06 09:04:04 +05:30
9e0468cd73 feat: Inbox header actions (Snooze/Delete) (#8858)
* feat: Inbox header actions (Snooze/Delete)

* chore: Minor fix

* chore: Fix eslint

* Update inboxHotKeys.js

* feat: custom snooze

* Update actions.spec.js

* chore: Clean up

* chore: add snoozed_until to notification end point

* chore: Minor fix

* chore: Minor style fix

* chore:Clean up

* chore: review fixes

* chore: Minor fix

* chore: Adds alert

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-02-06 08:54:15 +05:30
Nithin David ThomasandGitHub 65e9cee019 feat: Form input component to use with onboarding form (#8850)
* feat: Form input component to use with onboarding form

* Update Input.vue

* Update WithLabel.vue

* Review fixes
2024-02-05 10:17:06 -08:00
bb3851a35c feat: Wizard step component to use with onboarding (#8854)
* feat: Wizard step component to use with onboarding

* Update app/javascript/v3/views/onboarding/OnboardingStep.vue

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* Update app/javascript/v3/views/onboarding/OnboardingStep.vue

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* Update app/javascript/v3/views/onboarding/OnboardingStep.vue

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* Review fixes

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-02-05 09:34:33 -08:00
Nithin David ThomasandGitHub 4368bdb2bc feat: Form select component to use with onboarding form (#8852)
* feat: Form select component to use with onboarding form

* Update Select.vue

* Update WithLabel.vue
2024-02-05 09:19:44 -08:00
Sojan JoseandGitHub 1b753720c1 chore: Move Enterprise pricing validation to on create (#8856)
* chore: Move Pricing validation to on create

The previous validation was getting triggered on all transactions,
this would prevent login to existing users and session data need to be updated.
Opting for a less intrusive approach.

* chore: minore change
2024-02-05 19:18:29 +05:30
Muhsin KelothandGitHub 39e27d2a23 feat: Delete all/read notifications (#8844) 2024-02-05 13:33:05 +05:30
Sojan 608592db0c Merge branch 'release/3.5.2'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-19 15:15:27 +04:00
Sojan ab1f803354 Merge branch 'release/3.5.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-17 13:48:22 +04:00
Sojan b81c722ac8 Merge branch 'release/3.5.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-16 11:15:11 +04:00
Sojan b40ad399f6 Merge branch 'release/3.4.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-12-18 21:22:55 -08:00
Sojan dcd18072b4 Merge branch 'release/3.3.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-18 10:24:25 -08:00
Sojan bda2d1d9a4 Merge branch 'release/3.3.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-17 20:37:25 -08:00
Sojan 83231b9678 Merge branch 'release/3.2.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-10-17 17:55:34 -07:00
388 changed files with 4709 additions and 3903 deletions
-1
View File
@@ -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
+7
View File
@@ -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
@@ -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: |
+3 -3
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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:
+11 -70
View File
@@ -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'
-287
View File
@@ -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'
+13 -13
View File
@@ -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)
@@ -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)
+1 -1
View File
@@ -15,7 +15,7 @@ class AccountBuilder
end
[@user, @account]
rescue StandardError => e
puts e.inspect
Rails.logger.debug e.inspect
raise e
end
+60
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -7,8 +7,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
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
@@ -39,6 +39,15 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
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
@@ -40,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
@@ -66,7 +68,7 @@ class Api::V1::AccountsController < Api::BaseController
raise CustomExceptions::Account::InvalidParams.new({})
end
def get_cache_keys
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),
@@ -83,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
@@ -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
@@ -62,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,
@@ -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
@@ -17,8 +17,12 @@ class Api::V2::AccountsController < Api::BaseController
@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 }
@@ -29,6 +33,8 @@ class Api::V2::AccountsController < Api::BaseController
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)
@@ -46,3 +52,5 @@ class Api::V2::AccountsController < Api::BaseController
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')
@@ -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
@@ -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
@@ -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
+7 -1
View File
@@ -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
+16 -7
View File
@@ -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
+10 -10
View File
@@ -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
@@ -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
+8
View File
@@ -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();
+22 -2
View File
@@ -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) {
@@ -36,6 +44,18 @@ class NotificationsAPI extends ApiClient {
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();
@@ -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'],
}
);
});
});
});
@@ -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',
}
);
});
});
});
@@ -753,7 +753,7 @@ export default {
}
.ProseMirror-prompt {
@apply z-50 bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800;
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
h5 {
@apply dark:text-slate-25 text-slate-800;
@@ -66,7 +66,7 @@
</div>
</div>
<div
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0 rtl:relative rtl:left-6"
:class="{ 'justify-end': isContactPanelOpen }"
>
<more-actions :conversation-id="currentChat.id" />
@@ -86,7 +86,7 @@ import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
@@ -158,7 +158,7 @@ export default {
if (snoozedUntil) {
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${conversationReopenTime(snoozedUntil)}`;
)} ${snoozedReopenTime(snoozedUntil)}`;
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
@@ -46,5 +46,18 @@ export default {
},
EXAMPLE_URL: 'https://example.com',
EXAMPLE_WEBHOOK_URL: 'https://example/api/webhook',
INBOX_SORT_BY: {
NEWEST: 'desc',
OLDEST: 'asc',
},
INBOX_DISPLAY_BY: {
SNOOZED: 'snoozed',
READ: 'read',
},
INBOX_FILTER_TYPE: {
STATUS: 'status',
TYPE: 'type',
SORT_ORDER: 'sort_order',
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
@@ -24,6 +24,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
'notification.deleted': this.onNotificationDeleted,
'notification.updated': this.onNotificationUpdated,
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
@@ -200,6 +201,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('notifications/deleteNotification', data);
};
onNotificationUpdated = data => {
this.app.$store.dispatch('notifications/updateNotification', data);
};
// eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
@@ -86,3 +86,6 @@ export const getConversationDashboardRoute = routeName => {
return null;
}
};
export const isAInboxViewRoute = routeName =>
['inbox_view_conversation'].includes(routeName);
@@ -55,7 +55,7 @@ export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
return parsedDate ? getUnixTime(parsedDate) : null;
};
export const conversationReopenTime = snoozedUntil => {
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
@@ -5,6 +5,7 @@ import {
isAConversationRoute,
routeIsAccessibleFor,
validateLoggedInRoutes,
isAInboxViewRoute,
} from '../routeHelpers';
describe('#getCurrentAccount', () => {
@@ -134,3 +135,10 @@ describe('getConversationDashboardRoute', () => {
expect(getConversationDashboardRoute('non_existent_route')).toBeNull();
});
});
describe('isAInboxViewRoute', () => {
it('returns true if inbox view route name is provided', () => {
expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true);
expect(isAInboxViewRoute('inbox_conversation')).toBe(false);
});
});
@@ -1,6 +1,6 @@
import {
findSnoozeTime,
conversationReopenTime,
snoozedReopenTime,
findStartOfNextWeek,
findStartOfNextMonth,
findNextDay,
@@ -88,13 +88,13 @@ describe('#Snooze Helpers', () => {
});
});
describe('conversationReopenTime', () => {
describe('snoozedReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(conversationReopenTime(null)).toEqual(null);
expect(snoozedReopenTime(null)).toEqual(null);
});
it('should return formatted date if snoozedUntil is not nil', () => {
expect(conversationReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
@@ -112,7 +112,8 @@
"REMOVE_LABEL": "Remove label from the conversation",
"SETTINGS": "Settings",
"AI_ASSIST": "AI Assist",
"APPEARANCE": "Appearance"
"APPEARANCE": "Appearance",
"SNOOZE_NOTIFICATION": "Snooze Notification"
},
"COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
@@ -153,7 +154,8 @@
"CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light",
"DARK_MODE": "Dark",
"SYSTEM_MODE": "System"
"SYSTEM_MODE": "System",
"SNOOZE_NOTIFICATION": "Snooze Notification"
}
},
"DASHBOARD_APPS": {
@@ -6,7 +6,11 @@
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group.",
"NOTE": "Notifications from all subscribed inboxes"
"NO_NOTIFICATIONS": "No notifications",
"NOTE": "Notifications from all subscribed inboxes",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week"
},
"ACTION_HEADER": {
"SNOOZE": "Snooze notification",
@@ -42,6 +46,15 @@
"LABELS": "Labels",
"CONVERSATION_ID": "Conversation ID"
}
},
"ALERTS": {
"MARK_AS_READ": "Notification marked as read",
"MARK_AS_UNREAD": "Notification marked as unread",
"SNOOZE": "Notification snoozed",
"DELETE": "Notification deleted",
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
}
}
}
@@ -71,3 +71,5 @@ export const ICON_APPEARANCE = `<svg role="img" class="ninja-icon ninja-icon--fl
export const ICON_LIGHT_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2Zm5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0Zm4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19Zm-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5Zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06Zm1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06l-1.5 1.5Zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06Zm-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06l1.5 1.5Z"/></svg>`;
export const ICON_DARK_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A9.965 9.965 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.232-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.961 9.961 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662Zm-8.248-4.903c-1.25 2.389-3.31 4.1-6.817 5.499a8.49 8.49 0 0 0 2.152 1.766a8.502 8.502 0 0 0 8.502-14.725a8.484 8.484 0 0 0-2.792-1.015c.647 3.384.23 6.043-1.045 8.475Z"/></svg>`;
export const ICON_SYSTEM_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M4.25 3A2.25 2.25 0 0 0 2 5.25v10.5A2.25 2.25 0 0 0 4.25 18H9.5v1.25c0 .69-.56 1.25-1.25 1.25h-.5a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-.5c-.69 0-1.25-.56-1.25-1.25V18h5.25A2.25 2.25 0 0 0 22 15.75V5.25A2.25 2.25 0 0 0 19.75 3H4.25ZM13 18v1.25c0 .45.108.875.3 1.25h-2.6c.192-.375.3-.8.3-1.25V18h2ZM3.5 5.25a.75.75 0 0 1 .75-.75h15.5a.75.75 0 0 1 .75.75V13h-17V5.25Zm0 9.25h17v1.25a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75V14.5Z"/></svg>`;
export const ICON_SNOOZE_NOTIFICATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 3.5c-3.104 0-6 2.432-6 6.25v4.153L4.682 17h14.67l-1.354-3.093V11.75a.75.75 0 0 1 1.5 0v1.843l1.381 3.156a1.25 1.25 0 0 1-1.145 1.751H15a3.002 3.002 0 0 1-6.003 0H4.305a1.25 1.25 0 0 1-1.15-1.739l1.344-3.164V9.75C4.5 5.068 8.103 2 12 2c.86 0 1.705.15 2.5.432a.75.75 0 0 1-.502 1.413A5.964 5.964 0 0 0 12 3.5ZM12 20c.828 0 1.5-.671 1.501-1.5h-3.003c0 .829.673 1.5 1.502 1.5Zm3.25-13h-2.5l-.101.007A.75.75 0 0 0 12.75 8.5h1.043l-1.653 2.314l-.055.09A.75.75 0 0 0 12.75 12h2.5l.102-.007a.75.75 0 0 0-.102-1.493h-1.042l1.653-2.314l.055-.09A.75.75 0 0 0 15.25 7Zm6-5h-3.5l-.101.007A.75.75 0 0 0 17.75 3.5h2.134l-2.766 4.347l-.05.09A.75.75 0 0 0 17.75 9h3.5l.102-.007A.75.75 0 0 0 21.25 7.5h-2.133l2.766-4.347l.05-.09A.75.75 0 0 0 21.25 2Z"/></svg>`;
@@ -15,3 +15,6 @@ export const CMD_REOPEN_CONVERSATION = 'CMD_REOPEN_CONVERSATION';
export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION';
export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION';
export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
// Inbox Commands (Notifications)
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
@@ -12,6 +12,7 @@
<script>
import 'ninja-keys';
import conversationHotKeysMixin from './conversationHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys';
import agentMixin from 'dashboard/mixins/agentMixin';
@@ -25,6 +26,7 @@ export default {
adminMixin,
agentMixin,
conversationHotKeysMixin,
inboxHotKeysMixin,
conversationLabelMixin,
conversationTeamMixin,
appearanceHotKeys,
@@ -42,6 +44,7 @@ export default {
},
hotKeys() {
return [
...this.inboxHotKeys,
...this.conversationHotKeys,
...this.goToCommandHotKeys,
...this.goToAppearanceHotKeys,
@@ -30,7 +30,10 @@ import {
UNMUTE_ACTION,
MUTE_ACTION,
} from './commandBarActions';
import { isAConversationRoute } from '../../../helper/routeHelpers';
import {
isAConversationRoute,
isAInboxViewRoute,
} from '../../../helper/routeHelpers';
export default {
mixins: [aiMixin],
watch: {
@@ -325,7 +328,10 @@ export default {
},
conversationHotKeys() {
if (isAConversationRoute(this.$route.name)) {
if (
isAConversationRoute(this.$route.name) ||
isAInboxViewRoute(this.$route.name)
) {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
@@ -0,0 +1,81 @@
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const INBOX_SNOOZE_EVENTS = [
{
id: 'snooze_notification',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_notification',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export default {
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
@@ -182,6 +182,7 @@ import { getCountryFlag } from 'dashboard/helper/flag';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute,
} from '../../../../helper/routeHelpers';
@@ -303,6 +304,10 @@ export default {
this.$router.push({
name: getConversationDashboardRoute(this.$route.name),
});
} else if (isAInboxViewRoute(this.$route.name)) {
this.$router.push({
name: 'inbox_view',
});
} else if (this.$route.name !== 'contacts_dashboard') {
this.$router.push({
name: 'contacts_dashboard',
@@ -6,8 +6,8 @@ const InboxView = () => import('../inbox/InboxView.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/inbox'),
name: 'inbox',
path: frontendURL('accounts/:accountId/inbox-view'),
name: 'inbox_view',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {
@@ -15,7 +15,7 @@ export default {
},
},
{
path: frontendURL('accounts/:accountId/inbox/:conversation_id'),
path: frontendURL('accounts/:accountId/inbox-view/:conversation_id'),
name: 'inbox_view_conversation',
roles: ['administrator', 'agent'],
component: InboxView,
@@ -3,13 +3,13 @@
class="flex flex-col h-full w-full ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="isOnExpandedLayout ? '' : 'min-w-[360px] max-w-[360px]'"
>
<inbox-list-header />
<inbox-list-header @filter="onFilterChange" />
<div
ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
>
<inbox-card
v-for="notificationItem in records"
v-for="notificationItem in notifications"
:key="notificationItem.id"
:notification-item="notificationItem"
@mark-notification-as-read="markNotificationAsRead"
@@ -20,8 +20,14 @@
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEndOfList"
class="text-center text-slate-300 dark:text-slate-400 p-4"
v-if="showEmptyState"
class="text-center text-slate-400 text-sm dark:text-slate-400 p-4 font-medium"
>
{{ $t('INBOX.LIST.NO_NOTIFICATIONS') }}
</p>
<p
v-if="showEndOfListMessage"
class="text-center text-slate-400 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
@@ -36,16 +42,20 @@
<script>
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import alertMixin from 'shared/mixins/alertMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
},
mixins: [alertMixin, uiSettingsMixin],
props: {
conversationId: {
type: [String, Number],
@@ -63,36 +73,64 @@ export default {
rootMargin: '100px 0px 100px 0px',
},
page: 1,
status: '',
type: '',
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications',
}),
inboxFilters() {
return {
page: this.page,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
};
},
notifications() {
return this.notification(this.inboxFilters);
},
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
showEmptyState() {
return !this.uiFlags.isFetching && !this.notifications.length;
},
showEndOfListMessage() {
return this.showEndOfList && this.notifications.length;
},
},
mounted() {
this.$store.dispatch('notifications/clear');
this.$store.dispatch('notifications/index', { page: 1 });
this.setSavedFilter();
this.fetchNotifications();
},
methods: {
fetchNotifications() {
this.page = 1;
this.$store.dispatch('notifications/clear');
const filter = this.inboxFilters;
this.$store.dispatch('notifications/index', filter);
},
redirectToInbox() {
if (!this.conversationId) return;
if (this.$route.name === 'inbox') return;
this.$router.push({ name: 'inbox' });
},
onMarkAllDoneClick() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
this.$store.dispatch('notifications/readAll');
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', { page: this.page + 1 });
this.$store.dispatch('notifications/index', {
page: this.page + 1,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
});
this.page += 1;
},
markNotificationAsRead(notification) {
@@ -102,29 +140,60 @@ export default {
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notification;
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$store
.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_AS_READ'));
});
},
markNotificationAsUnRead(notification) {
this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
this.redirectToInbox();
const { id } = notification;
this.$store.dispatch('notifications/unread', {
id,
});
this.$store
.dispatch('notifications/unread', {
id,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_AS_UNREAD'));
});
},
deleteNotification(notification) {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.redirectToInbox();
this.$store.dispatch('notifications/delete', {
notification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
});
this.$store
.dispatch('notifications/delete', {
notification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE'));
});
},
onFilterChange(option) {
if (option.type === wootConstants.INBOX_FILTER_TYPE.STATUS) {
this.status = option.selected ? option.key : '';
}
if (option.type === wootConstants.INBOX_FILTER_TYPE.TYPE) {
this.type = option.selected ? option.key : '';
}
if (option.type === wootConstants.INBOX_FILTER_TYPE.SORT_ORDER) {
this.sortOrder = option.key;
}
this.fetchNotifications();
},
setSavedFilter() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { status, type, sort_by: sortBy } = filterBy;
this.status = status;
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
},
},
};
@@ -5,10 +5,15 @@
:conversation-id="conversationId"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div v-if="showInboxMessageView" class="flex flex-col w-full h-full">
<div
v-if="showInboxMessageView"
class="flex flex-col h-full"
:class="isOnExpandedLayout ? 'w-full' : 'w-[calc(100%-360px)]'"
>
<inbox-item-header
:total-length="totalNotifications"
:current-index="activeNotificationIndex"
:active-notification="activeNotification"
@next="onClickNext"
@prev="onClickPrev"
/>
@@ -26,13 +31,13 @@
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-row items-center gap-1">
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="18"
class="text-slate-700 dark:text-slate-400"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-700 text-sm font-medium dark:text-slate-400">
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
@@ -76,6 +81,11 @@ export default {
allConversation: 'getAllConversations',
uiFlags: 'notifications/getUIFlags',
}),
activeNotification() {
return this.notifications.find(
n => n.primary_actor.id === Number(this.conversationId)
);
},
isInboxViewEnabled() {
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
this.currentAccountId,
@@ -138,6 +148,7 @@ export default {
name: 'home',
});
}
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
@@ -1,7 +1,7 @@
<template>
<div
role="button"
class="flex flex-col pl-5 pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
class="flex flex-col ltr:pl-5 rtl:pl-3 rtl:pr-5 ltr:pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
:class="
isInboxCardActive
? 'bg-slate-25 dark:bg-slate-800 click-animation'
@@ -13,7 +13,7 @@
<div class="flex relative items-center justify-between w-full">
<div
v-if="isUnread"
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
class="absolute ltr:-left-3.5 rtl:-right-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" :conversation-id="primaryActor.id" />
@@ -29,15 +29,15 @@
v-if="assigneeMeta"
:src="assigneeMeta.thumbnail"
:username="assigneeMeta.name"
size="20px"
size="16px"
class="relative bottom-0.5"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
class="text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="isUnread ? 'font-medium' : 'font-normal'"
>
<span class="font-normal text-sm">
{{ pushTitle }}
</span>
{{ pushTitle }}
</span>
</div>
</div>
@@ -47,6 +47,11 @@
{{ lastActivityAt }}
</span>
</div>
<div v-if="snoozedUntilTime" class="flex items-center">
<span class="text-woot-500 dark:text-woot-500 text-xs font-medium">
{{ snoozedDisplayText }}
</span>
</div>
<inbox-context-menu
v-if="isContextMenuOpen"
:context-menu-position="contextMenuPosition"
@@ -63,6 +68,7 @@ import InboxNameAndId from './InboxNameAndId.vue';
import InboxContextMenu from './InboxContextMenu.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
@@ -138,6 +144,18 @@ export default {
}
return items;
},
snoozedUntilTime() {
const { snoozed_until: snoozedUntil } = this.notificationItem;
return snoozedUntil;
},
snoozedDisplayText() {
if (this.snoozedUntilTime) {
return `${this.$t('INBOX.LIST.SNOOZED_UNTIL')} ${snoozedReopenTime(
this.snoozedUntilTime
)}`;
}
return '';
},
},
unmounted() {
this.closeContextMenu();
@@ -5,7 +5,7 @@
@close="handleClose"
>
<div
class="bg-white dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-500 rounded-xl"
class="bg-white dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl"
>
<menu-item
v-for="item in menuItems"
@@ -38,18 +38,17 @@
:key="option.key"
role="button"
class="flex rounded-[4px] h-5 w-full items-center justify-between p-0.5 gap-1"
:class="
activeSort === option.key ? 'bg-woot-50 dark:bg-woot-700/50' : ''
"
@click.stop="onSortOptionClick(option.key)"
:class="{
'bg-woot-50 dark:bg-woot-700/50': activeSort === option.key,
}"
@click.stop="onSortOptionClick(option)"
>
<span
class="text-xs font-medium hover:text-woot-600 dark:hover:text-woot-600"
:class="
activeSort === option.key
? 'text-woot-600 dark:text-woot-600'
: 'text-slate-600 dark:text-slate-300'
"
:class="{
'text-woot-600 dark:text-woot-600': activeSort === option.key,
'text-slate-600 dark:text-slate-300': activeSort !== option.key,
}"
>
{{ option.name }}
</span>
@@ -74,19 +73,19 @@
>
<div
v-for="option in displayOptions"
:key="option.id"
:key="option.key"
class="flex items-center px-3 py-2 gap-1.5 h-9"
>
<input
:id="option.value"
:id="option.key"
type="checkbox"
:name="option.value"
:name="option.key"
:checked="option.selected"
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 checked:bg-woot-600 dark:checked:bg-woot-600 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-woot-700 dark:checked:border-woot-300 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
@change="updateDisplayOption(option)"
/>
<label
:for="option.value"
:for="option.key"
class="text-xs font-medium text-slate-800 !ml-0 !mr-0 dark:text-slate-100"
>
{{ option.name }}
@@ -98,71 +97,101 @@
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default {
mixins: [uiSettingsMixin],
data() {
return {
showSortMenu: false,
displayOptions: [
{
id: 1,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.SNOOZED'),
value: 'snoozed',
key: wootConstants.INBOX_DISPLAY_BY.SNOOZED,
selected: false,
type: wootConstants.INBOX_FILTER_TYPE.STATUS,
},
{
id: 2,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.READ'),
value: 'read',
selected: true,
},
{
id: 3,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.LABELS'),
value: 'labels',
selected: false,
},
{
id: 4,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.CONVERSATION_ID'),
value: 'conversationId',
key: wootConstants.INBOX_DISPLAY_BY.READ,
selected: false,
type: wootConstants.INBOX_FILTER_TYPE.TYPE,
},
],
sortOptions: [
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.NEWEST'),
key: 'newest',
key: wootConstants.INBOX_SORT_BY.NEWEST,
type: wootConstants.INBOX_FILTER_TYPE.SORT_ORDER,
},
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.OLDEST'),
key: 'oldest',
},
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.PRIORITY'),
key: 'priority',
key: wootConstants.INBOX_SORT_BY.OLDEST,
type: wootConstants.INBOX_FILTER_TYPE.SORT_ORDER,
},
],
activeSort: 'newest',
activeSort: wootConstants.INBOX_SORT_BY.NEWEST,
activeDisplayFilter: {
status: '',
type: '',
},
};
},
computed: {
activeSortOption() {
return this.sortOptions.find(option => option.key === this.activeSort)
.name;
return (
this.sortOptions.find(option => option.key === this.activeSort)?.name ||
''
);
},
},
mounted() {
this.setSavedFilter();
},
methods: {
updateDisplayOption(option) {
option.selected = !option.selected;
// TODO: Update the display options
this.displayOptions.forEach(displayOption => {
if (displayOption.key === option.key) {
displayOption.selected = !option.selected;
this.activeDisplayFilter[displayOption.type] = displayOption.selected
? displayOption.key
: '';
this.saveSelectedDisplayFilter();
this.$emit('filter', option);
}
});
},
openSortMenu() {
this.showSortMenu = !this.showSortMenu;
},
onSortOptionClick(key) {
this.activeSort = key;
onSortOptionClick(option) {
this.activeSort = option.key;
this.showSortMenu = false;
// TODO: Update the sort options
this.saveSelectedDisplayFilter();
this.$emit('filter', option);
},
saveSelectedDisplayFilter() {
this.updateUISettings({
inbox_filter_by: {
...this.activeDisplayFilter,
sort_by: this.activeSort || wootConstants.INBOX_SORT_BY.NEWEST,
},
});
},
setSavedFilter() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { status, type, sort_by: sortBy } = filterBy;
this.activeSort = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
this.displayOptions.forEach(option => {
option.selected =
option.type === wootConstants.INBOX_FILTER_TYPE.STATUS
? option.key === status
: option.key === type;
this.activeDisplayFilter[option.type] = option.selected
? option.key
: '';
});
},
},
};
@@ -1,20 +1,22 @@
<template>
<div
class="flex gap-2 py-2 pl-4 h-14 pr-2 justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
class="flex gap-2 py-2 ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
>
<pagination-button
v-if="totalLength > 1"
:total-length="totalLength"
:current-index="currentIndex"
@next="onClickNext"
@prev="onClickPrev"
/>
<div v-else />
<div class="flex items-center gap-2">
<woot-button
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
@click="onSnooze"
@click="openSnoozeNotificationModal"
>
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
</woot-button>
@@ -23,20 +25,39 @@
size="small"
color-scheme="secondary"
variant="hollow"
@click="onDelete"
@click="deleteNotification"
>
{{ $t('INBOX.ACTION_HEADER.DELETE') }}
</woot-button>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { getUnixTime } from 'date-fns';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import PaginationButton from './PaginationButton.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
PaginationButton,
CustomSnoozeModal,
},
mixins: [alertMixin],
props: {
totalLength: {
type: Number,
@@ -46,10 +67,73 @@ export default {
type: Number,
default: 0,
},
activeNotification: {
type: Object,
default: null,
},
},
data() {
return {
showCustomSnoozeModal: false,
};
},
computed: {
...mapGetters({
meta: 'notifications/getMeta',
}),
},
mounted() {
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
onSnooze() {},
onDelete() {},
openSnoozeNotificationModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
snoozeNotification(snoozedUntil) {
this.$store
.dispatch('notifications/snooze', {
id: this.activeNotification?.id,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
});
},
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
}
},
scheduleCustomSnooze(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
this.snoozeNotification(snoozedUntil);
}
},
deleteNotification() {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.$store
.dispatch('notifications/delete', {
notification: this.activeNotification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE'));
});
this.$router.push({ name: 'inbox_view' });
},
onClickNext() {
this.$emit('next');
},
@@ -1,6 +1,6 @@
<template>
<div
class="flex w-full pl-4 pr-2 py-2 h-14 justify-between items-center border-b border-slate-50 dark:border-slate-800/50"
class="flex w-full ltr:pl-4 rtl:pl-2 rtl:pr-4 ltr:pr-2 py-2 h-14 justify-between items-center border-b border-slate-50 dark:border-slate-800/50"
>
<div class="flex items-center gap-1.5">
<h1 class="font-medium text-slate-900 dark:text-slate-25 text-xl">
@@ -27,6 +27,7 @@
v-if="showInboxDisplayMenu"
v-on-clickaway="openInboxDisplayMenu"
class="absolute top-8"
@filter="onFilterChange"
/>
</div>
</div>
@@ -49,6 +50,7 @@
v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9"
@option-click="onInboxOptionMenuClick"
/>
</div>
</div>
@@ -57,13 +59,16 @@
<script>
import { mixin as clickaway } from 'vue-clickaway';
import InboxOptionMenu from './InboxOptionMenu.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxDisplayMenu from './InboxDisplayMenu.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
InboxOptionMenu,
InboxDisplayMenu,
},
mixins: [clickaway],
mixins: [clickaway, alertMixin],
data() {
return {
showInboxDisplayMenu: false,
@@ -71,12 +76,46 @@ export default {
};
},
methods: {
markAllRead() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
this.$store.dispatch('notifications/readAll').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.MARK_ALL_READ'));
});
},
deleteAll() {
this.$store.dispatch('notifications/deleteAll').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE_ALL'));
});
},
deleteAllRead() {
this.$store.dispatch('notifications/deleteAllRead').then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE_ALL_READ'));
});
},
openInboxDisplayMenu() {
this.showInboxDisplayMenu = !this.showInboxDisplayMenu;
},
openInboxOptionsMenu() {
this.showInboxOptionMenu = !this.showInboxOptionMenu;
},
onInboxOptionMenuClick(key) {
this.showInboxOptionMenu = false;
if (key === 'mark_all_read') {
this.markAllRead();
}
if (key === 'delete_all') {
this.deleteAll();
}
if (key === 'delete_all_read') {
this.deleteAllRead();
}
},
onFilterChange(option) {
this.$emit('filter', option);
this.showInboxDisplayMenu = false;
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
},
},
};
</script>
@@ -1,21 +1,13 @@
<template>
<div
class="flex flex-col gap-1 bg-white z-50 dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-500 rounded-xl divide-y divide-slate-100 dark:divide-slate-700/50"
class="flex flex-col gap-1 bg-white z-50 dark:bg-slate-900 w-40 py-1 border shadow-md border-slate-100 dark:border-slate-700/50 rounded-xl divide-y divide-slate-100 dark:divide-slate-700/50"
>
<div class="flex flex-col">
<menu-item
v-for="item in menuItems"
:key="item.key"
:label="item.label"
@click="onMenuItemClick(item.key)"
/>
</div>
<div class="flex flex-col">
<menu-item
v-for="item in commonMenuItems"
:key="item.key"
:label="item.label"
@click="onMenuItemClick(item.key)"
@click="onClick(item.key)"
/>
</div>
</div>
@@ -30,24 +22,6 @@ export default {
data() {
return {
menuItems: [
{
key: 'mark_as_read',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_READ'),
},
{
key: 'mark_as_unread',
label: this.$t('INBOX.MENU_ITEM.MARK_AS_UNREAD'),
},
{
key: 'snooze',
label: this.$t('INBOX.MENU_ITEM.SNOOZE'),
},
{
key: 'delete',
label: this.$t('INBOX.MENU_ITEM.DELETE'),
},
],
commonMenuItems: [
{
key: 'mark_all_read',
label: this.$t('INBOX.MENU_ITEM.MARK_ALL_READ'),
@@ -64,7 +38,7 @@ export default {
};
},
methods: {
onMenuItemClick(key) {
onClick(key) {
this.$emit('option-click', key);
},
},
@@ -8,6 +8,7 @@ import {
buildConversationList,
isOnMentionsView,
isOnUnattendedView,
isOnFoldersView,
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
@@ -321,12 +322,12 @@ const actions = {
inbox_id: inboxId,
meta: { sender },
} = conversation;
const hasAppliedFilters = !!appliedFilters.length;
const isMatchingInboxFilter =
!currentInbox || Number(currentInbox) === inboxId;
if (
!hasAppliedFilters &&
!isOnFoldersView(rootState) &&
!isOnMentionsView(rootState) &&
!isOnUnattendedView(rootState) &&
isMatchingInboxFilter
@@ -30,6 +30,14 @@ export const isOnUnattendedView = ({ route: { name: routeName } }) => {
return UNATTENDED_ROUTES.includes(routeName);
};
export const isOnFoldersView = ({ route: { name: routeName } }) => {
const FOLDER_ROUTES = [
'folder_conversations',
'conversations_through_folders',
];
return FOLDER_ROUTES.includes(routeName);
};
export const buildConversationList = (
context,
requestPayload,
@@ -1,4 +1,4 @@
import { isOnMentionsView } from '../actionHelpers';
import { isOnMentionsView, isOnFoldersView } from '../actionHelpers';
describe('#isOnMentionsView', () => {
it('return valid responses when passing the state', () => {
@@ -10,3 +10,17 @@ describe('#isOnMentionsView', () => {
);
});
});
describe('#isOnFoldersView', () => {
it('return valid responses when passing the state', () => {
expect(isOnFoldersView({ route: { name: 'folder_conversations' } })).toBe(
true
);
expect(
isOnFoldersView({ route: { name: 'conversations_through_folders' } })
).toBe(true);
expect(isOnFoldersView({ route: { name: 'conversation_messages' } })).toBe(
false
);
});
});
@@ -9,7 +9,7 @@ export const actions = {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get(page);
} = await NotificationsAPI.get({ page });
commit(types.CLEAR_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
@@ -18,14 +18,19 @@ export const actions = {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
index: async ({ commit }, { page = 1 } = {}) => {
index: async ({ commit }, { page = 1, status, type, sortOrder } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get(page);
} = await NotificationsAPI.get({
page,
status,
type,
sortOrder,
});
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
@@ -54,7 +59,7 @@ export const actions = {
try {
await NotificationsAPI.read(primaryActorType, primaryActorId);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.UPDATE_NOTIFICATION, { id, read_at: new Date() });
commit(types.READ_NOTIFICATION, { id, read_at: new Date() });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
@@ -64,7 +69,7 @@ export const actions = {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.unRead(id);
commit(types.UPDATE_NOTIFICATION, { id, read_at: null });
commit(types.READ_NOTIFICATION, { id, read_at: null });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
@@ -95,6 +100,56 @@ export const actions = {
}
},
deleteAllRead: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'read',
});
commit(types.DELETE_READ_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAll: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'all',
});
commit(types.DELETE_ALL_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
snooze: async ({ commit }, { id, snoozedUntil }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
const response = await NotificationsAPI.snooze({
id,
snoozedUntil,
});
const {
data: { snoozed_until = null },
} = response;
commit(types.SNOOZE_NOTIFICATION, {
id,
snoozed_until,
});
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
updateNotification: async ({ commit }, data) => {
commit(types.UPDATE_NOTIFICATION, data);
},
addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data);
},
@@ -1,7 +1,16 @@
import { sortComparator } from './helpers';
export const getters = {
getNotifications($state) {
return Object.values($state.records).sort((n1, n2) => n2.id - n1.id);
},
getFilteredNotifications: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
);
return sortedNotifications;
},
getUIFlags($state) {
return $state.uiFlags;
},
@@ -0,0 +1,17 @@
const INBOX_SORT_OPTIONS = {
newest: 'desc',
oldest: 'asc',
};
const sortConfig = {
newest: (a, b) => b.created_at - a.created_at,
oldest: (a, b) => a.created_at - b.created_at,
};
export const sortComparator = (a, b, sortOrder) => {
const sortDirection = INBOX_SORT_OPTIONS[sortOrder];
if (sortOrder === 'newest' || sortOrder === 'oldest') {
return sortConfig[sortOrder](a, b, sortDirection);
}
return 0;
};
@@ -34,7 +34,7 @@ export const mutations = {
});
});
},
[types.UPDATE_NOTIFICATION]: ($state, { id, read_at }) => {
[types.READ_NOTIFICATION]: ($state, { id, read_at }) => {
Vue.set($state.records[id], 'read_at', read_at);
},
[types.UPDATE_ALL_NOTIFICATIONS]: $state => {
@@ -52,6 +52,15 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
[types.UPDATE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
Vue.set($state.records, notification.id, {
...($state.records[notification.id] || {}),
...notification,
});
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
[types.DELETE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
Vue.delete($state.records, notification.id);
@@ -61,4 +70,19 @@ export const mutations = {
[types.SET_ALL_NOTIFICATIONS_LOADED]: $state => {
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', true);
},
[types.DELETE_READ_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
if (item.read_at) {
Vue.delete($state.records, item.id);
}
});
},
[types.DELETE_ALL_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {});
},
[types.SNOOZE_NOTIFICATION]: ($state, { id, snoozed_until }) => {
Vue.set($state.records[id], 'snoozed_until', snoozed_until);
},
};
@@ -181,6 +181,26 @@ describe('#actions', () => {
expect(dispatch.mock.calls).toEqual([]);
});
it('doesnot send mutation if the view is conversation folders', () => {
const conversation = {
id: 1,
messages: [],
meta: { sender: { id: 1, name: 'john-doe' } },
inbox_id: 1,
};
actions.addConversation(
{
commit,
rootState: { route: { name: 'folder_conversations' } },
dispatch,
state: { currentInbox: 1, appliedFilters: [{ id: 'random-filter' }] },
},
conversation
);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
it('sends correct mutations', () => {
const conversation = {
id: 1,
@@ -1,7 +1,6 @@
import axios from 'axios';
import { actions } from '../../notifications/actions';
import types from '../../../mutation-types';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
@@ -101,7 +100,7 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 1],
[types.UPDATE_NOTIFICATION, { id: 1, read_at: expect.any(Date) }],
[types.READ_NOTIFICATION, { id: 1, read_at: expect.any(Date) }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
@@ -125,7 +124,7 @@ describe('#actions', () => {
await actions.unread({ commit }, { id: 1 });
expect(commit.mock.calls).toEqual([
['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: true }],
['UPDATE_NOTIFICATION', { id: 1, read_at: null }],
['READ_NOTIFICATION', { id: 1, read_at: null }],
['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: false }],
]);
});
@@ -219,4 +218,69 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([[types.CLEAR_NOTIFICATIONS]]);
});
});
describe('deleteAllRead', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_READ_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_READ_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('deleteAll', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('snooze', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({
data: { snoozed_until: '20 Jan, 5.04pm' },
});
await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SNOOZE_NOTIFICATION, { id: 1, snoozed_until: '20 Jan, 5.04pm' }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
});
});
@@ -16,6 +16,34 @@ describe('#getters', () => {
]);
});
it('getFilteredNotifications', () => {
const state = {
records: {
1: { id: 1, read_at: '2024-02-07T11:42:39.988Z', snoozed_until: null },
2: { id: 2, read_at: null, snoozed_until: null },
3: {
id: 3,
read_at: '2024-02-07T11:42:39.988Z',
snoozed_until: '2024-02-07T11:42:39.988Z',
},
},
};
const filters = {
type: 'read',
status: 'snoozed',
sortOrder: 'desc',
};
expect(getters.getFilteredNotifications(state)(filters)).toEqual([
{ id: 1, read_at: '2024-02-07T11:42:39.988Z', snoozed_until: null },
{ id: 2, read_at: null, snoozed_until: null },
{
id: 3,
read_at: '2024-02-07T11:42:39.988Z',
snoozed_until: '2024-02-07T11:42:39.988Z',
},
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
@@ -0,0 +1,74 @@
import { sortComparator } from '../../notifications/helpers';
const notifications = [
{
id: 1,
read_at: '2024-02-07T11:42:39.988Z',
snoozed_until: null,
created_at: 1707328400,
},
{
id: 2,
read_at: null,
snoozed_until: null,
created_at: 1707233688,
},
{
id: 3,
read_at: '2024-01-07T11:42:39.988Z',
snoozed_until: null,
created_at: 1707233672,
},
{
id: 4,
read_at: null,
snoozed_until: '2024-02-08T03:30:00.000Z',
created_at: 1707233667,
},
{
id: 5,
read_at: '2024-02-07T10:42:39.988Z',
snoozed_until: '2024-02-08T03:30:00.000Z',
created_at: 1707233662,
},
{
id: 6,
read_at: null,
snoozed_until: '2024-02-08T03:30:00.000Z',
created_at: 1707233561,
},
];
describe('#sortComparator', () => {
it('returns the notifications sorted by newest', () => {
const sortOrder = 'newest';
const sortedNotifications = [...notifications].sort((a, b) =>
sortComparator(a, b, sortOrder)
);
const expectedOrder = [
notifications[0],
notifications[1],
notifications[2],
notifications[3],
notifications[4],
notifications[5],
].sort((a, b) => b.created_at - a.created_at);
expect(sortedNotifications).toEqual(expectedOrder);
});
it('returns the notifications sorted by oldest', () => {
const sortOrder = 'oldest';
const sortedNotifications = [...notifications].sort((a, b) =>
sortComparator(a, b, sortOrder)
);
const expectedOrder = [
notifications[0],
notifications[1],
notifications[2],
notifications[3],
notifications[4],
notifications[5],
].sort((a, b) => a.created_at - b.created_at);
expect(sortedNotifications).toEqual(expectedOrder);
});
});
@@ -75,7 +75,7 @@ describe('#mutations', () => {
1: { id: 1, primary_actor_id: 1 },
},
};
mutations[types.UPDATE_NOTIFICATION](state, {
mutations[types.READ_NOTIFICATION](state, {
id: 1,
read_at: true,
});
@@ -155,4 +155,32 @@ describe('#mutations', () => {
expect(state.uiFlags).toEqual({ isAllNotificationsLoaded: true });
});
});
describe('#DELETE_READ_NOTIFICATIONS', () => {
it('delete read notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_READ_NOTIFICATIONS](state);
expect(state.records).toEqual({
2: { id: 2, primary_actor_id: 2 },
});
});
});
describe('#DELETE_ALL_NOTIFICATIONS', () => {
it('delete all notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_ALL_NOTIFICATIONS](state);
expect(state.records).toEqual({});
});
});
});
@@ -132,6 +132,7 @@ export default {
SET_NOTIFICATIONS_UNREAD_COUNT: 'SET_NOTIFICATIONS_UNREAD_COUNT',
SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG',
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION',
READ_NOTIFICATION: 'READ_NOTIFICATION',
ADD_NOTIFICATION: 'ADD_NOTIFICATION',
DELETE_NOTIFICATION: 'DELETE_NOTIFICATION',
UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS',
@@ -141,6 +142,9 @@ export default {
EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS',
UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE',
SET_ALL_NOTIFICATIONS_LOADED: 'SET_ALL_NOTIFICATIONS_LOADED',
DELETE_READ_NOTIFICATIONS: 'DELETE_READ_NOTIFICATIONS',
DELETE_ALL_NOTIFICATIONS: 'DELETE_ALL_NOTIFICATIONS',
SNOOZE_NOTIFICATION: 'SNOOZE_NOTIFICATION',
// Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
+50 -36
View File
@@ -1,44 +1,45 @@
<template>
<div>
<label
v-if="label"
:for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
:class="{ 'text-red-500': hasError }"
>
{{ label }}
<with-label
:label="label"
:icon="icon"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<template #rightOfLabel>
<slot />
</label>
<div class="mt-1">
<input
:id="name"
:name="name"
:type="type"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:class="{
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
!hasError,
}"
class="block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700"
@input="onInput"
@blur="$emit('blur')"
/>
<span
v-if="errorMessage && hasError"
class="text-xs leading-2 text-red-400"
>
{{ errorMessage }}
</span>
</div>
</div>
</template>
<input
:id="name"
:name="name"
:type="type"
autocomplete="off"
:tabindex="tabindex"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:class="{
'focus:outline-red-600 outline-red-600 dark:focus:outline-red-600 dark:outline-red-600':
hasError,
'outline-slate-200 dark:outline-slate-600 dark:focus:outline-woot-500 focus:outline-woot-500':
!hasError,
'px-3 py-3': spacing === 'base',
'px-3 py-2 mb-0': spacing === 'compact',
'pl-9': icon,
}"
class="block w-full border-none shadow-sm appearance-none rounded-xl outline outline-1 focus:outline-2 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 sm:text-sm sm:leading-6 dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
label: {
type: String,
@@ -52,6 +53,10 @@ export default {
type: String,
default: 'text',
},
tabindex: {
type: Number,
default: undefined,
},
required: {
type: Boolean,
default: false,
@@ -64,6 +69,10 @@ export default {
type: [String, Number],
default: '',
},
icon: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
@@ -76,6 +85,11 @@ export default {
type: String,
default: '',
},
spacing: {
type: String,
default: 'base',
validator: value => ['base', 'compact'].includes(value),
},
},
methods: {
onInput(e) {
@@ -0,0 +1,60 @@
<template>
<with-label
:label="label"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<div class="flex gap-2 flex-wrap">
<woot-button
v-for="option in options"
:key="option.value"
:variant="value === option.value ? '' : 'hollow'"
:color-scheme="value === option.value ? 'primary' : 'secondary'"
size="small"
@click="$emit('input', option.value)"
>
{{ option.label }}
</woot-button>
</div>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
id: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
};
</script>
@@ -0,0 +1,92 @@
<template>
<with-label
:label="label"
:icon="icon"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<select
:id="id"
:selected="value"
:name="name"
:class="{
'text-slate-400': !value,
'text-slate-900 dark:text-slate-100': value,
'pl-9': icon,
}"
class="mb-0 block w-full px-3 py-2 pr-6 border-0 rounded-xl shadow-sm outline-none appearance-none select-caret ring-1 ring-inset placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200"
@input="onInput"
>
<option value="" disabled selected class="hidden">
{{ placeholder }}
</option>
<slot>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</slot>
</select>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
id: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
icon: {
type: String,
default: '',
},
label: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
<style scoped>
.select-caret {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-origin: content-box;
background-position: right -1rem center;
background-repeat: no-repeat;
background-size: 9px 6px;
}
</style>
@@ -0,0 +1,83 @@
<template>
<with-label
:label="label"
:name="name"
:has-error="hasError"
:error-message="errorMessage"
>
<textarea
:id="name"
:name="name"
autocomplete="off"
:required="required"
:placeholder="placeholder"
:data-testid="dataTestid"
:value="value"
:rows="rows"
:class="{
'focus:outline-red-600 outline-red-600': hasError,
'dark:outline-slate-600 dark:focus:outline-woot-500 outline-slate-200 focus:outline-woot-500':
!hasError,
'resize-none': !allowResize,
}"
class="block w-full p-3 border-none rounded-xl shadow-sm appearance-none outline outline-1 focus:outline focus:outline-2 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 bg-white dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
</with-label>
</template>
<script>
import WithLabel from './WithLabel.vue';
export default {
components: {
WithLabel,
},
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
rows: {
type: Number,
default: 3,
},
allowResize: {
type: Boolean,
default: true,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
dataTestid: {
type: String,
default: '',
},
},
methods: {
onInput(e) {
this.$emit('input', e.target.value);
},
},
};
</script>
@@ -0,0 +1,58 @@
<template>
<div class="space-y-1">
<label
v-if="label"
:for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
:class="{ 'text-red-500': hasError }"
>
<slot name="label">
{{ label }}
</slot>
<slot name="rightOfLabel" />
</label>
<div class="w-full">
<div class="flex items-center relative w-full">
<fluent-icon
v-if="icon"
size="16"
:icon="icon"
class="absolute left-2 transform text-slate-400 dark:text-slate-600 w-5 h-5"
/>
<slot />
</div>
<span
v-if="errorMessage && hasError"
class="text-xs text-red-400 leading-2"
>
{{ errorMessage }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
icon: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
};
</script>
+8 -1
View File
@@ -46,6 +46,7 @@
name="email_address"
type="text"
data-testid="email_input"
:tabindex="1"
required
:label="$t('LOGIN.EMAIL.LABEL')"
:placeholder="$t('LOGIN.EMAIL.PLACEHOLDER')"
@@ -58,19 +59,25 @@
name="password"
data-testid="password_input"
required
:tabindex="2"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('LOGIN.PASSWORD.PLACEHOLDER')"
:has-error="$v.credentials.password.$error"
@input="$v.credentials.password.$touch"
>
<p v-if="!globalConfig.disableUserProfileUpdate">
<router-link to="auth/reset/password" class="text-link">
<router-link
to="auth/reset/password"
class="text-link text-sm"
tabindex="4"
>
{{ $t('LOGIN.FORGOT_PASSWORD') }}
</router-link>
</p>
</form-input>
<submit-button
:disabled="loginApi.showLoading"
:tabindex="3"
:button-text="$t('LOGIN.SUBMIT')"
:loading="loginApi.showLoading"
/>
@@ -0,0 +1,54 @@
<template>
<div
class="flex items-center gap-2 p-2 text-lg font-bold mb-6 relative before:absolute before:h-10 before:w-[1px] before:bg-slate-200 before:-bottom-8 before:left-[24px] hide-before-of-last"
:class="{
'text-woot-500 ': isActive,
'text-slate-400': !isActive || isComplete,
'before:bg-woot-500': !isActive && isComplete,
}"
>
<div
class="grid w-8 h-8 border border-solid rounded-full place-content-center"
:class="{
'border-woot-500': !isActive || isComplete,
'border-slate-200 dark:border-slate-600': !isActive && !isComplete,
'text-woot-500': isComplete,
}"
>
<fluent-icon v-if="isComplete" size="20" icon="checkmark" />
<fluent-icon v-else size="20" :icon="icon" />
</div>
<span>
{{ title }}
</span>
</div>
</template>
<script>
export default {
name: 'OnboardingStep',
props: {
title: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
isComplete: {
type: Boolean,
default: false,
},
},
};
</script>
<style scoped>
.hide-before-of-last:last-child::before {
display: none;
}
</style>
+7 -127
View File
@@ -7,6 +7,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
return unless should_fetch_email?(channel)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) do
process_email_for_channel(channel)
end
@@ -28,128 +29,16 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
end
def process_email_for_channel(channel)
if channel.microsoft?
fetch_mail_for_ms_provider(channel)
else
fetch_mail_for_channel(channel)
end
# clearing old failures like timeouts since the mail is now successfully processed
channel.reauthorized!
end
def fetch_mail_for_channel(channel)
imap_client = build_imap_client(channel, channel.imap_password, 'PLAIN')
message_ids_with_seq = fetch_message_ids_with_sequence(imap_client, channel)
message_ids_with_seq.each do |message_id_with_seq|
process_message_id(channel, imap_client, message_id_with_seq)
end
end
def process_message_id(channel, imap_client, message_id_with_seq)
seq_no, message_id = message_id_with_seq
return if email_already_present?(channel, message_id)
if message_id.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
return
end
# Fetch the original mail content using the sequence no
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
if mail_str.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
return
end
inbound_mail = build_mail_from_string(mail_str)
mail_info_logger(channel, inbound_mail, seq_no)
process_mail(inbound_mail, channel)
end
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
# You can send batches of message sequence number in `.fetch` method.
def fetch_message_ids_with_sequence(imap_client, channel)
seq_nums = fetch_available_mail_sequence_numbers(imap_client)
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
message_ids_with_seq = []
seq_nums.each_slice(10).each do |batch|
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
# Check
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
next
end
batch_message_ids.each do |data|
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
end
end
message_ids_with_seq
end
# Sends a SEARCH command to search the mailbox for messages that were
# created between yesterday and today and returns message sequence numbers.
# Return <message set>
def fetch_available_mail_sequence_numbers(imap_client)
imap_client.search(['SINCE', yesterday])
end
def fetch_mail_for_ms_provider(channel)
return if channel.provider_config['access_token'].blank?
access_token = valid_access_token channel
return unless access_token
imap_client = build_imap_client(channel, access_token, 'XOAUTH2')
process_mails(imap_client, channel)
end
def process_mails(imap_client, channel)
fetch_available_mail_sequence_numbers(imap_client).each do |seq_no|
inbound_mail = Mail.read_from_string imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
mail_info_logger(channel, inbound_mail, seq_no)
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
inbound_emails = if channel.microsoft?
Imap::MicrosoftFetchEmailService.new(channel: channel).perform
else
Imap::FetchEmailService.new(channel: channel).perform
end
inbound_emails.map do |inbound_mail|
process_mail(inbound_mail, channel)
end
end
def mail_info_logger(channel, inbound_mail, uid)
return if Rails.env.test?
Rails.logger.info("
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{uid}")
end
def build_imap_client(channel, access_token, auth_method)
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
imap.authenticate(auth_method, channel.imap_login, access_token)
imap.select('INBOX')
imap
end
def email_already_present?(channel, message_id)
channel.inbox.messages.find_by(source_id: message_id).present?
end
def build_mail_from_string(raw_email_content)
Mail.read_from_string(raw_email_content)
end
def process_mail(inbound_mail, channel)
Imap::ImapMailbox.new.process(inbound_mail, channel)
rescue StandardError => e
@@ -157,13 +46,4 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
Rails.logger.error("
#{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
end
# Making sure the access token is valid for microsoft provider
def valid_access_token(channel)
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
end
@@ -0,0 +1,15 @@
class Notification::DeleteNotificationJob < ApplicationJob
queue_as :low
def perform(user, type: :all)
ActiveRecord::Base.transaction do
if type == :all
# Delete all notifications
user.notifications.destroy_all
elsif type == :read
# Delete only read notifications
user.notifications.where.not(read_at: nil).destroy_all
end
end
end
end
@@ -4,7 +4,7 @@ class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
def perform
# rubocop:disable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current)
.update_all(snoozed_until: nil, updated_at: Time.current, last_activity_at: Time.current)
.update_all(snoozed_until: nil, updated_at: Time.current, last_activity_at: Time.current, read_at: nil)
# rubocop:enable Rails/SkipsModelValidations
end
end
+2
View File
@@ -24,3 +24,5 @@ class TriggerScheduledItemsJob < ApplicationJob
Notification::RemoveOldNotificationJob.perform_later
end
end
TriggerScheduledItemsJob.prepend_mod_with('TriggerScheduledItemsJob')
+6
View File
@@ -7,6 +7,12 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
end
def notification_updated(event)
notification, account, unread_count, count = extract_notification_and_account(event)
tokens = [event.data[:notification].user.pubsub_token]
broadcast(account, tokens, NOTIFICATION_UPDATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
end
def notification_deleted(event)
return if event.data[:notification].user.blank?
+14
View File
@@ -1,4 +1,18 @@
class NotificationListener < BaseListener
def conversation_bot_handoff(event)
conversation, account = extract_conversation_and_account(event)
return if conversation.pending?
conversation.inbox.members.each do |agent|
NotificationBuilder.new(
notification_type: 'conversation_creation',
user: agent,
account: account,
primary_actor: conversation
).perform
end
end
def conversation_created(event)
conversation, account = extract_conversation_and_account(event)
return if conversation.pending?
+6
View File
@@ -2,6 +2,7 @@ module MailboxHelper
private
def create_message
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
@message = @conversation.messages.create!(
@@ -36,6 +37,7 @@ module MailboxHelper
end
def process_regular_attachments(attachments)
Rails.logger.info "[MailboxHelper] Processing regular attachments for message with ID: #{processed_mail.message_id}"
attachments.each do |mail_attachment|
attachment = @message.attachments.new(
account_id: @conversation.account_id,
@@ -46,6 +48,8 @@ module MailboxHelper
end
def process_inline_attachments(attachments)
Rails.logger.info "[MailboxHelper] Processing inline attachments for message with ID: #{processed_mail.message_id}"
# create an instance variable here, the `embed_inline_image_source`
# updates them directly. And then the value is eventaully used to update the message content
@html_content = processed_mail.serialized_data[:html_content][:full]
@@ -98,7 +102,9 @@ module MailboxHelper
}
}
).perform
@contact = @contact_inbox.contact
Rails.logger.info "[MailboxHelper] Contact created with ID: #{@contact.id} for inbox with ID: #{@inbox.id}"
end
def notification_email_from_chatwoot?
+4 -3
View File
@@ -100,7 +100,7 @@ class Account < ApplicationRecord
.where(context: 'labels',
taggable_type: 'Conversation',
taggable_id: conversation_ids)
.map { |_| _.tag.name }
.map { |tagging| tagging.tag.name }
end
def webhook_data
@@ -111,11 +111,12 @@ class Account < ApplicationRecord
end
def inbound_email_domain
domain || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', false)
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
false)
end
def support_email
super || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
end
def usage_limits
+1 -1
View File
@@ -43,7 +43,7 @@ class Article < ApplicationRecord
belongs_to :account
belongs_to :category, optional: true
belongs_to :portal
belongs_to :author, class_name: 'User'
belongs_to :author, class_name: 'User', inverse_of: :articles
before_validation :ensure_account_id
before_validation :ensure_article_slug
+2 -1
View File
@@ -38,7 +38,8 @@ class Attachment < ApplicationRecord
has_one_attached :file
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: [:image, :audio, :video, :file, :location, :fallback, :share, :story_mention, :contact]
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8 }
def push_event_data
return unless file_type
+4 -4
View File
@@ -11,10 +11,10 @@
#
class CannedResponse < ApplicationRecord
validates_presence_of :content
validates_presence_of :short_code
validates_presence_of :account
validates_uniqueness_of :short_code, scope: :account_id
validates :content, presence: true
validates :short_code, presence: true
validates :account, presence: true
validates :short_code, uniqueness: { scope: :account_id }
belongs_to :account
+4 -3
View File
@@ -46,20 +46,20 @@ class Channel::FacebookPage < ApplicationRecord
def subscribe
# ref https://developers.facebook.com/docs/messenger-platform/reference/webhook-events
response = Facebook::Messenger::Subscriptions.subscribe(
Facebook::Messenger::Subscriptions.subscribe(
access_token: page_access_token,
subscribed_fields: %w[
messages message_deliveries message_echoes message_reads standby messaging_handovers
]
)
rescue => e
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
def unsubscribe
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
rescue => e
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
@@ -73,6 +73,7 @@ class Channel::FacebookPage < ApplicationRecord
delete_instagram_story(message) if story_link.blank?
story_link
rescue Koala::Facebook::ClientError => e
Rails.logger.debug { "Instagram Story Expired: #{e.inspect}" }
delete_instagram_story(message)
end
+1 -1
View File
@@ -89,7 +89,7 @@ class Conversation < ApplicationRecord
belongs_to :account
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
belongs_to :contact
belongs_to :contact_inbox
belongs_to :team, optional: true
+1 -1
View File
@@ -26,7 +26,7 @@ class CsatSurveyResponse < ApplicationRecord
belongs_to :conversation
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
+1 -1
View File
@@ -21,7 +21,7 @@ class Macro < ApplicationRecord
belongs_to :account
belongs_to :created_by,
class_name: :User, optional: true
class_name: :User, optional: true, inverse_of: :macros
belongs_to :updated_by,
class_name: :User, optional: true
has_many_attached :files
+6 -6
View File
@@ -118,7 +118,7 @@ class Message < ApplicationRecord
belongs_to :account
belongs_to :inbox
belongs_to :conversation, touch: true
belongs_to :sender, polymorphic: true, required: false
belongs_to :sender, polymorphic: true, optional: true
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
has_one :csat_survey_response, dependent: :destroy_async
@@ -139,8 +139,8 @@ class Message < ApplicationRecord
conversation_id: conversation.display_id,
conversation: conversation_push_event_data
)
data.merge!(echo_id: echo_id) if echo_id.present?
data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present?
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
merge_sender_attributes(data)
end
@@ -163,8 +163,8 @@ class Message < ApplicationRecord
end
def merge_sender_attributes(data)
data.merge!(sender: sender.push_event_data) if sender && !sender.is_a?(AgentBot)
data.merge!(sender: sender.push_event_data(inbox)) if sender.is_a?(AgentBot)
data[:sender] = sender.push_event_data if sender && !sender.is_a?(AgentBot)
data[:sender] = sender.push_event_data(inbox) if sender.is_a?(AgentBot)
data
end
@@ -184,7 +184,7 @@ class Message < ApplicationRecord
sender: sender.try(:webhook_data),
source_id: source_id
}
data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data
end
+9 -1
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# last_activity_at :datetime
# meta :jsonb
# notification_type :integer not null
# primary_actor_type :string not null
# read_at :datetime
@@ -46,6 +47,7 @@ class Notification < ApplicationRecord
before_create :set_last_activity_at
after_create_commit :process_notification_delivery, :dispatch_create_event
after_destroy_commit :dispatch_destroy_event
after_update_commit :dispatch_update_event
PRIMARY_ACTORS = ['Conversation'].freeze
@@ -61,6 +63,7 @@ class Notification < ApplicationRecord
user: user&.push_event_data,
created_at: created_at.to_i,
last_activity_at: last_activity_at.to_i,
snoozed_until: snoozed_until,
account_id: account_id
}
@@ -74,7 +77,8 @@ class Notification < ApplicationRecord
def primary_actor_data
{
id: primary_actor.push_event_data[:id],
meta: primary_actor.push_event_data[:meta]
meta: primary_actor.push_event_data[:meta],
inbox_id: primary_actor.push_event_data[:inbox_id]
}
end
@@ -141,6 +145,10 @@ class Notification < ApplicationRecord
Rails.configuration.dispatcher.dispatch(NOTIFICATION_CREATED, Time.zone.now, notification: self)
end
def dispatch_update_event
Rails.configuration.dispatcher.dispatch(NOTIFICATION_UPDATED, Time.zone.now, notification: self)
end
def dispatch_destroy_event
Rails.configuration.dispatcher.dispatch(NOTIFICATION_DELETED, Time.zone.now, notification: self)
end
+1 -1
View File
@@ -13,5 +13,5 @@
class TelegramBot < ApplicationRecord
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy_async
validates_uniqueness_of :auth_key, scope: :account_id
validates :auth_key, uniqueness: { scope: :account_id }
end
+9 -5
View File
@@ -74,15 +74,15 @@ class User < ApplicationRecord
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation
has_many :inbox_members, dependent: :destroy_async
has_many :inboxes, through: :inbox_members, source: :inbox
has_many :messages, as: :sender
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
has_many :custom_filters, dependent: :destroy_async
@@ -94,12 +94,16 @@ class User < ApplicationRecord
has_many :notifications, dependent: :destroy_async
has_many :team_members, dependent: :destroy_async
has_many :teams, through: :team_members
has_many :articles, foreign_key: 'author_id', dependent: :nullify
has_many :articles, foreign_key: 'author_id', dependent: :nullify, inverse_of: :author
has_many :portal_members, class_name: :PortalMember, dependent: :destroy_async
has_many :portals, through: :portal_members, source: :portal,
class_name: :Portal,
dependent: :nullify
has_many :macros, foreign_key: 'created_by_id'
# rubocop:disable Rails/HasManyOrHasOneDependent
# we are handling this in `remove_macros` callback
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
# rubocop:enable Rails/HasManyOrHasOneDependent
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
+5
View File
@@ -0,0 +1,5 @@
class ReportPolicy < ApplicationPolicy
def view?
@account_user.administrator?
end
end
+4
View File
@@ -14,4 +14,8 @@ class UserPolicy < ApplicationPolicy
def destroy?
@account_user.administrator?
end
def bulk_create?
@account_user.administrator?
end
end

Some files were not shown because too many files have changed in this diff Show More