Compare commits

...
Author SHA1 Message Date
Muhsin Keloth 70fd1e27dc chore: add @chatwoot/ninja-keys 2024-02-21 11:57:22 +05:30
Sivin VargheseandGitHub a2f9cd6e38 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-21 08:28:31 +05:30
Sivin VargheseandGitHub 758c4bd361 Merge branch 'develop' into feat/CW-3059 2024-02-21 08:28:23 +05:30
Vishnu NarayananandGitHub d53097f77d fix: Raise error if email to_header is invalid (#8688) 2024-02-20 17:03:39 -08:00
9911c5dc12 chore: Hide banners on onboarding view (#8934)
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-02-20 16:12:51 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
19aef3e94b chore(deps): bump ip from 1.1.5 to 1.1.9 (#8976)
Bumps [ip](https://github.com/indutny/node-ip) from 1.1.5 to 1.1.9.
- [Commits](https://github.com/indutny/node-ip/compare/v1.1.5...v1.1.9)

---
updated-dependencies:
- dependency-name: ip
  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-20 16:11:02 -08:00
23230e0143 feat: sla-7 ensure applied_sla uniqueness (#8938)
* feat: refactor fetching sla in action service

* chore: modify spec

* chore: ensure applied_sla uniqueness

* chore: review fixes

* feat: add unique index on applied_sla

* chore: fix spec

* chore: add new specs to improve coverage

* chore: improve spec

* Update spec/enterprise/services/enterprise/action_service_spec.rb

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-20 21:59:49 +05:30
Vishnu NarayananandGitHub f92cea144c feat(perf): sla-9 improve perf of TriggerSlasForAccountsJob (#8953)
* feat: improve perf of TriggerSlasForAccountsJob
2024-02-20 21:54:37 +05:30
Sivin VargheseandGitHub 4a48569ec4 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-20 21:52:13 +05:30
Sivin VargheseandGitHub 20c609a615 Merge branch 'develop' into feat/CW-3059 2024-02-20 21:50:58 +05:30
Muhsin KelothandGitHub e6cf8c39b7 feat: Update last_snoozed_at after the un-snooze notification (#8943) 2024-02-20 18:55:39 +05:30
Muhsin KelothandGitHub c5c0845151 feat: Add labels, status and priority in notification push_event_data (#8972) 2024-02-20 17:17:25 +05:30
5d9fb55370 feat: Export contact improvements (#8895)
This pull request enhances the export contacts feature by adding a confirmation step before exporting. Previously, clicking the export button would trigger the export action without confirmation.

Additionally, it ensures that only the intended recipient receives the export email, addressing the previous behaviour where all administrators received it.

Fixes: #8504

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-02-20 17:11:03 +05:30
721a2f5052 feat: API changes to support multi step user signup (#8933)
-API Changes to support the new onboarding flow 

Co-authored-by: Sojan <sojan@pepalo.com>
2024-02-20 16:48:51 +05:30
7320957405 chore: Add facebook_api_version to global config (#8965)
- Ability to configure facebook_api_version from global config

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-02-20 15:53:29 +05:30
Sivin VargheseandGitHub 010998d541 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-20 09:22:42 +05:30
Sivin VargheseandGitHub 4b88ae906d Merge branch 'develop' into feat/CW-3059 2024-02-19 18:29:51 +05:30
Sojan e4d072c79c Merge branch 'release/3.6.0' into develop 2024-02-19 15:59:26 +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
Sivin VargheseandGitHub c1ce9fe215 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-19 11:25:49 +05:30
iamsivin 94b86a1244 chore: Review fix 2024-02-19 11:25:14 +05:30
Sivin VargheseandGitHub ac947eedf5 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-18 22:51:08 +05:30
Muhsin KelothandGitHub 6fb3e96bc9 Merge branch 'develop' into feat/CW-3059 2024-02-17 13:59:43 +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
Sivin VargheseandGitHub 72f64836a5 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-16 23:47:53 +05:30
Sivin VargheseandGitHub 33d93310d2 Merge branch 'develop' into feat/CW-3059 2024-02-16 23:47:39 +05:30
Muhsin KelothandGitHub fe744abe26 fix: Mark notification as unread after the un-snooze (#8917) 2024-02-16 17:30:54 +05:30
Muhsin KelothandGitHub 9ec4ec94ed Merge branch 'develop' into feat/CW-3059 2024-02-16 17:12:21 +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 04e829e639 Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-16 10:55:12 +05:30
Sivin VargheseandGitHub 69182f748b Merge branch 'develop' into feat/CW-3059 2024-02-16 10:54:46 +05:30
Sivin VargheseandGitHub 597a4164e8 fix: Editor hyperlink popup is hidden under modal (#8950) 2024-02-16 08:19:06 +05:30
Sivin VargheseandGitHub b6244c870c Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-15 06:00:22 +05:30
Sivin VargheseandGitHub 9d063b730b Merge branch 'develop' into feat/CW-3059 2024-02-15 05:59:55 +05:30
Nithin David ThomasandGitHub 678c00f254 fix: Removes default slot for right of label (#8936) 2024-02-14 10:48:54 -08:00
Sivin VargheseandGitHub 4146da5a6d Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-14 16:44:20 +05:30
Sivin VargheseandGitHub b53730bb73 Merge branch 'develop' into feat/CW-3059 2024-02-14 16:44:08 +05:30
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
Sivin VargheseandGitHub 75b57d6a0a Merge branch 'feat/CW-3059' into feat/CW-3067-snooze 2024-02-14 09:34:24 +05:30
iamsivin e3cd3283a0 chore: Minor fix 2024-02-14 09:31:32 +05:30
iamsivin 554395b6c9 feat: Snooze inbox item from context menu 2024-02-13 22:33:59 +05:30
iamsivin 8b2f656372 chore: Clean up 2024-02-13 22:32:43 +05:30
Sivin VargheseandGitHub b4686ec07a Merge branch 'develop' into feat/CW-3059 2024-02-13 19:07:48 +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
Sivin VargheseandGitHub 4d995b16f0 Merge branch 'develop' into feat/CW-3059 2024-02-13 16:54:01 +05:30
iamsivin 8de4911fb3 chore: Route review fix 2024-02-13 16:53:41 +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
iamsivin a870d1c8a2 chore: Minor UI fixes 2024-02-13 14:15:18 +05:30
iamsivin ab0ab9b032 chore: Fix small screens 2024-02-13 13:49:58 +05:30
iamsivin ac4ce529bf chore: Minor fix 2024-02-13 13:06:06 +05:30
iamsivin f19474c681 chore: Minor fix 2024-02-13 13:05:23 +05:30
Sivin VargheseandGitHub 7359f10be2 Delete app/javascript/shared/mixins/specs/resizeListenerMixin.spec.js 2024-02-13 13:04:36 +05:30
Sivin VargheseandGitHub 778dbaf812 Delete app/javascript/shared/mixins/resizeListenerMixin.js 2024-02-13 13:04:21 +05:30
Sivin VargheseandGitHub faa7a5f585 Merge branch 'develop' into feat/CW-3059 2024-02-13 10:25:59 +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
Sivin VargheseandGitHub eaddb253a5 Merge branch 'develop' into feat/CW-3059 2024-02-13 08:55:12 +05:30
iamsivin 8d200b8a99 chore: Review fix 2024-02-13 08:54:56 +05:30
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
Sivin VargheseandGitHub 34aef51064 Merge branch 'develop' into feat/CW-3059 2024-02-13 06:34:46 +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
iamsivin 139b0d8a4c chore: Remove loader 2024-02-12 22:42:27 +05:30
Sivin VargheseandGitHub 440e357c84 Merge branch 'develop' into feat/CW-3059 2024-02-12 20:56:33 +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
Sivin VargheseandGitHub 58959d42f5 Merge branch 'develop' into feat/CW-3059 2024-02-12 18:03:22 +05:30
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
iamsivin 01264d45c2 chore: Clean up 2024-02-12 13:18:53 +05:30
Sivin VargheseandGitHub 8548f6e00b Merge branch 'develop' into feat/CW-3059 2024-02-12 13:07:13 +05:30
iamsivin 0763cc477f chore: Add snooze 2024-02-12 13:05:47 +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
Sivin VargheseandGitHub 2fc5c6f236 Merge branch 'develop' into feat/CW-3059 2024-02-12 08:29:40 +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
iamsivin 5935a4ff6c chore: Fix codeclimate 2024-02-09 16:18:25 +05:30
iamsivin 43de19f685 chore: Inbox view enhancements 2024-02-09 15:40:12 +05:30
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
142 changed files with 2560 additions and 1147 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:
+9 -9
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)
@@ -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
@@ -43,8 +43,17 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
inviter: current_user,
account: Current.account
)
builder.perform
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok
end
@@ -46,7 +46,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
head :ok, message: I18n.t('errors.contacts.export.success')
end
@@ -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
@@ -54,7 +54,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def snooze
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil)
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
render json: @notification
end
@@ -44,7 +44,10 @@ class Api::V1::AccountsController < Api::BaseController
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.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
def update_active_at
@@ -83,6 +86,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,13 @@ 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
update_account_info if @account.present?
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
@@ -29,6 +34,20 @@ class Api::V2::AccountsController < Api::BaseController
private
def account_attributes
{
custom_attributes: @account.custom_attributes.merge({ 'onboarding_step' => 'profile_update' })
}
end
def update_account_info
@account.update!(
account_attributes
)
end
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 +65,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
+1 -1
View File
@@ -55,7 +55,7 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0',
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -34,7 +34,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
def allowed_configs
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
@@ -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
+14 -10
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,27 +26,31 @@ class NotificationFinder
def set_up
find_all_notifications
filter_by_read_status
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 filter_by_read_status
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
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: params[:sort_order] || :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
+6 -2
View File
@@ -8,8 +8,8 @@
>
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<pending-email-verification-banner />
<payment-pending-banner />
<pending-email-verification-banner v-if="hideOnOnboardingView" />
<payment-pending-banner v-if="hideOnOnboardingView" />
<upgrade-banner />
</template>
<transition name="fade" mode="out-in">
@@ -38,6 +38,7 @@ import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -79,6 +80,9 @@ export default {
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
},
},
watch: {
+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();
@@ -7,12 +7,13 @@ class NotificationsAPI extends ApiClient {
}
get({ page, status, type, sortOrder }) {
const includesFilter = [status, type].filter(value => !!value);
return axios.get(this.url, {
params: {
page,
status,
type,
sort_order: sortOrder,
includes: includesFilter,
},
});
}
@@ -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,20 +27,36 @@ describe('#NotificationAPI', () => {
window.axios = originalAxios;
});
it('#get', () => {
notificationsAPI.get({
page: 1,
status: 'read',
type: 'Conversation',
sortOrder: 'desc',
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
params: {
describe('#get', () => {
it('generates the API call if both params are available', () => {
notificationsAPI.get({
page: 1,
status: 'read',
type: 'Conversation',
sort_order: 'desc',
},
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'],
},
});
});
});
@@ -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;
@@ -8,7 +8,7 @@
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
@@ -35,7 +35,10 @@
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<empty-state v-else :is-on-expanded-layout="isOnExpandedLayout" />
<empty-state
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div
v-show="showContactPanel"
class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0"
@@ -3,11 +3,10 @@
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
>
<div
class="flex-1 w-full min-w-0 flex flex-col md:flex-row items-center justify-center"
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center gap-4"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
>
<div
class="flex justify-start items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0 w-[inherit]"
>
<div class="flex justify-start items-center min-w-0 w-fit max-w-full">
<back-button
v-if="showBackButton"
:back-url="backButtonUrl"
@@ -20,9 +19,11 @@
:status="currentContact.availability_status"
/>
<div
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-[inherit] overflow-hidden"
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden"
>
<div class="flex items-center flex-row gap-1 m-0 p-0 w-[inherit]">
<div
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full"
>
<woot-button
variant="link"
color-scheme="secondary"
@@ -66,7 +67,7 @@
</div>
</div>
<div
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0 rtl:relative rtl:left-6"
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<more-actions :conversation-id="currentChat.id" />
@@ -87,7 +88,6 @@ import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -128,9 +128,6 @@ export default {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
if (this.isInboxView) {
return frontendURL(`accounts/${accountId}/inbox`);
}
return conversationListPageURL({
accountId,
inboxId,
@@ -79,7 +79,13 @@
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
"ERROR_MESSAGE": "There was an error, please try again"
"ERROR_MESSAGE": "There was an error, please try again",
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
},
"DELETE_NOTE": {
"CONFIRM": {
@@ -4,7 +4,6 @@
"TITLE": "Inbox",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group.",
"NO_NOTIFICATIONS": "No notifications",
"NOTE": "Notifications from all subscribed inboxes",
@@ -14,7 +13,8 @@
},
"ACTION_HEADER": {
"SNOOZE": "Snooze notification",
"DELETE": "Delete notification"
"DELETE": "Delete notification",
"BACK": "Back"
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
@@ -18,3 +18,4 @@ export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
// Inbox Commands (Notifications)
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
export const CMD_TOGGLE_SNOOZE_NOTIFICATION = 'CMD_TOGGLE_SNOOZE_NOTIFICATION';
@@ -6,11 +6,12 @@
hideBreadcrumbs
:placeholder="placeholder"
@selected="onSelected"
@closed="onClosed"
/>
</template>
<script>
import 'ninja-keys';
import '@chatwoot/ninja-keys';
import conversationHotKeysMixin from './conversationHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
@@ -32,6 +33,11 @@ export default {
appearanceHotKeys,
goToCommandHotKeys,
],
data() {
return {
modalObserver: null,
};
},
computed: {
placeholder() {
return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER');
@@ -55,6 +61,10 @@ export default {
routeName() {
this.setCommandbarData();
},
hotKeys() {
// This is to update the command bar data when the hot keys are updated dynamically
this.setCommandbarData();
},
},
mounted() {
this.setCommandbarData();
@@ -72,6 +82,9 @@ export default {
});
this.setCommandbarData();
},
onClosed() {
this.showSnoozeNotificationItems = false;
},
},
};
</script>
@@ -1,6 +1,9 @@
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import {
CMD_SNOOZE_NOTIFICATION,
CMD_TOGGLE_SNOOZE_NOTIFICATION,
} from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
@@ -61,14 +64,28 @@ const INBOX_SNOOZE_EVENTS = [
},
];
export default {
data() {
return {
showSnoozeNotificationItems: false,
};
},
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
if (
isAInboxViewRoute(this.$route.name) ||
this.showSnoozeNotificationItems
) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
mounted() {
bus.$on(CMD_TOGGLE_SNOOZE_NOTIFICATION, this.toggleSnoozeOptions);
},
destroyed() {
bus.$off(CMD_TOGGLE_SNOOZE_NOTIFICATION, this.toggleSnoozeOptions);
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
@@ -77,5 +94,9 @@ export default {
section: this.$t(action.section),
}));
},
toggleSnoozeOptions() {
// Used to show/hide snooze notification items in cmd bar dynamically
this.showSnoozeNotificationItems = true;
},
},
};
@@ -113,6 +113,13 @@
</woot-button>
</div>
</div>
<woot-confirm-modal
ref="confirmExportContactsDialog"
:title="$t('EXPORT_CONTACTS.CONFIRM.TITLE')"
:description="$t('EXPORT_CONTACTS.CONFIRM.MESSAGE')"
:confirm-label="$t('EXPORT_CONTACTS.CONFIRM.YES')"
:cancel-label="$t('EXPORT_CONTACTS.CONFIRM.NO')"
/>
</header>
</template>
@@ -175,8 +182,13 @@ export default {
toggleImport() {
this.$emit('on-toggle-import');
},
submitExport() {
this.$emit('on-export-submit');
async submitExport() {
const ok =
await this.$refs.confirmExportContactsDialog.showConfirmation();
if (ok) {
this.$emit('on-export-submit');
}
},
submitSearch() {
this.$emit('on-search-submit');
@@ -1,28 +1,9 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
const InboxView = () => import('../inbox/InboxView.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/inbox-view'),
name: 'inbox_view',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {
return { inboxId: 0 };
},
},
{
path: frontendURL('accounts/:accountId/inbox-view/:conversation_id'),
name: 'inbox_view_conversation',
roles: ['administrator', 'agent'],
component: InboxView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
},
},
{
path: frontendURL('accounts/:accountId/dashboard'),
name: 'home',
@@ -3,6 +3,7 @@ import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
@@ -16,6 +17,7 @@ export default {
path: frontendURL('accounts/:account_id'),
component: AppContainer,
children: [
...inboxRoutes,
...conversation.routes,
...settings.routes,
...contactRoutes,
@@ -0,0 +1,153 @@
<template>
<div class="flex flex-col h-full w-full md:w-[calc(100%-360px)]">
<inbox-item-header
:total-length="totalNotifications"
:current-index="activeNotificationPosition"
:active-notification="activeNotification"
@next="onClickNext"
@prev="onClickPrev"
/>
<conversation-box
class="h-[calc(100%-56px)]"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="false"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
InboxItemHeader,
ConversationBox,
},
mixins: [uiSettingsMixin],
computed: {
...mapGetters({
notifications: 'notifications/getNotifications',
activeNotificationById: 'notifications/getActiveNotificationById',
currentChat: 'getSelectedChat',
conversationById: 'getConversationById',
uiFlags: 'notifications/getUIFlags',
}),
inboxId() {
return Number(this.$route.params.inboxId);
},
notificationId() {
return Number(this.$route.params.notification_id);
},
activeNotification() {
return this.activeNotificationById(this.notificationId);
},
conversationId() {
return this.activeNotification?.primary_actor?.id;
},
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationPosition() {
const notificationIndex = this.notifications.findIndex(
n => n.id === this.notificationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler(newVal, oldVal) {
if (newVal !== oldVal) {
this.fetchConversationById();
}
},
},
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
if (!this.notificationId || !this.conversationId) return;
this.$store.dispatch('clearSelectedState');
const existingChat = this.findConversation();
if (existingChat) {
this.setActiveChat(existingChat);
return;
}
await this.$store.dispatch('getConversation', this.conversationId);
this.setActiveChat();
},
setActiveChat(conversation = null) {
const selectedConversation = conversation || this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
return this.conversationById(this.conversationId);
},
navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2;
const targetNotification = this.notifications[activeIndex + indexOffset];
if (targetNotification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { inbox_id: inboxId, meta: { unreadCount } = {} },
notification_type: notificationType,
} = targetNotification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, notification_id: id },
});
}
},
onClickNext() {
this.navigateToConversation(this.activeNotificationPosition, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationPosition, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
@@ -0,0 +1,28 @@
<template>
<div
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full hidden md:flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
uiFlags: 'notifications/getUIFlags',
}),
},
};
</script>
@@ -1,9 +1,13 @@
<template>
<div
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]'"
class="flex-col h-full w-full md:min-w-[360px] md:max-w-[360px] ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="!notificationId ? 'flex' : 'hidden md:flex'"
>
<inbox-list-header @filter="onFilterChange" />
<inbox-list-header
:is-context-menu-open="isInboxContextMenuOpen"
@filter="onFilterChange"
@redirect="redirectToInbox"
/>
<div
ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
@@ -15,6 +19,9 @@
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification"
@snooze-notification="openSnoozeNotificationModal"
@context-menu-open="isInboxContextMenuOpen = true"
@context-menu-close="isInboxContextMenuOpen = false"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
@@ -25,18 +32,21 @@
>
{{ $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>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
</div>
</template>
@@ -46,26 +56,25 @@ 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 {
CMD_TOGGLE_SNOOZE_NOTIFICATION,
CMD_SNOOZE_NOTIFICATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import alertMixin from 'shared/mixins/alertMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
CustomSnoozeModal,
},
mixins: [alertMixin, uiSettingsMixin],
props: {
conversationId: {
type: [String, Number],
default: 0,
},
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
data() {
return {
infiniteLoaderOptions: {
@@ -76,6 +85,9 @@ export default {
status: '',
type: '',
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
isInboxContextMenuOpen: false,
showCustomSnoozeModal: false,
notificationIdToSnooze: null,
};
},
computed: {
@@ -85,6 +97,9 @@ export default {
uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications',
}),
notificationId() {
return Number(this.$route.params.notification_id);
},
inboxFilters() {
return {
page: this.page,
@@ -102,13 +117,14 @@ export default {
showEmptyState() {
return !this.uiFlags.isFetching && !this.notifications.length;
},
showEndOfListMessage() {
return this.showEndOfList && this.notifications.length;
},
},
mounted() {
this.setSavedFilter();
this.fetchNotifications();
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
fetchNotifications() {
@@ -119,17 +135,15 @@ export default {
this.$store.dispatch('notifications/index', filter);
},
redirectToInbox() {
if (!this.conversationId) return;
if (!this.notificationId) return;
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', {
...this.inboxFilters,
page: this.page + 1,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
});
this.page += 1;
},
@@ -195,6 +209,47 @@ export default {
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
},
openSnoozeNotificationModal(notificationItem) {
this.notificationIdToSnooze = notificationItem.id;
// There is an bus event to toggle snooze modal, because
// We need to show the snooze notification items in the command bar Dynamically
// If cmd + k is pressed from inbox view (In list screen) and search snooze notification items then we no need to show,
// snooze notification because we don't get notification id.
// Only show when the snooze button is clicked from the notification item card context menu
if (!this.notificationId) bus.$emit(CMD_TOGGLE_SNOOZE_NOTIFICATION);
this.$nextTick(() => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
});
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
snoozeNotification(snoozedUntil) {
this.$store
.dispatch('notifications/snooze', {
id: this.notificationId || this.notificationIdToSnooze,
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);
}
},
},
};
</script>
@@ -1,144 +1,25 @@
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<inbox-list
v-show="showConversationList"
:conversation-id="conversationId"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<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"
/>
<conversation-box
class="h-[calc(100%-56px)]"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
<div
v-if="!showInboxMessageView && !isOnExpandedLayout"
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-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
</div>
<router-view name="default" />
<router-view name="detailView" />
</section>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
InboxList,
InboxItemHeader,
ConversationBox,
},
mixins: [uiSettingsMixin],
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications',
currentChat: 'getSelectedChat',
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,
FEATURE_FLAGS.INBOX_VIEW
);
},
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
isFetchingInitialData() {
return this.uiFlags.isFetching && !this.notifications.length;
},
showInboxMessageView() {
return (
Boolean(this.conversationId) &&
Boolean(this.currentChat.id) &&
!this.isFetchingInitialData
);
},
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationIndex() {
const conversationId = Number(this.conversationId);
const notificationIndex = this.notifications.findIndex(
n => n.primary_actor.id === conversationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.fetchConversationById();
},
},
},
mounted() {
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
@@ -148,70 +29,6 @@ export default {
name: 'home',
});
}
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
if (!this.conversationId) return;
const chat = this.findConversation();
if (!chat) {
await this.$store.dispatch('getConversation', this.conversationId);
}
this.setActiveChat();
},
setActiveChat() {
const selectedConversation = this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
const conversationId = Number(this.conversationId);
return this.allConversation.find(c => c.id === conversationId);
},
navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2;
const targetNotification = this.notifications[activeIndex + indexOffset];
if (targetNotification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, meta: { unreadCount } = {} },
notification_type: notificationType,
} = targetNotification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { conversation_id: conversationId },
});
}
},
onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationIndex, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
@@ -89,6 +89,7 @@ export default {
return {
isContextMenuOpen: false,
contextMenuPosition: { x: null, y: null },
activeNotificationId: null,
};
},
computed: {
@@ -96,7 +97,9 @@ export default {
return this.notificationItem?.primary_actor;
},
isInboxCardActive() {
return this.$route.params.conversation_id === this.primaryActor?.id;
return (
Number(this.activeNotificationId) === Number(this.notificationItem?.id)
);
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
@@ -109,6 +112,9 @@ export default {
meta() {
return this.primaryActor?.meta;
},
isNotSnoozed() {
return !this.notificationItem?.snoozed_until;
},
assigneeMeta() {
return this.meta?.assignee;
},
@@ -131,6 +137,12 @@ export default {
},
];
if (this.isNotSnoozed) {
items.push({
key: 'snooze',
label: this.$t('INBOX.MENU_ITEM.SNOOZE'),
});
}
if (!this.isUnread) {
items.push({
key: 'mark_as_unread',
@@ -157,6 +169,14 @@ export default {
return '';
},
},
watch: {
'$route.params.notification_id': {
immediate: true,
handler(newVal) {
this.activeNotificationId = newVal;
},
},
},
unmounted() {
this.closeContextMenu();
},
@@ -166,11 +186,11 @@ export default {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, inbox_id: inboxId },
primary_actor: { inbox_id: inboxId },
notification_type: notificationType,
} = notification;
if (this.$route.params.conversation_id !== conversationId) {
if (this.$route.params.notification_id !== id) {
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
@@ -184,13 +204,14 @@ export default {
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, conversation_id: conversationId },
params: { inboxId, notification_id: id },
});
}
},
closeContextMenu() {
this.isContextMenuOpen = false;
this.contextMenuPosition = { x: null, y: null };
this.$emit('context-menu-close');
},
openContextMenu(e) {
this.closeContextMenu();
@@ -200,6 +221,7 @@ export default {
y: e.pageY || e.clientY,
};
this.isContextMenuOpen = true;
this.$emit('context-menu-open');
},
handleAction(key) {
switch (key) {
@@ -212,6 +234,9 @@ export default {
case 'delete':
this.$emit('delete-notification', this.notificationItem);
break;
case 'snooze':
this.$emit('snooze-notification', this.notificationItem);
break;
default:
}
},
@@ -2,6 +2,16 @@
<div
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"
>
<woot-button
variant="clear link"
class="flex md:hidden !pt-1 !pb-1 rounded-md ltr:pr-1 rtl:pl-1 !no-underline"
size="medium"
color-scheme="primary"
icon="chevron-left"
@click="onClickGoToInboxList"
>
{{ $t('INBOX.ACTION_HEADER.BACK') }}
</woot-button>
<pagination-button
v-if="totalLength > 1"
:total-length="totalLength"
@@ -12,10 +22,12 @@
<div v-else />
<div class="flex items-center gap-2">
<woot-button
v-if="!isNotificationAlreadySnoozed"
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
class="[&>span]:hidden md:[&>span]:inline-flex"
@click="openSnoozeNotificationModal"
>
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
@@ -25,37 +37,23 @@
size="small"
color-scheme="secondary"
variant="hollow"
class="[&>span]:hidden md:[&>span]:inline-flex"
@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: {
@@ -72,55 +70,19 @@ export default {
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);
isNotificationAlreadySnoozed() {
return this.activeNotification?.snoozed_until;
},
},
methods: {
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
@@ -140,6 +102,9 @@ export default {
onClickPrev() {
this.$emit('prev');
},
onClickGoToInboxList() {
this.$router.push({ name: 'inbox_view' });
},
},
};
</script>
@@ -49,7 +49,7 @@
<inbox-option-menu
v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9"
class="absolute top-9 ltr:right-0 ltr:md:right-[unset] rtl:left-0 rtl:md:left-[unset]"
@option-click="onInboxOptionMenuClick"
/>
</div>
@@ -69,12 +69,29 @@ export default {
InboxDisplayMenu,
},
mixins: [clickaway, alertMixin],
props: {
isContextMenuOpen: {
type: Boolean,
default: false,
},
},
data() {
return {
showInboxDisplayMenu: false,
showInboxOptionMenu: false,
};
},
watch: {
isContextMenuOpen: {
handler(val) {
if (val) {
this.showInboxDisplayMenu = false;
this.showInboxOptionMenu = false;
}
},
immediate: true,
},
},
methods: {
markAllRead() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
@@ -100,21 +117,19 @@ export default {
},
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();
}
const actions = {
mark_all_read: () => this.markAllRead(),
delete_all: () => this.deleteAll(),
delete_all_read: () => this.deleteAllRead(),
};
const action = actions[key];
if (action) action();
this.$emit('redirect');
},
onFilterChange(option) {
this.$emit('filter', option);
this.showInboxDisplayMenu = false;
if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' });
this.$emit('redirect');
},
},
};
@@ -19,7 +19,7 @@
/>
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-gray-600">
<span class="text-sm font-medium text-gray-600 tabular-nums">
{{ totalLength <= 1 ? '1' : currentIndex }}
</span>
<span
@@ -28,7 +28,7 @@
>
/
</span>
<span v-if="totalLength > 1" class="text-sm text-slate-400">
<span v-if="totalLength > 1" class="text-sm text-slate-400 tabular-nums">
{{ totalLength }}
</span>
</div>
@@ -0,0 +1,32 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
const InboxView = () => import('./InboxView.vue');
const InboxListView = () => import('./InboxList.vue');
const InboxDetailView = () => import('./InboxDetail.vue');
const InboxEmptyStateView = () => import('./InboxEmptyState.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/inbox-view'),
component: InboxView,
children: [
{
path: '',
name: 'inbox_view',
components: {
default: InboxListView,
detailView: InboxEmptyStateView,
},
roles: ['administrator', 'agent'],
},
{
path: ':notification_id',
name: 'inbox_view_conversation',
components: {
default: InboxListView,
detailView: InboxDetailView,
},
roles: ['administrator', 'agent'],
},
],
},
];
@@ -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
);
});
});
@@ -1,4 +1,4 @@
import { applyInboxPageFilters, sortComparator } from './helpers';
import { sortComparator } from './helpers';
export const getters = {
getNotifications($state) {
@@ -6,14 +6,14 @@ export const getters = {
},
getFilteredNotifications: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const filteredNotifications = Object.values($state.records).filter(
notification => applyInboxPageFilters(notification, filters)
);
const sortedNotifications = filteredNotifications.sort((a, b) =>
sortComparator(a, b, sortOrder)
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
);
return sortedNotifications;
},
getActiveNotificationById: $state => id => {
return $state.records[id] || {};
},
getUIFlags($state) {
return $state.uiFlags;
},
@@ -1,31 +1,3 @@
export const filterByStatus = (snoozedUntil, filterStatus) =>
filterStatus === 'snoozed' ? !!snoozedUntil : !snoozedUntil;
export const filterByType = (readAt, filterType) =>
filterType === 'read' ? !!readAt : !readAt;
export const filterByTypeAndStatus = (
readAt,
snoozedUntil,
filterType,
filterStatus
) => {
const shouldFilterByStatus = filterByStatus(snoozedUntil, filterStatus);
const shouldFilterByType = filterByType(readAt, filterType);
return shouldFilterByStatus && shouldFilterByType;
};
export const applyInboxPageFilters = (notification, filters) => {
const { status, type } = filters;
const { read_at: readAt, snoozed_until: snoozedUntil } = notification;
if (status && type)
return filterByTypeAndStatus(readAt, snoozedUntil, type, status);
if (status && !type) return filterByStatus(snoozedUntil, status);
if (!status && type) return filterByType(readAt, type);
return true;
};
const INBOX_SORT_OPTIONS = {
newest: 'desc',
oldest: 'asc',
@@ -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,
@@ -34,6 +34,8 @@ describe('#getters', () => {
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',
@@ -42,6 +44,16 @@ describe('#getters', () => {
]);
});
it('getActiveNotificationById', () => {
const state = {
records: {
1: { id: 1 },
},
};
expect(getters.getActiveNotificationById(state)(1)).toEqual({ id: 1 });
expect(getters.getActiveNotificationById(state)(2)).toEqual({});
});
it('getUIFlags', () => {
const state = {
uiFlags: {
@@ -1,10 +1,4 @@
import {
filterByStatus,
filterByType,
filterByTypeAndStatus,
applyInboxPageFilters,
sortComparator,
} from '../../notifications/helpers';
import { sortComparator } from '../../notifications/helpers';
const notifications = [
{
@@ -45,126 +39,6 @@ const notifications = [
},
];
describe('#filterByStatus', () => {
it('returns the notifications with snoozed status', () => {
const filters = { status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByStatus(notification.snoozed_until, filters.status)
).toEqual(notification.snoozed_until !== null);
});
});
it('returns true if the notification is snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[3].snoozed_until, filters.status)
).toEqual(true);
});
it('returns false if the notification is not snoozed', () => {
const filters = { status: 'snoozed' };
expect(
filterByStatus(notifications[2].snoozed_until, filters.status)
).toEqual(false);
});
});
describe('#filterByType', () => {
it('returns the notifications with read status', () => {
const filters = { type: 'read' };
notifications.forEach(notification => {
expect(filterByType(notification.read_at, filters.type)).toEqual(
notification.read_at !== null
);
});
});
it('returns true if the notification is read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[0].read_at, filters.type)).toEqual(true);
});
it('returns false if the notification is not read', () => {
const filters = { type: 'read' };
expect(filterByType(notifications[1].read_at, filters.type)).toEqual(false);
});
});
describe('#filterByTypeAndStatus', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
).toEqual(
notification.read_at !== null && notification.snoozed_until !== null
);
});
});
it('returns true if the notification is read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[4].read_at,
notifications[4].snoozed_until,
filters.type,
filters.status
)
).toEqual(true);
});
it('returns false if the notification is not read and snoozed', () => {
const filters = { type: 'read', status: 'snoozed' };
expect(
filterByTypeAndStatus(
notifications[3].read_at,
notifications[3].snoozed_until,
filters.type,
filters.status
)
).toEqual(false);
});
});
describe('#applyInboxPageFilters', () => {
it('returns the notifications with type and status', () => {
const filters = { type: 'read', status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByTypeAndStatus(
notification.read_at,
notification.snoozed_until,
filters.type,
filters.status
)
);
});
});
it('returns the notifications with type only', () => {
const filters = { type: 'read', status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByType(notification.read_at, filters.type)
);
});
});
it('returns the notifications with status only', () => {
const filters = { type: null, status: 'snoozed' };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(
filterByStatus(notification.snoozed_until, filters.status)
);
});
});
it('returns true if there are no filters', () => {
const filters = { type: null, status: null };
notifications.forEach(notification => {
expect(applyInboxPageFilters(notification, filters)).toEqual(true);
});
});
});
describe('#sortComparator', () => {
it('returns the notifications sorted by newest', () => {
const sortOrder = 'newest';
+12 -3
View File
@@ -6,24 +6,29 @@
:has-error="hasError"
:error-message="errorMessage"
>
<template #rightOfLabel>
<slot />
</template>
<input
:id="name"
:name="name"
:type="type"
autocomplete="off"
:tabindex="tabindex"
: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':
'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 rounded-xl shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
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')"
/>
@@ -48,6 +53,10 @@ export default {
type: String,
default: 'text',
},
tabindex: {
type: Number,
default: undefined,
},
required: {
type: Boolean,
default: false,
@@ -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>
@@ -9,6 +9,7 @@
<slot name="label">
{{ label }}
</slot>
<slot name="rightOfLabel" />
</label>
<div class="w-full">
<div class="flex items-center relative w-full">
+10
View File
@@ -48,3 +48,13 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
next();
};
export const isOnOnboardingView = route => {
const { name = '' } = route || {};
if (!name) {
return false;
}
return name.includes('onboarding_');
};
@@ -1,4 +1,4 @@
import { validateRouteAccess } from '../RouteHelper';
import { validateRouteAccess, isOnOnboardingView } from '../RouteHelper';
import { clearBrowserSessionCookies } from 'dashboard/store/utils/api';
import { replaceRouteWithReload } from '../CommonHelper';
import Cookies from 'js-cookie';
@@ -67,3 +67,24 @@ describe('#validateRouteAccess', () => {
expect(next).toHaveBeenCalledWith();
});
});
describe('isOnOnboardingView', () => {
test('returns true for a route with onboarding name', () => {
const route = { name: 'onboarding_welcome' };
expect(isOnOnboardingView(route)).toBe(true);
});
test('returns false for a route without onboarding name', () => {
const route = { name: 'home' };
expect(isOnOnboardingView(route)).toBe(false);
});
test('returns false for a route with null name', () => {
const route = { name: null };
expect(isOnOnboardingView(route)).toBe(false);
});
test('returns false for an undefined route object', () => {
expect(isOnOnboardingView()).toBe(false);
});
});
+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"
/>
+2 -2
View File
@@ -1,13 +1,13 @@
class Account::ContactsExportJob < ApplicationJob
queue_as :low
def perform(account_id, column_names)
def perform(account_id, column_names, email_to)
account = Account.find(account_id)
headers = valid_headers(column_names)
generate_csv(account, headers)
file_url = account_contact_export_url(account)
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url, email_to)&.deliver_later
end
def generate_csv(account, headers)
+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
@@ -2,9 +2,24 @@ class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
queue_as :low
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)
# rubocop:enable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current).find_in_batches(batch_size: 100) do |notifications_batch|
notifications_batch.each do |notification|
update_notification(notification)
end
end
end
private
def update_notification(notification)
updated_meta = (notification.meta || {}).merge('last_snoozed_at' => notification.snoozed_until)
notification.update!(
snoozed_until: nil,
updated_at: Time.current,
last_activity_at: Time.current,
meta: updated_meta,
read_at: nil
)
end
end
+14
View File
@@ -37,10 +37,24 @@ class ApplicationMailbox < ActionMailbox::Base
# checks if follow this pattern send it to reply_mailbox
# reply+<conversation-uuid>@<mailer-domain.com>
def reply_uuid_mail?(inbound_mail)
validate_to_address(inbound_mail)
inbound_mail.mail.to&.any? do |email|
conversation_uuid = email.split('@')[0]
conversation_uuid.match?(REPLY_EMAIL_UUID_PATTERN)
end
end
# if mail.to returns a string, then it is a malformed `to` header
# valid `to` header will be of type Mail::AddressContainer
# validate if the to address is of type string
def validate_to_address(inbound_mail)
to_address_class = inbound_mail.mail.to&.class
return if to_address_class == Mail::AddressContainer
Rails.logger.error "Email to address header is malformed `#{inbound_mail.mail.to}`"
raise StandardError, "Invalid email to address header #{inbound_mail.mail.to}"
end
end
end
+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?
@@ -60,12 +60,13 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url)
def contact_export_complete(file_url, email_to)
return unless smtp_config_set_or_development?
@action_url = file_url
subject = "Your contact's export file is available to download."
send_mail_with_liquid(to: admin_emails, subject: subject) and return
send_mail_with_liquid(to: email_to, subject: subject) and return
end
private
+3 -2
View File
@@ -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
+3 -9
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
@@ -63,24 +64,17 @@ class Notification < ApplicationRecord
created_at: created_at.to_i,
last_activity_at: last_activity_at.to_i,
snoozed_until: snoozed_until,
meta: meta,
account_id: account_id
}
if primary_actor.present?
payload[:primary_actor] = primary_actor_data
payload[:primary_actor] = primary_actor&.push_event_data
payload[:push_message_title] = push_message_title
end
payload
end
def primary_actor_data
{
id: primary_actor.push_event_data[:id],
meta: primary_actor.push_event_data[:meta],
inbox_id: primary_actor.push_event_data[:inbox_id]
}
end
def fcm_push_data
{
id: id,
+5
View File
@@ -0,0 +1,5 @@
class ReportPolicy < ApplicationPolicy
def view?
@account_user.administrator?
end
end
@@ -0,0 +1,33 @@
class Reports::TimeFormatPresenter
include ActionView::Helpers::TextHelper
attr_reader :seconds
def initialize(seconds)
@seconds = seconds.to_i
end
def format
return '--' if seconds.nil? || seconds.zero?
days, remainder = seconds.divmod(86_400)
hours, remainder = remainder.divmod(3600)
minutes, seconds = remainder.divmod(60)
format_components(days: days, hours: hours, minutes: minutes, seconds: seconds)
end
private
def format_components(components)
formatted_components = components.filter_map do |unit, value|
next if value.zero?
I18n.t("time_units.#{unit}", count: value)
end
return I18n.t('time_units.seconds', count: 0) if formatted_components.empty?
formatted_components.first(2).join(' ')
end
end
+1
View File
@@ -3,6 +3,7 @@ class ActionService
def initialize(conversation)
@conversation = conversation.reload
@account = @conversation.account
end
def mute_conversation(_params)
@@ -0,0 +1,115 @@
require 'net/imap'
class Imap::BaseFetchEmailService
pattr_initialize [:channel!]
def perform
# Override this method
end
private
def authentication_type
# Override this method
end
def imap_password
# Override this method
end
def imap_client
@imap_client ||= build_imap_client
end
def mail_info_logger(inbound_mail, seq_no)
return if Rails.env.test?
Rails.logger.info("
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{seq_no}")
end
def email_already_present?(channel, message_id)
channel.inbox.messages.find_by(source_id: message_id).present?
end
def fetch_mail_for_channel
message_ids_with_seq = fetch_message_ids_with_sequence
message_ids_with_seq.filter_map do |message_id_with_seq|
process_message_id(message_id_with_seq)
end
end
def process_message_id(message_id_with_seq)
seq_no, message_id = message_id_with_seq
if message_id.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
return
end
return if email_already_present?(channel, message_id)
# 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(inbound_mail, seq_no)
inbound_mail
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
seq_nums = fetch_available_mail_sequence_numbers
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.search(['SINCE', yesterday])
end
def build_imap_client
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
imap.authenticate(authentication_type, channel.imap_login, imap_password)
imap.select('INBOX')
imap
end
def build_mail_from_string(raw_email_content)
Mail.read_from_string(raw_email_content)
end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
end
+15
View File
@@ -0,0 +1,15 @@
class Imap::FetchEmailService < Imap::BaseFetchEmailService
def perform
fetch_mail_for_channel
end
private
def authentication_type
'PLAIN'
end
def imap_password
channel.imap_password
end
end
@@ -0,0 +1,17 @@
class Imap::MicrosoftFetchEmailService < Imap::BaseFetchEmailService
def perform
return if channel.provider_config['access_token'].blank?
fetch_mail_for_channel
end
private
def authentication_type
'XOAUTH2'
end
def imap_password
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end
end
@@ -21,6 +21,7 @@ json.data do
json.created_at notification.created_at.to_i
json.last_activity_at notification.last_activity_at.to_i
json.snoozed_until notification.snoozed_until
json.meta notification.meta
end
end
end
@@ -6,6 +6,11 @@ if resource.custom_attributes.present?
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
end
end
json.domain @account.domain
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.agent_csv.agent_name'),
I18n.t('reports.agent_csv.conversations_count'),
@@ -9,4 +11,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
@@ -1,4 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<% end %>
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.inbox_csv.inbox_name'),
I18n.t('reports.inbox_csv.inbox_type'),
@@ -10,4 +12,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.label_csv.label_title'),
I18n.t('reports.label_csv.conversations_count'),
@@ -9,4 +11,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.team_csv.team_name'),
I18n.t('reports.team_csv.conversations_count'),
@@ -9,4 +11,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.5.2'
version: '3.6.0'
development:
<<: *shared
+1 -1
View File
@@ -1,6 +1,6 @@
# Define a method to fetch the git commit hash
def fetch_git_sha
sha = `git rev-parse HEAD`
sha = `git rev-parse HEAD` if File.directory?('.git')
if sha.present?
sha.strip
elsif File.exist?('.git_sha')
+6
View File
@@ -148,6 +148,12 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
## Prevent abuse of contact search api
throttle('/api/v1/accounts/:account_id/contacts/search', limit: 5, period: 1.minute) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/contacts/search}.match(req.path)
match_data[:account_id] if match_data.present?
end
## ----------------------------------------------- ##
end
+16 -13
View File
@@ -7,13 +7,11 @@
# value: the value of the config
# display_title: the title of the config displayed in the dashboard UI
# description: the description of the config displayed in the dashboard UI
# locked: if you don't specify locked attribute in yaml, the default value will be true,
# locked: if you don't specify locked attribute in yaml, the default value will be true,
# which means the particular config will be locked and won't be available in `super_admin/installation_configs`
# premium: These values get overwritten unless the user is on a premium plan
# type: The type of the config. Default is text, boolean is also supported
# ------- Branding Related Config ------- #
- name: INSTALLATION_NAME
value: 'Chatwoot'
@@ -58,8 +56,6 @@
type: boolean
# ------- End of Branding Related Config ------- #
# ------- Signup & Account Related Config ------- #
- name: ENABLE_ACCOUNT_SIGNUP
display_title: 'Enable Account Signup'
@@ -89,8 +85,6 @@
locked: false
# ------- End of Account Related Config ------- #
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
value:
@@ -101,12 +95,11 @@
locked: false
# ------- End of Email Related Config ------- #
# ------- Facebook Channel Related Config ------- #
- name: FB_APP_ID
- name: FB_APP_ID
display_title: 'Facebook App ID'
locked: false
- name: FB_VERIFY_TOKEN
- name: FB_VERIFY_TOKEN
display_title: 'Facebook Verify Token'
description: 'The verify token used for Facebook Messenger Webhook'
locked: false
@@ -117,6 +110,11 @@
display_title: 'Instagram Verify Token'
description: 'The verify token used for Instagram Webhook'
locked: false
- name: FACEBOOK_API_VERSION
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
value: 'v17.0'
locked: false
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
display_title: 'Enable human agent'
value: false
@@ -128,10 +126,12 @@
# ------- Chatwoot Internal Config for Cloud ----#
- name: CHATWOOT_INBOX_TOKEN
value:
display_title: 'Inbox Token'
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
locked: false
- name: CHATWOOT_INBOX_HMAC_KEY
value:
display_title: 'Inbox HMAC Key'
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
locked: false
- name: CHATWOOT_CLOUD_PLANS
@@ -142,10 +142,14 @@
description: 'The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted'
- name: ANALYTICS_TOKEN
value:
display_title: 'Analytics Token'
description: 'The June.so analytics token for Chatwoot cloud'
- name: CLEARBIT_API_KEY
value:
display_title: 'Clearbit API Key'
description: 'This API key is used for onboarding the users, to pre-fill account data.'
# ------- End of Chatwoot Internal Config for Cloud ----#
# ------- Chatwoot Internal Config for Self Hosted ----#
- name: INSTALLATION_PRICING_PLAN
value: 'community'
@@ -179,5 +183,4 @@
value: false
locked: false
description: 'Disable rendering profile update page for users'
## ------ End of Configs added for enterprise clients ------ ##
## ------ End of Configs added for enterprise clients ------ ##
+22 -9
View File
@@ -83,25 +83,25 @@ en:
utc_warning: The report generated is in UTC timezone
agent_csv:
agent_name: Agent name
conversations_count: Conversations count
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
conversations_count: Assigned conversations
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
inbox_csv:
inbox_name: Inbox name
inbox_type: Inbox type
conversations_count: No. of conversations
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
label_csv:
label_title: Label
conversations_count: No. of conversations
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
team_csv:
team_name: Team name
conversations_count: Conversations count
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
conversation_traffic_csv:
timezone: Timezone
default_group_by: day
@@ -245,3 +245,16 @@ en:
inbox_name: Inbox
inbox_type: Inbox Type
button: Open conversation
time_units:
days:
one: "%{count} day"
other: "%{count} days"
hours:
one: "%{count} hour"
other: "%{count} hours"
minutes:
one: "%{count} minute"
other: "%{count} minutes"
seconds:
one: "%{count} second"
other: "%{count} seconds"
+6
View File
@@ -294,6 +294,12 @@ Rails.application.routes.draw do
namespace :v2 do
resources :accounts, only: [:create] do
scope module: :accounts do
resources :summary_reports, only: [] do
collection do
get :agent
get :team
end
end
resources :reports, only: [:index] do
collection do
get :summary
@@ -0,0 +1,5 @@
class AddMetaToNotifications < ActiveRecord::Migration[7.0]
def change
add_column :notifications, :meta, :jsonb, default: {}
end
end
@@ -0,0 +1,8 @@
class AddUniqueIndexToAppliedSlas < ActiveRecord::Migration[7.0]
def change
add_index :applied_slas,
[:account_id, :sla_policy_id, :conversation_id],
unique: true,
name: 'index_applied_slas_on_account_sla_policy_conversation'
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
ActiveRecord::Schema[7.0].define(version: 2024_02_16_055809) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -122,6 +122,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "sla_status", default: 0
t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true
t.index ["account_id"], name: "index_applied_slas_on_account_id"
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
@@ -750,6 +751,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
t.datetime "updated_at", null: false
t.datetime "snoozed_until"
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }
t.jsonb "meta", default: {}
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at"
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
@@ -0,0 +1,40 @@
module Enterprise::Api::V2::AccountsController
private
def fetch_account_and_user_info
@data = fetch_from_clearbit
return if @data.blank?
update_user_info
end
def fetch_from_clearbit
Enterprise::ClearbitLookupService.lookup(@user.email)
rescue StandardError => e
Rails.logger.error "Error fetching data from clearbit: #{e}"
nil
end
def update_user_info
@user.update!(name: @data[:name]) if @data[:name].present?
end
def data_from_clearbit
return {} if @data.blank?
{ name: @data[:company_name],
custom_attributes: {
'industry' => @data[:industry],
'company_size' => @data[:company_size],
'timezone' => @data[:timezone],
'logo' => @data[:logo]
} }
end
def account_attributes
super.deep_merge(
data_from_clearbit
)
end
end
@@ -6,20 +6,30 @@ module Enterprise::SuperAdmin::AppConfigsController
case @config
when 'custom_branding'
@allowed_configs = %w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
@allowed_configs = custom_branding_options
when 'internal'
@allowed_configs = internal_config_options
else
super
end
end
def custom_branding_options
%w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
end
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY]
end
end

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