Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 54308a4145 Merge branch 'develop' into fix/editor-heading-error 2024-01-29 20:23:00 +05:30
Shivam MishraandGitHub 2eeec22868 fix: Cookies.set does not stringify JSON (#8807)
* fix: cookie setting

* chore: remove debug statement

* chore: add specs to test stringify
2024-01-29 18:14:49 +05:30
Muhsin KelothandGitHub ef50edb9e2 feat: Standardise the external channel user id and user name (#8802)
* feat: Standardize the external channel id and user name

* chore: add specs

* chore: add name space `social`
2024-01-29 16:57:10 +05:30
Shivam MishraandGitHub 3ed80fa867 feat: expiry for cache keys [CW-3038] (#8793)
* feat: set cache keys for 3 days only

* feat: invalidate should set the latest timestamp

* refactor: cache_keys concern

* remove invalidate_cache method
* refactor reset to set to new value instead of delete
* ensure only one event is dispatched

* feat: set expiry to 24 hours

* chore: make expiry 48 hours

* feat: include destroy event

* feat: set expiry to 72 days

* fix: typo

* test: cache update after `touch`

* test: update cache keys

* refactor: remove after_touch, it's already handled in commit
2024-01-29 15:27:26 +05:30
Nithin David ThomasandGitHub 88f44c21f2 Merge branch 'develop' into fix/editor-heading-error 2024-01-29 01:23:51 -08:00
Sivin VargheseandGitHub cf664ca2a0 feat: Inbox item card component (#8801) 2024-01-29 14:52:55 +05:30
Muhsin KelothandGitHub 485c561b18 feat: Add attributes location and country_code to Contact model (#8803)
feat: Add attributes location and country_code to Contact model
2024-01-29 14:10:14 +05:30
Pranav Raj SandGitHub 766698cb3a chore: Upgrade @june-so/analytics-next, js-cookie to the latest version (#8799)
This is a small change, upgrading 2 packages to the latest version. getJSON is removed from the latest version, I've added a patch for the same across the codebase.

fixes: https://linear.app/chatwoot/issue/CW-3035/upgrade-dependencies
2024-01-29 11:41:42 +04:00
Nithin David Thomas feb8ec7879 Upgrades package ith latest fixes 2024-01-29 10:33:23 +05:30
Shivam MishraandGitHub 082793290a feat: allow multiple files for FB and Insta [CW-3019] (#8783)
* feat: allow multiple messages

* fix: typo

* feat: send content and attachments both

* refactor: message sequence

* test: multiple attachments for instagram

* test: multiple attachments on facebook
2024-01-28 13:20:54 +05:30
Pranav Raj SandGitHub 12916ceca6 fix: Capture delivery errors to avoid false positives (#8790)
The system did not detect the delivery errors earlier, resulting in some false positives. The user was not informed when an email failed to be delivered. While we do handle failure status in other channels, we were not able to capture the actual delivery status for the email channel.

This pull request makes the following changes:

- Updated the class EmailReplyWorker to use the deliver_now method instead of deliver_later. This change is made to raise any errors that may occur with the SMTP connection. The errors are then captured and sent to Sentry, and the email is marked as failed. Previously, we did not consider the case of retries in the email channel, so this feature is currently not functioning. So, I have disabled the retry option. We will address this in a follow-up ticket.
- Downgraded the net-smtp gem to version 0.3.4. This change is made to avoid an argument error when using XOAUTH2.

Fixes: https://linear.app/chatwoot/issue/CW-3032/argumenterror-wrong-authentication-type-xoauth2-argumenterror
2024-01-26 14:22:18 +04:00
Nithin David Thomas b5e81ccd3b Updates yarn lock 2024-01-26 10:45:16 +05:30
Nithin David ThomasandGitHub b31040901e Merge branch 'develop' into fix/editor-heading-error 2024-01-25 20:02:16 -08:00
3c952e6a4a fix: change email conversation not found exception to log (#8785)
* fix: change email conversation not found exception to log

* chore: refactor reply_mailbox methods

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-01-25 22:36:02 +05:30
Sojan JoseandGitHub 59184122f7 fix: [Snyk] Security upgrade administrate from 0.19.0 to 0.20.1 (#8741)
-  Upgrade  administrate  gem to latest
- Update the `show` partial with changes from upstream
2024-01-25 17:33:21 +04:00
381423b1ae fix: Removed author section from public help center (#8767)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-01-25 17:17:16 +05:30
Pranav Raj SandGitHub b7c9f779ad fix: Avoid processing reactions, ephemeral, request_welcome or unsupported messages (#8780)
Currently, we do not support reactions, ephemeral messages, or the request_welcome event for the WhatsApp channel. However, if this is the first event we receive in Chatwoot (i.e., there is no previous conversation or contact in Chatwoot), it will create a contact and a conversation without any messages. This confuses our customer, as it may appear that Chatwoot has missed some messages. There are multiple cases where this might be the first event we receive in Chatwoot. One quick example is when the user has sent an outbound campaign from another tool and their customers reacted to the message.

Another event like this is request_welcome event. WhatsApp has a concept for welcome messages. You can send an outbound message even though the user has not send a message. You can receive notifications through a webhook whenever a WhatsApp user initiates a chat with you for the first time. (Read the Welcome message section: https://developers.facebook.com/docs/whatsapp/cloud-api/phone-numbers/conversational-components/ ). Although this can help the business send a pro-active message to the user, we don't have it scoped in our feature set. For now, I'm ignoring this event.

Fixes https://linear.app/chatwoot/issue/CW-3018/whatsapp-handle-request-welcome-case-properly
Fixes https://linear.app/chatwoot/issue/CW-3017/whatsapp-handle-reactions-properly
2024-01-25 11:40:18 +04:00
Muhsin KelothandGitHub 904d76420d fix: Add last_activity_at to notification push event data (#8784)
fix: Add last_activity_at to push event data
2024-01-25 12:05:00 +05:30
Muhsin KelothandGitHub fa907840c7 feat: Add middle_name and last_name to contact model (#8771)
feat: Add `middle_name` and `last_name`
2024-01-24 16:22:04 +05:30
3760f206e8 fix: mutex timeout and error handling (#8770)
Fixes the follow cases 
- The ensure block released the lock even on LockAcquisitionError
- Custom timeout was not allowed

This also refactored the with_lock method, now the key has to be constructed in the parent function itself

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-01-24 14:18:21 +04:00
Sojan JoseandGitHub a861257f73 chore: Fix flaky contacts spec (#8773)
- The ordering was not guaranteed; hence, the specs were failing randomly. Made changes to the expectations accordingly
2024-01-24 13:58:27 +04:00
Muhsin KelothandGitHub 143299f138 feat: Add contact_type attribute to contact model (#8768) 2024-01-24 12:26:47 +05:30
Sivin VargheseandGitHub 1f610c7fc5 Merge branch 'develop' into fix/editor-heading-error 2024-01-24 11:09:20 +05:30
Vishnu NarayananandGitHub 232369cd5c feat: sla 1 - refactor sla_policies model and add applied_sla model (#8602)
* feat: add models

* chore: refactor sla column names

* chore: remove foreign keys

* chore: fix spec

* chore: refactor models
2024-01-23 23:48:02 +05:30
Surabhi SumanandGitHub 4b40c61201 feat: Support Regex validation for custom attributes (#7856)
This allows a user to add/update a custom regex and a cue while defining custom attributes(Only applicable for type- text).
While adding/editing custom attributes, the values are validated against the attribute definition regex, and if it is incorrect, a cue message or default error message is shown and restricts invalid values from being saved.

Fixes: #6866
2024-01-23 18:01:57 +04:00
834c219b9b feat(perf): update query to do a simpler search [CW-2997] (#8763)
Message search would frequently timeout. The reason was that the query would join the conversation too, the new query searches the message table directly

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-23 14:20:00 +04:00
Sivin VargheseandGitHub d0cd1c8887 fix: Help center articles not accessible after authoring agent is deleted (#8756) 2024-01-23 14:06:51 +05:30
Nithin David Thomas 188a8a67c0 chore: Updates prosemirror package to latest version 2024-01-23 08:37:06 +05:30
Muhsin KelothandGitHub 682a2aea1c chore: Handle twillio Down::ClientError (#8757)
Fixes: https://linear.app/chatwoot/issue/CW-2992/downclienterror-400-bad-request-downclienterror
2024-01-22 15:33:26 +04:00
Shivam MishraandGitHub 1dc66db516 fix: SQL error when rules with missing attributes is triggered (#8673) 2024-01-22 16:09:34 +05:30
Sojan JoseandGitHub bc04d81a5a fix: Handle Net::IMAP::InvalidResponseError Exception bad response type "ESMTP" (#8755) 2024-01-22 15:32:44 +05:30
d2c5c2f9a3 chore: [Snyk] Security upgrade sidekiq from 7.2.0 to 7.2.1 (#8748)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-01-22 13:12:26 +04:00
Sojan JoseandGitHub 381fda270a chore: Fix typo in Inbox Management copy (#8750)
- Fixes the typo in Inbox Management copy -> vistors to visitors
2024-01-22 13:07:36 +04:00
b3c9d1f1a5 fix: clear timers and animation frame request before component unmounts (#8700)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-01-22 12:08:33 +05:30
Sivin VargheseandGitHub fd4376d062 fix: TypeError: Cannot read properties of undefined (reading 'emoji') (#8753) 2024-01-22 12:06:59 +05:30
Pranav Raj SandGitHub a8f053921b fix: Migrate notes when merging the contacts (#8749)
Fixes: https://linear.app/chatwoot/issue/CW-2987/migrate-notes-of-the-secondary-contact-to-primary-contact-when-merging
2024-01-20 10:32:18 +04:00
Sivin VargheseandGitHub 8e15ada164 fix: TypeError: Cannot read properties of undefined (reading 'emoji') (#8747) 2024-01-19 19:55:51 +05:30
Sojan 51093fd543 Merge branch 'release/3.5.2' into develop 2024-01-19 15:15:33 +04:00
Sojan 48e638cc56 Bump version to 3.5.2 2024-01-19 15:14:49 +04:00
Pavel KuzminandGitHub e6dfc159ed fix: change teamid (#8613) 2024-01-19 16:15:28 +05:30
Chatwoot BotandGitHub 900c2de89a chore: Update translations (#8717) 2024-01-19 14:32:27 +04:00
29106ebc4b fix: raise_lock_acquisition_error if the job cannot set the lock (#8744)
Consider a scenario where two jobs are concurrently accessing a job with a lock mechanism. If the second job accesses lock_manager.locked? before the first job called lock_manager.lock(lock_key), it would return a false positive. At this point, both jobs can be executed freely.

To address this issue, the following change ensures that only the current thread that has set the lock can execute. Otherwise, a LockAcquisitionError will be thrown.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-01-19 14:31:41 +04:00
Pranav Raj SandGitHub a579684b34 fix: Update the message-id check to use messageId parsing from mail gem (#8743)
- Update the message ID parsing logic to use the mail gem
- Update the code to improve readability
2024-01-19 14:29:04 +04:00
c29a9ad062 feat: Updates branding logo and name in public portal footer (#8745)
- Use white-label settings for Chatwoot Help Center

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-19 13:37:24 +04:00
Muhsin KelothandGitHub 0ac015ce7a feat: Add a job to clear notifications that were created before 1 month (#8732) 2024-01-19 13:03:23 +05:30
19474e0074 chore: Improve active job error logs for deserialization error (#8742)
- Improve active job error logs for deserialization error 

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-18 19:27:18 +04:00
Sojan JoseandGitHub ce8190dacf fix: API error when using SuperAdmin token (#8739)
- Fixes the issue in release 3.5.0, which causes SuperAdmin tokens to throw error during API calls

Fixes: #8719
2024-01-18 18:49:32 +04:00
Shivam MishraandGitHub aacf326ca1 refactor: remove exception tracker (#8737) 2024-01-18 17:49:27 +05:30
Shivam MishraandGitHub e39c14460b fix: validate url for Dashboard Apps [CW-2979] (#8736) 2024-01-18 17:48:30 +05:30
Sojan JoseandGitHub 5f6e17f307 feat: Use Telegram HTML Parsemode (#8731)
- this ensures that the markdown formatted messages from the Chatwoot dashboard will render consistently in telegram UI for the supported types like bold, italics, links etc
2024-01-18 15:36:36 +04:00
Shivam MishraandGitHub 4bf23adcf5 feat: use short_summary for downloading reports [CW-2962] (#8733) 2024-01-18 16:06:13 +05:30
Pranav Raj SandGitHub 1f4d860d9d fix: Use channel.inbox instead of inbox (#8734) 2024-01-18 00:46:25 -08:00
fdbb3bf4b1 fix: Optimize email fetching logic to fix bandwidth exceeded issues (#8730)
The Inboxes::FetchImapEmailsJob is designed to fetch the entire email object and check if its message id is already in the database. However, this process is resource-intensive and time-consuming, as fetching the full email object takes a significant amount of time.

On average, fetching 100 emails can take approximately 3-4 minutes to complete depending on the IMAP server. Since we are not using server flags to identify which emails have already been fetched (to avoid compatibility issues with flags in different email services), we have to fetch all previously available emails. Currently we fetch all the messages that were created from yesterday. This becomes problematic with services like Gmail, which throttle requests based on bandwidth usage.

To address this issue, I have updated the logic as follows:

Fetch the sequence IDs of all the mails in the "Inbox" that were created from yesterday.
Use the FETCH command to fetch only the message-ids using BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)] with the sequence IDs we got in the previous step. This is a faster operation with lower bandwidth usage, as it only returns the sequence ID and message ID.
Check if the message IDs are already present in the database for the selected inbox.
If not present, fetch the entire email object using the FETCH command and create the message.
If the message ID is already present, ignore it and move on to the next message-id.
I have also addressed the issue of duplicate emails appearing in conversations when two fetch email jobs occur simultaneously. I have added a lock for the channel to ensure that the job gracefully exits without waiting for the current job to complete.

Fixes #7247
Fixes #6082
Fixes #8314

Co-authored-by: Sojan <sojan@pepalo.com>
2024-01-18 11:45:16 +04:00
Muhsin KelothandGitHub c899cc825d fix: Handle Contact import MalformedCSVError (#8706) 2024-01-18 13:05:58 +05:30
Muhsin KelothandGitHub eb972684b3 feat: Show notification last active time instead of created time (#8724) 2024-01-18 09:41:53 +05:30
Muhsin KelothandGitHub 896473f03e fix: Notification count is incorrect when the number of notifications exceeds the page size. (#8723)
- The notification count is incorrect when the number of notifications exceeds the page size.
2024-01-17 15:14:37 +04:00
Sojan 8c43edbd50 Merge branch 'release/3.5.1' into develop 2024-01-17 13:48:28 +04:00
136 changed files with 1724 additions and 416 deletions
+4 -2
View File
@@ -39,6 +39,8 @@ gem 'rack-attack', '>= 6.7.0'
gem 'down' gem 'down'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
gem 'net-smtp', '~> 0.3.4'
# Prevent CSV injection # Prevent CSV injection
gem 'csv-safe' gem 'csv-safe'
@@ -74,7 +76,7 @@ gem 'devise_token_auth'
gem 'jwt' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.19.0' gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.1' gem 'administrate-field-active_storage', '>= 1.0.1'
gem 'administrate-field-belongs_to_search', '>= 0.9.0' gem 'administrate-field-belongs_to_search', '>= 0.9.0'
@@ -114,7 +116,7 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false gem 'sentry-sidekiq', '>= 5.14.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '>= 7.1.3' gem 'sidekiq', '>= 7.2.1'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0' gem 'sidekiq-cron', '>= 1.12.0'
+15 -14
View File
@@ -105,12 +105,12 @@ GEM
activerecord (>= 6.0, < 7.1) activerecord (>= 6.0, < 7.1)
addressable (2.8.4) addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
administrate (0.19.0) administrate (0.20.1)
actionpack (>= 5.0) actionpack (>= 6.0, < 8.0)
actionview (>= 5.0) actionview (>= 6.0, < 8.0)
activerecord (>= 5.0) activerecord (>= 6.0, < 8.0)
jquery-rails (>= 4.0) jquery-rails (~> 4.6.0)
kaminari (>= 1.0) kaminari (~> 1.2.2)
sassc-rails (~> 2.1) sassc-rails (~> 2.1)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.1) administrate-field-active_storage (1.0.1)
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.2.2) concurrent-ruby (1.2.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crack (0.4.5) crack (0.4.5)
rexml rexml
@@ -461,7 +461,7 @@ GEM
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.5) mini_portile2 (2.8.5)
minitest (5.20.0) minitest (5.21.2)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.7.0) msgpack (1.7.0)
@@ -479,7 +479,7 @@ GEM
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
timeout timeout
net-smtp (0.4.0) net-smtp (0.3.4)
net-protocol net-protocol
netrc (0.11.0) netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.2) newrelic-sidekiq-metrics (1.6.2)
@@ -610,7 +610,7 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
redis (5.0.6) redis (5.0.6)
redis-client (>= 0.9.0) redis-client (>= 0.9.0)
redis-client (0.19.0) redis-client (0.19.1)
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
@@ -720,11 +720,11 @@ GEM
sexp_processor (4.17.0) sexp_processor (4.17.0)
shoulda-matchers (5.3.0) shoulda-matchers (5.3.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (7.2.0) sidekiq (7.2.1)
concurrent-ruby (< 2) concurrent-ruby (< 2)
connection_pool (>= 2.3.0) connection_pool (>= 2.3.0)
rack (>= 2.2.4) rack (>= 2.2.4)
redis-client (>= 0.14.0) redis-client (>= 0.19.0)
sidekiq-cron (1.12.0) sidekiq-cron (1.12.0)
fugit (~> 1.8) fugit (~> 1.8)
globalid (>= 1.0.1) globalid (>= 1.0.1)
@@ -840,7 +840,7 @@ DEPENDENCIES
active_record_query_trace active_record_query_trace
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate (>= 0.19.0) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.1) administrate-field-active_storage (>= 1.0.1)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
annotate annotate
@@ -903,6 +903,7 @@ DEPENDENCIES
meta_request meta_request
mock_redis mock_redis
neighbor neighbor
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2) newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm newrelic_rpm
omniauth (>= 2.1.2) omniauth (>= 2.1.2)
@@ -939,7 +940,7 @@ DEPENDENCIES
sentry-ruby sentry-ruby
sentry-sidekiq (>= 5.14.0) sentry-sidekiq (>= 5.14.0)
shoulda-matchers shoulda-matchers
sidekiq (>= 7.1.3) sidekiq (>= 7.2.1)
sidekiq-cron (>= 1.12.0) sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0) slack-ruby-client (~> 2.2.0)
+5
View File
@@ -12,6 +12,7 @@ class ContactMergeAction
merge_conversations merge_conversations
merge_messages merge_messages
merge_contact_inboxes merge_contact_inboxes
merge_contact_notes
merge_and_remove_mergee_contact merge_and_remove_mergee_contact
end end
@base_contact @base_contact
@@ -33,6 +34,10 @@ class ContactMergeAction
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
end end
def merge_contact_notes
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id)
end
def merge_messages def merge_messages
Message.where(sender: @mergee_contact).update(sender: @base_contact) Message.where(sender: @mergee_contact).update(sender: @base_contact)
end end
+8
View File
@@ -46,6 +46,14 @@ class V2::ReportBuilder
} }
end end
def short_summary
{
conversations_count: conversations.count,
avg_first_response_time: avg_first_response_time_summary,
avg_resolution_time: avg_resolution_time_summary
}
end
def conversation_metrics def conversation_metrics
if params[:type].equal?(:account) if params[:type].equal?(:account)
live_conversations live_conversations
@@ -39,6 +39,8 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
:attribute_display_type, :attribute_display_type,
:attribute_key, :attribute_key,
:attribute_model, :attribute_model,
:regex_pattern,
:regex_cue,
attribute_values: [] attribute_values: []
) )
end end
@@ -9,7 +9,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def index def index
@unread_count = notification_finder.unread_count @unread_count = notification_finder.unread_count
@notifications = notification_finder.perform @notifications = notification_finder.perform
@count = @notifications.count @count = notification_finder.count
end end
def read_all def read_all
@@ -14,7 +14,14 @@ module AccessTokenAuthHelper
render_unauthorized('Invalid Access Token') && return if @access_token.blank? render_unauthorized('Invalid Access Token') && return if @access_token.blank?
@resource = @access_token.owner @resource = @access_token.owner
Current.user = @resource if [User, AgentBot].include?(@resource.class) Current.user = @resource if allowed_current_user_type?(@resource)
end
def allowed_current_user_type?(resource)
return true if resource.is_a?(User)
return true if resource.is_a?(AgentBot)
false
end end
def validate_bot_access_token! def validate_bot_access_token!
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::BaseController < PublicController class Public::Api::V1::Portals::BaseController < PublicController
before_action :show_plain_layout before_action :show_plain_layout
before_action :set_color_scheme before_action :set_color_scheme
before_action :set_global_config
around_action :set_locale around_action :set_locale
after_action :allow_iframe_requests after_action :allow_iframe_requests
@@ -60,4 +61,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
portal portal
render 'public/api/v1/portals/error/404', status: :not_found render 'public/api/v1/portals/error/404', status: :not_found
end end
def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
end
end end
+1 -1
View File
@@ -53,7 +53,7 @@ module Api::V1::InboxesHelper
rescue StandardError => e rescue StandardError => e
raise StandardError, e.message raise StandardError, e.message
ensure ensure
ChatwootExceptionTracker.new(e).capture_exception if e.present? Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end end
def check_smtp_connection(channel_data, smtp) def check_smtp_connection(channel_data, smtp)
@@ -37,7 +37,7 @@ module Api::V2::Accounts::ReportsHelper
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
} }
) )
).summary ).short_summary
end end
private private
+3 -2
View File
@@ -29,11 +29,12 @@ export default {
return fetchPromise; return fetchPromise;
}, },
hasAuthCookie() { hasAuthCookie() {
return !!Cookies.getJSON('cw_d_session_info'); return !!Cookies.get('cw_d_session_info');
}, },
getAuthData() { getAuthData() {
if (this.hasAuthCookie()) { if (this.hasAuthCookie()) {
return Cookies.getJSON('cw_d_session_info'); const savedAuthInfo = Cookies.get('cw_d_session_info');
return JSON.parse(savedAuthInfo || '{}');
} }
return false; return false;
}, },
@@ -519,6 +519,9 @@ export default {
}, },
}, },
watch: { watch: {
teamId() {
this.updateVirtualListProps('teamId', this.teamId);
},
activeTeam() { activeTeam() {
this.resetAndFetchData(); this.resetAndFetchData();
}, },
@@ -126,18 +126,26 @@ import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue'; import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import { isValidURL } from '../helper/URLHelper'; import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
const DATE_FORMAT = 'yyyy-MM-dd'; const DATE_FORMAT = 'yyyy-MM-dd';
export default { export default {
components: { components: {
MultiselectDropdown, MultiselectDropdown,
}, },
mixins: [customAttributeMixin],
props: { props: {
label: { type: String, required: true }, label: { type: String, required: true },
values: { type: Array, default: () => [] }, values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' }, value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false }, showActions: { type: Boolean, default: false },
attributeType: { type: String, default: 'text' }, attributeType: { type: String, default: 'text' },
attributeRegex: {
type: String,
default: null,
},
regexCue: { type: String, default: null },
regexEnabled: { type: Boolean, default: false },
attributeKey: { type: String, required: true }, attributeKey: { type: String, required: true },
contactId: { type: Number, default: null }, contactId: { type: Number, default: null },
}, },
@@ -204,6 +212,11 @@ export default {
if (this.$v.editedValue.url) { if (this.$v.editedValue.url) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL'); return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
} }
if (!this.$v.editedValue.regexValidation) {
return this.regexCue
? this.regexCue
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}, },
}, },
@@ -221,7 +234,15 @@ export default {
}; };
} }
return { return {
editedValue: { required }, editedValue: {
required,
regexValidation: value => {
return !(
this.attributeRegex &&
!this.getRegexp(this.attributeRegex).test(value)
);
},
},
}; };
}, },
mounted() { mounted() {
@@ -56,7 +56,7 @@
class="bg-slate-50 dark:bg-slate-700 rounded text-xxs font-medium mx-1 py-0 px-1" class="bg-slate-50 dark:bg-slate-700 rounded text-xxs font-medium mx-1 py-0 px-1"
:class=" :class="
isCountZero isCountZero
? `text-slate-300 dark:text-slate-700` ? `text-slate-300 dark:text-slate-500`
: `text-slate-700 dark:text-slate-50` : `text-slate-700 dark:text-slate-50`
" "
> >
@@ -1,7 +1,10 @@
<template> <template>
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble"> <li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
<div :class="wrapClass"> <div :class="wrapClass">
<div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert"> <div
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
class="message-failed--alert"
>
<woot-button <woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')" v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny" size="tiny"
@@ -203,6 +206,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isAnEmailInbox: {
type: Boolean,
default: false,
},
inboxSupportsReplyTo: { inboxSupportsReplyTo: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
@@ -33,6 +33,7 @@
:is-a-whatsapp-channel="isAWhatsAppChannel" :is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox" :is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox" :is-a-facebook-inbox="isAFacebookInbox"
:is-an-email-inbox="isAnEmailChannel"
:is-instagram="isInstagramDM" :is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo" :inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)" :in-reply-to="getInReplyToMessage(message)"
@@ -5,7 +5,7 @@
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14" class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
@click="toggleCountryDropdown" @click="toggleCountryDropdown"
> >
<h5 v-if="activeCountry.emoji" class="mb-0"> <h5 v-if="activeCountry" class="mb-0">
{{ activeCountry.emoji }} {{ activeCountry.emoji }}
</h5> </h5>
<fluent-icon v-else icon="globe" class="fluent-icon" size="16" /> <fluent-icon v-else icon="globe" class="fluent-icon" size="16" />
@@ -47,6 +47,8 @@ export const getCustomFields = ({ standardFields, customAttributes }) => {
type: attribute.attribute_display_type, type: attribute.attribute_display_type,
values: attribute.attribute_values, values: attribute.attribute_values,
field_type: attribute.attribute_model, field_type: attribute.attribute_model,
regex_pattern: attribute.regex_pattern,
regex_cue: attribute.regex_cue,
required: false, required: false,
enabled: false, enabled: false,
}); });
@@ -44,4 +44,18 @@ export default {
created_at: '2021-11-29T10:20:04.563Z', created_at: '2021-11-29T10:20:04.563Z',
}, },
], ],
customAttributesWithRegex: [
{
id: 2,
attribute_description: 'Test contact Attribute',
attribute_display_name: 'Test contact Attribute',
attribute_display_type: 'text',
attribute_key: 'test_contact_attribute',
attribute_model: 'contact_attribute',
attribute_values: Array(0),
created_at: '2023-09-20T10:20:04.563Z',
regex_pattern: '^w+$',
regex_cue: 'It should be a combination of alphabets and numbers',
},
],
}; };
@@ -5,7 +5,8 @@ import {
} from '../preChat'; } from '../preChat';
import inboxFixture from './inboxFixture'; import inboxFixture from './inboxFixture';
const { customFields, customAttributes } = inboxFixture; const { customFields, customAttributes, customAttributesWithRegex } =
inboxFixture;
describe('#Pre chat Helpers', () => { describe('#Pre chat Helpers', () => {
describe('getPreChatFields', () => { describe('getPreChatFields', () => {
it('should return correct pre-chat fields form options passed', () => { it('should return correct pre-chat fields form options passed', () => {
@@ -27,7 +28,6 @@ describe('#Pre chat Helpers', () => {
placeholder: 'Please enter your email address', placeholder: 'Please enter your email address',
type: 'email', type: 'email',
field_type: 'standard', field_type: 'standard',
required: false, required: false,
enabled: false, enabled: false,
}, },
@@ -71,6 +71,26 @@ describe('#Pre chat Helpers', () => {
values: [], values: [],
}, },
]); ]);
expect(
getCustomFields({
standardFields: { pre_chat_fields: customFields.pre_chat_fields },
customAttributes: customAttributesWithRegex,
})
).toEqual([
{
enabled: false,
label: 'Test contact Attribute',
placeholder: 'Test contact Attribute',
name: 'test_contact_attribute',
required: false,
field_type: 'contact_attribute',
type: 'text',
values: [],
regex_pattern: '^w+$',
regex_cue: 'It should be a combination of alphabets and numbers',
},
]);
}); });
}); });
}); });
@@ -39,6 +39,17 @@
"PLACEHOLDER": "Enter custom attribute key", "PLACEHOLDER": "Enter custom attribute key",
"ERROR": "Key is required", "ERROR": "Key is required",
"IN_VALID": "Invalid key" "IN_VALID": "Invalid key"
},
"REGEX_PATTERN": {
"LABEL": "Regex Pattern",
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
},
"REGEX_CUE": {
"LABEL": "Regex Cue",
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
},
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
} }
}, },
"API": { "API": {
@@ -88,6 +99,17 @@
"EMPTY_RESULT": { "EMPTY_RESULT": {
"404": "There are no custom attributes created", "404": "There are no custom attributes created",
"NOT_FOUND": "There are no custom attributes configured" "NOT_FOUND": "There are no custom attributes configured"
},
"REGEX_PATTERN": {
"LABEL": "Regex Pattern",
"PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)"
},
"REGEX_CUE": {
"LABEL": "Regex Cue",
"PLACEHOLDER": "Please enter regex pattern hint. (Optional)"
},
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
} }
} }
} }
@@ -339,7 +339,8 @@
}, },
"VALIDATIONS": { "VALIDATIONS": {
"REQUIRED": "Valid value is required", "REQUIRED": "Valid value is required",
"INVALID_URL": "Invalid URL" "INVALID_URL": "Invalid URL",
"INVALID_INPUT": "Invalid Input"
} }
}, },
"MERGE_CONTACTS": { "MERGE_CONTACTS": {
@@ -324,7 +324,8 @@
"LAST_EDITED": "Last edited" "LAST_EDITED": "Last edited"
}, },
"COLUMNS": { "COLUMNS": {
"BY": "by" "BY": "by",
"AUTHOR_NOT_AVAILABLE": "Author is not available"
} }
}, },
"EDIT_ARTICLE": { "EDIT_ARTICLE": {
@@ -569,7 +569,7 @@
"UPDATE": "Update business hours settings", "UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.",
"DAY": { "DAY": {
"ENABLE": "Enable availability for this day", "ENABLE": "Enable availability for this day",
"UNAVAILABLE": "Unavailable", "UNAVAILABLE": "Unavailable",
@@ -126,6 +126,6 @@
"NO_CONTENT": "Содержимое отсутствует", "NO_CONTENT": "Содержимое отсутствует",
"HIDE_QUOTED_TEXT": "Скрыть цитируемый текст", "HIDE_QUOTED_TEXT": "Скрыть цитируемый текст",
"SHOW_QUOTED_TEXT": "Показать цитируемый текст", "SHOW_QUOTED_TEXT": "Показать цитируемый текст",
"MESSAGE_READ": "Читать" "MESSAGE_READ": "Прочитано"
} }
} }
@@ -0,0 +1,11 @@
export default {
methods: {
getRegexp(regexPatternValue) {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
},
},
};
@@ -11,6 +11,8 @@
emoji="" emoji=""
:value="attribute.value" :value="attribute.value"
:show-actions="true" :show-actions="true"
:attribute-regex="attribute.regex_pattern"
:regex-cue="attribute.regex_cue"
:class="attributeClass" :class="attributeClass"
@update="onUpdate" @update="onUpdate"
@delete="onDelete" @delete="onDelete"
@@ -11,9 +11,30 @@
{{ title }} {{ title }}
</h6> </h6>
</router-link> </router-link>
<div class="author"> <div class="flex gap-1 items-center">
<span class="by">{{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }}</span> <Thumbnail
<span class="name">{{ articleAuthorName }}</span> v-if="author"
:src="author.thumbnail"
:username="author.name"
size="16px"
/>
<div
v-else
v-tooltip.right="
$t('HELP_CENTER.TABLE.COLUMNS.AUTHOR_NOT_AVAILABLE')
"
class="flex items-center justify-center rounded w-4 h-4 bg-woot-100 dark:bg-woot-700"
>
<fluent-icon
icon="person"
type="filled"
size="10"
class="text-woot-300 dark:text-woot-300"
/>
</div>
<span class="font-normal text-slate-500 dark:text-slate-200 text-sm">
{{ articleAuthorName }}
</span>
</div> </div>
</div> </div>
</span> </span>
@@ -57,10 +78,12 @@ import timeMixin from 'dashboard/mixins/time';
import portalMixin from '../mixins/portalMixin'; import portalMixin from '../mixins/portalMixin';
import { frontendURL } from 'dashboard/helper/URLHelper'; import { frontendURL } from 'dashboard/helper/URLHelper';
import EmojiOrIcon from '../../../../../shared/components/EmojiOrIcon.vue'; import EmojiOrIcon from '../../../../../shared/components/EmojiOrIcon.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default { export default {
components: { components: {
EmojiOrIcon, EmojiOrIcon,
Thumbnail,
}, },
mixins: [timeMixin, portalMixin], mixins: [timeMixin, portalMixin],
props: { props: {
@@ -110,7 +133,7 @@ export default {
}).format(this.views || 0); }).format(this.views || 0);
}, },
articleAuthorName() { articleAuthorName() {
return this.author.name; return this.author?.name || '-';
}, },
labelColor() { labelColor() {
switch (this.status) { switch (this.status) {
@@ -189,15 +212,6 @@ export default {
.article-block { .article-block {
@apply min-w-0; @apply min-w-0;
} }
.author {
.by {
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
}
.name {
@apply font-normal text-slate-500 dark:text-slate-200 text-sm;
}
}
} }
span { span {
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<div <div
class="my-0 py-0 px-4 grid grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900" class="my-0 py-0 px-4 grid grid-cols-6 z-10 md:grid-cols-7 lg:grid-cols-8 gap-4 border-b border-slate-100 dark:border-slate-700 sticky top-16 bg-white dark:bg-slate-900"
:class="{ draggable: onCategoryPage }" :class="{ draggable: onCategoryPage }"
> >
<div <div
@@ -0,0 +1,70 @@
<script setup>
// import { defineProps } from 'vue';
import PriorityIcon from './components/PriorityIcon.vue';
import StatusIcon from './components/StatusIcon.vue';
import InboxNameAndId from './components/InboxNameAndId.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
// const props = defineProps({
// notificationItem: {
// type: Object,
// default: () => {},
// },
// });
const assigneeMeta = {
thumbnail: '',
name: 'Michael Johnson',
};
const { thumbnail, name } = assigneeMeta || {};
const status = 'open';
const priority = 'high';
const inboxTypeMessage = 'Mentioned by Michael';
const inboxMessage = 'What is the best way to get started?';
const inbox = {
inbox_id: 16787,
inbox_name: 'Chatwoot Support',
};
</script>
<template>
<div
class="flex max-w-[360px] flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-500 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
>
<div class="flex relative items-center justify-between w-full">
<div
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" />
<div class="flex gap-2">
<PriorityIcon :priority="priority" />
<StatusIcon :status="status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[80%]">
<Thumbnail
v-if="assigneeMeta"
:src="thumbnail"
:username="name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-100 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ inboxTypeMessage }}<span v-if="inboxTypeMessage">:</span>
<span class="font-normal">{{ inboxMessage }}</span>
</span>
</div>
</div>
<span
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
10h ago
</span>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
inbox: {
type: Object,
default: () => {},
},
});
const { inbox } = props;
const { inbox_id: inboxId, inbox_name: inboxName } = inbox;
</script>
<template>
<div
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-600 divide-x divide-slate-100 dark:divide-slate-600 bg-none"
>
<div class="flex items-center gap-0.5 py-0.5 px-1.5">
<fluent-icon
class="text-slate-600 dark:text-slate-300"
icon="globe-desktop"
size="14"
/>
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ inboxName }}
</span>
</div>
<div class="flex items-center py-0.5 px-1.5">
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ inboxId }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
priority: {
type: String,
default: '',
},
});
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- High Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.HIGH"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#FFC291" />
</svg>
<!-- Low Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.LOW"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#DDDDDD" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
</svg>
<!-- Medium Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.MEDIUM"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#FFC291" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#FFC291" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#DDDDDD" />
</svg>
<!-- Urgent Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.URGENT"
class="h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="12" width="4" height="8" rx="1" fill="#E5484D" />
<rect x="10" y="8" width="4" height="12" rx="1" fill="#E5484D" />
<rect x="16" y="4" width="4" height="16" rx="1" fill="#E5484D" />
</svg>
</div>
</template>
@@ -0,0 +1,80 @@
<script setup>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
status: {
type: String,
default: '',
},
});
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Pending -->
<svg
v-if="props.status === CONVERSATION_STATUS.PENDING"
class="h-3.5 w-3.5"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.1 0.0449978V1.8558C4.5486 2.2986 1.8 5.328 1.8 9C1.8 12.9762 5.0238 16.2 9 16.2C10.6641 16.2 12.195 15.6357 13.4154 14.688L14.6961 15.9687C13.1445 17.2377 11.16 18 9 18C4.0293 18 0 13.9707 0 9C0 4.3335 3.5523 0.495898 8.1 0.0449978ZM17.955 9.9C17.775 11.7099 17.0604 13.3623 15.9687 14.6952L14.688 13.4154C15.462 12.4191 15.9804 11.2149 16.1442 9.9H17.9559H17.955ZM9.9018 0.0449978C14.1534 0.467098 17.5338 3.8484 17.9568 8.1H16.1451C15.7392 4.8438 13.158 2.2626 9.9018 1.8558V0.0440979V0.0449978Z"
class="fill-[#B9BBC6]"
/>
</svg>
<!-- Open -->
<svg
v-if="props.status === CONVERSATION_STATUS.OPEN"
class="h-3.5 w-3.5"
width="19"
height="19"
viewBox="0 0 19 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.375 18.875C4.19733 18.875 0 14.6776 0 9.5C0 4.32233 4.19733 0.125 9.375 0.125C14.5526 0.125 18.75 4.32233 18.75 9.5C18.75 14.6776 14.5526 18.875 9.375 18.875ZM9.375 17C13.5172 17 16.875 13.6422 16.875 9.5C16.875 5.35786 13.5172 2 9.375 2C5.23286 2 1.875 5.35786 1.875 9.5C1.875 13.6422 5.23286 17 9.375 17Z"
class="fill-[#ED8A5C]"
/>
</svg>
<!-- Snoozed -->
<svg
v-if="props.status === CONVERSATION_STATUS.SNOOZED"
class="h-3.5 w-3.5"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 18C4.0293 18 0 13.9707 0 9C0 4.0293 4.0293 0 9 0C13.9707 0 18 4.0293 18 9C18 13.9707 13.9707 18 9 18ZM2.9961 12.9825C3.58766 13.8676 4.36812 14.6105 5.28129 15.1577C6.19446 15.7049 7.21761 16.0428 8.27707 16.147C9.33652 16.2513 10.4059 16.1193 11.4082 15.7606C12.4105 15.4019 13.3208 14.8254 14.0736 14.0726C14.8263 13.3198 15.4027 12.4094 15.7613 11.4071C16.12 10.4047 16.2518 9.33532 16.1475 8.27588C16.0431 7.21644 15.7052 6.19332 15.1579 5.2802C14.6106 4.36707 13.8676 3.58668 12.9825 2.9952C13.3706 4.3796 13.383 5.84237 13.0186 7.23318C12.6542 8.62399 11.926 9.89269 10.9089 10.9089C9.89277 11.9258 8.62423 12.6539 7.23359 13.0183C5.84296 13.3828 4.38037 13.3704 2.9961 12.9825Z"
class="fill-[#0B68CB]"
/>
</svg>
<!-- Resolved -->
<svg
v-if="props.status === CONVERSATION_STATUS.RESOLVED"
class="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="3 3 17.92 17.92"
>
<path
d="M11.96 20.92C7.01152 20.92 3 16.9084 3 11.96C3 7.01152 7.01152 3 11.96 3C16.9084 3 20.92 7.01152 20.92 11.96C20.92 16.9084 16.9084 20.92 11.96 20.92ZM11.96 19.128C15.9188 19.128 19.128 15.9188 19.128 11.96C19.128 8.00122 15.9188 4.792 11.96 4.792C8.00122 4.792 4.792 8.00122 4.792 11.96C4.792 15.9188 8.00122 19.128 11.96 19.128Z"
class="fill-[#5BB98C]"
/>
<path
d="M11.9599 17.9333C15.2589 17.9333 17.9332 15.2589 17.9332 11.96C17.9332 8.66098 15.2589 5.98663 11.9599 5.98663C8.66092 5.98663 5.98657 8.66098 5.98657 11.96C5.98657 15.2589 8.66092 17.9333 11.9599 17.9333Z"
class="fill-[#5BB98C]"
/>
</svg>
</div>
</template>
@@ -56,7 +56,7 @@
<span <span
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex" class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
> >
{{ dynamicTime(notificationItem.created_at) }} {{ dynamicTime(notificationItem.last_activity_at) }}
</span> </span>
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@
<td> <td>
<div class="text-right timestamp--column"> <div class="text-right timestamp--column">
<span class="notification--created-at"> <span class="notification--created-at">
{{ dynamicTime(notificationItem.created_at) }} {{ dynamicTime(notificationItem.last_activity_at) }}
</span> </span>
</div> </div>
</td> </td>
@@ -11,6 +11,7 @@
<table-footer <table-footer
:current-page="Number(meta.currentPage)" :current-page="Number(meta.currentPage)"
:total-count="meta.count" :total-count="meta.count"
:page-size="15"
@page-change="onPageChange" @page-change="onPageChange"
/> />
</div> </div>
@@ -86,6 +86,30 @@
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label> </label>
</div> </div>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full"> <div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
<woot-submit-button <woot-submit-button
:disabled="isButtonDisabled" :disabled="isButtonDisabled"
@@ -124,6 +148,9 @@ export default {
attributeModel: 0, attributeModel: 0,
attributeType: 0, attributeType: 0,
attributeKey: '', attributeKey: '',
regexPattern: null,
regexCue: null,
regexEnabled: false,
models: ATTRIBUTE_MODELS, models: ATTRIBUTE_MODELS,
types: ATTRIBUTE_TYPES, types: ATTRIBUTE_TYPES,
values: [], values: [],
@@ -163,6 +190,12 @@ export default {
isAttributeTypeList() { isAttributeTypeList() {
return this.attributeType === 6; return this.attributeType === 6;
}, },
isAttributeTypeText() {
return this.attributeType === 0;
},
isRegexEnabled() {
return this.regexEnabled;
},
}, },
validations: { validations: {
@@ -201,11 +234,18 @@ export default {
onDisplayNameChange() { onDisplayNameChange() {
this.attributeKey = convertToAttributeSlug(this.displayName); this.attributeKey = convertToAttributeSlug(this.displayName);
}, },
toggleRegexEnabled() {
this.regexEnabled = !this.regexEnabled;
},
async addAttributes() { async addAttributes() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) { if (this.$v.$invalid) {
return; return;
} }
if (!this.regexEnabled) {
this.regexPattern = null;
this.regexCue = null;
}
try { try {
await this.$store.dispatch('attributes/create', { await this.$store.dispatch('attributes/create', {
attribute_display_name: this.displayName, attribute_display_name: this.displayName,
@@ -214,6 +254,10 @@ export default {
attribute_display_type: this.attributeType, attribute_display_type: this.attributeType,
attribute_key: this.attributeKey, attribute_key: this.attributeKey,
attribute_values: this.attributeListValues, attribute_values: this.attributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
regex_cue: this.regexCue,
}); });
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE'); this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
this.onClose(); this.onClose();
@@ -70,6 +70,30 @@
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }} {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label> </label>
</div> </div>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
</div> </div>
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full"> <div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
<woot-button :is-loading="isUpdating" :disabled="isButtonDisabled"> <woot-button :is-loading="isUpdating" :disabled="isButtonDisabled">
@@ -88,9 +112,10 @@ import { mapGetters } from 'vuex';
import { required, minLength } from 'vuelidate/lib/validators'; import { required, minLength } from 'vuelidate/lib/validators';
import { ATTRIBUTE_TYPES } from './constants'; import { ATTRIBUTE_TYPES } from './constants';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import customAttributeMixin from '../../../../mixins/customAttributeMixin';
export default { export default {
components: {}, components: {},
mixins: [alertMixin], mixins: [alertMixin, customAttributeMixin],
props: { props: {
selectedAttribute: { selectedAttribute: {
type: Object, type: Object,
@@ -106,6 +131,9 @@ export default {
displayName: '', displayName: '',
description: '', description: '',
attributeType: 0, attributeType: 0,
regexPattern: null,
regexCue: null,
regexEnabled: false,
types: ATTRIBUTE_TYPES, types: ATTRIBUTE_TYPES,
show: true, show: true,
attributeKey: '', attributeKey: '',
@@ -152,6 +180,7 @@ export default {
this.isAttributeTypeList && this.isTouched && this.values.length === 0 this.isAttributeTypeList && this.isTouched && this.values.length === 0
); );
}, },
pageTitle() { pageTitle() {
return `${this.$t('ATTRIBUTES_MGMT.EDIT.TITLE')} - ${ return `${this.$t('ATTRIBUTES_MGMT.EDIT.TITLE')} - ${
this.selectedAttribute.attribute_display_name this.selectedAttribute.attribute_display_name
@@ -173,6 +202,12 @@ export default {
isAttributeTypeList() { isAttributeTypeList() {
return this.attributeType === 6; return this.attributeType === 6;
}, },
isAttributeTypeText() {
return this.attributeType === 0;
},
isRegexEnabled() {
return this.regexEnabled;
},
}, },
mounted() { mounted() {
this.setFormValues(); this.setFormValues();
@@ -189,10 +224,16 @@ export default {
this.$refs.tagInput.$el.focus(); this.$refs.tagInput.$el.focus();
}, },
setFormValues() { setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern
? this.getRegexp(this.selectedAttribute.regex_pattern).source
: null;
this.displayName = this.selectedAttribute.attribute_display_name; this.displayName = this.selectedAttribute.attribute_display_name;
this.description = this.selectedAttribute.attribute_description; this.description = this.selectedAttribute.attribute_description;
this.attributeType = this.selectedAttributeType; this.attributeType = this.selectedAttributeType;
this.attributeKey = this.selectedAttribute.attribute_key; this.attributeKey = this.selectedAttribute.attribute_key;
this.regexPattern = regexPattern;
this.regexCue = this.selectedAttribute.regex_cue;
this.regexEnabled = regexPattern != null;
this.values = this.setAttributeListValue; this.values = this.setAttributeListValue;
}, },
async editAttributes() { async editAttributes() {
@@ -200,14 +241,21 @@ export default {
if (this.$v.$invalid) { if (this.$v.$invalid) {
return; return;
} }
if (!this.regexEnabled) {
this.regexPattern = null;
this.regexCue = null;
}
try { try {
await this.$store.dispatch('attributes/update', { await this.$store.dispatch('attributes/update', {
id: this.selectedAttribute.id, id: this.selectedAttribute.id,
attribute_description: this.description, attribute_description: this.description,
attribute_display_name: this.displayName, attribute_display_name: this.displayName,
attribute_values: this.updatedAttributeListValues, attribute_values: this.updatedAttributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
regex_cue: this.regexCue,
}); });
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE'); this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
this.onClose(); this.onClose();
} catch (error) { } catch (error) {
@@ -218,6 +266,9 @@ export default {
this.showAlert(this.alertMessage); this.showAlert(this.alertMessage);
} }
}, },
toggleRegexEnabled() {
this.regexEnabled = !this.regexEnabled;
},
}, },
}; };
</script> </script>
@@ -12,7 +12,7 @@ jest.mock('../../../utils/api', () => ({
getHeaderExpiry: jest.fn(), getHeaderExpiry: jest.fn(),
})); }));
jest.mock('js-cookie', () => ({ jest.mock('js-cookie', () => ({
getJSON: jest.fn(), get: jest.fn(),
})); }));
const commit = jest.fn(); const commit = jest.fn();
@@ -155,14 +155,14 @@ describe('#actions', () => {
describe('#setUser', () => { describe('#setUser', () => {
it('sends correct actions if user is logged in', async () => { it('sends correct actions if user is logged in', async () => {
Cookies.getJSON.mockImplementation(() => true); Cookies.get.mockImplementation(() => true);
actions.setUser({ commit, dispatch }); actions.setUser({ commit, dispatch });
expect(commit.mock.calls).toEqual([]); expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([['validityCheck']]); expect(dispatch.mock.calls).toEqual([['validityCheck']]);
}); });
it('sends correct actions if user is not logged in', async () => { it('sends correct actions if user is not logged in', async () => {
Cookies.getJSON.mockImplementation(() => false); Cookies.get.mockImplementation(() => false);
actions.setUser({ commit, dispatch }); actions.setUser({ commit, dispatch });
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.CLEAR_USER], [types.default.CLEAR_USER],
+1 -1
View File
@@ -27,7 +27,7 @@ export const getHeaderExpiry = response =>
export const setAuthCredentials = response => { export const setAuthCredentials = response => {
const expiryDate = getHeaderExpiry(response); const expiryDate = getHeaderExpiry(response);
Cookies.set('cw_d_session_info', response.headers, { Cookies.set('cw_d_session_info', JSON.stringify(response.headers), {
expires: differenceInDays(expiryDate, new Date()), expires: differenceInDays(expiryDate, new Date()),
}); });
setUser(response.data.data, expiryDate); setUser(response.data.data, expiryDate);
@@ -84,6 +84,10 @@ export default {
}, },
}, },
beforeDestroy() {
clearTimeout(this.typingTimer);
},
methods: { methods: {
onChange(e) { onChange(e) {
this.$emit('input', e.target.value); this.$emit('input', e.target.value);
+7
View File
@@ -34,5 +34,12 @@ export const setCookieWithDomain = (
domain: baseDomain, domain: baseDomain,
}; };
// if type of value is object, stringify it
// this is because js-cookies 3.0 removed builtin json support
// ref: https://github.com/js-cookie/js-cookie/releases/tag/v3.0.0
if (typeof value === 'object') {
value = JSON.stringify(value);
}
Cookies.set(name, value, cookieOptions); Cookies.set(name, value, cookieOptions);
}; };
@@ -107,6 +107,26 @@ describe('setCookieWithDomain', () => {
); );
}); });
it('should stringify the cookie value when setting', () => {
setCookieWithDomain(
'myCookie',
{ value: 'cookieValue' },
{
baseDomain: 'example.com',
}
);
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
JSON.stringify({ value: 'cookieValue' }),
expect.objectContaining({
expires: 365,
sameSite: 'Lax',
domain: 'example.com',
})
);
});
it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => { it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => {
setCookieWithDomain('myCookie', 'cookieValue', { setCookieWithDomain('myCookie', 'cookieValue', {
expires: 7, expires: 7,
@@ -154,6 +154,7 @@
"person-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11zm-5.477 2a6.47 6.47 0 0 0-.709 1.5H4.253a.749.749 0 0 0-.75.75v.577c0 .535.192 1.053.54 1.46c1.253 1.469 3.22 2.214 5.957 2.214c.597 0 1.157-.035 1.68-.106c.246.495.553.954.912 1.367c-.795.16-1.66.24-2.592.24c-3.146 0-5.532-.906-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.578A2.249 2.249 0 0 1 4.253 14h7.77zm5.477 0l-.09.008a.5.5 0 0 0-.402.402L17 14.5V17h-2.496l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17L17 20.5l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402L18 20.5V18h2.504l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.402-.402l-.09-.008H18L18 14.5l-.008-.09a.5.5 0 0 0-.402-.402L17.5 14zM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z", "person-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11zm-5.477 2a6.47 6.47 0 0 0-.709 1.5H4.253a.749.749 0 0 0-.75.75v.577c0 .535.192 1.053.54 1.46c1.253 1.469 3.22 2.214 5.957 2.214c.597 0 1.157-.035 1.68-.106c.246.495.553.954.912 1.367c-.795.16-1.66.24-2.592.24c-3.146 0-5.532-.906-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.578A2.249 2.249 0 0 1 4.253 14h7.77zm5.477 0l-.09.008a.5.5 0 0 0-.402.402L17 14.5V17h-2.496l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17L17 20.5l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402L18 20.5V18h2.504l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.402-.402l-.09-.008H18L18 14.5l-.008-.09a.5.5 0 0 0-.402-.402L17.5 14zM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z",
"person-assign-outline": "M11.313 15.5a6.471 6.471 0 0 1 .709-1.5h-7.77a2.249 2.249 0 0 0-2.249 2.25v.577c0 .892.319 1.756.899 2.435c1.566 1.834 3.952 2.74 7.098 2.74c.931 0 1.796-.08 2.592-.24a6.51 6.51 0 0 1-.913-1.366c-.524.07-1.083.105-1.68.105c-2.737 0-4.703-.745-5.957-2.213a2.25 2.25 0 0 1-.539-1.461v-.578a.75.75 0 0 1 .75-.749h7.06ZM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7ZM23 17.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Zm-4.647-2.853a.5.5 0 0 0-.707.707L19.293 17H15a.5.5 0 1 0 0 1h4.293l-1.647 1.647a.5.5 0 0 0 .707.707l2.5-2.5a.497.497 0 0 0 .147-.345V17.5a.498.498 0 0 0-.15-.357l-2.497-2.496Z", "person-assign-outline": "M11.313 15.5a6.471 6.471 0 0 1 .709-1.5h-7.77a2.249 2.249 0 0 0-2.249 2.25v.577c0 .892.319 1.756.899 2.435c1.566 1.834 3.952 2.74 7.098 2.74c.931 0 1.796-.08 2.592-.24a6.51 6.51 0 0 1-.913-1.366c-.524.07-1.083.105-1.68.105c-2.737 0-4.703-.745-5.957-2.213a2.25 2.25 0 0 1-.539-1.461v-.578a.75.75 0 0 1 .75-.749h7.06ZM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7ZM23 17.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Zm-4.647-2.853a.5.5 0 0 0-.707.707L19.293 17H15a.5.5 0 1 0 0 1h4.293l-1.647 1.647a.5.5 0 0 0 .707.707l2.5-2.5a.497.497 0 0 0 .147-.345V17.5a.498.498 0 0 0-.15-.357l-2.497-2.496Z",
"person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z", "person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",
"person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z",
"play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z", "play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z",
"power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z", "power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z",
"quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z", "quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z",
+1 -1
View File
@@ -3,7 +3,7 @@ import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper'; import { frontendURL } from 'dashboard/helper/URLHelper';
export const hasAuthCookie = () => { export const hasAuthCookie = () => {
return !!Cookies.getJSON('cw_d_session_info'); return !!Cookies.get('cw_d_session_info');
}; };
const getSSOAccountPath = ({ ssoAccountId, user }) => { const getSSOAccountPath = ({ ssoAccountId, user }) => {
@@ -10,10 +10,10 @@ jest.mock('dashboard/store/utils/api', () => ({
jest.mock('../CommonHelper', () => ({ replaceRouteWithReload: jest.fn() })); jest.mock('../CommonHelper', () => ({ replaceRouteWithReload: jest.fn() }));
jest.mock('js-cookie', () => ({ jest.mock('js-cookie', () => ({
getJSON: jest.fn(), get: jest.fn(),
})); }));
Cookies.getJSON.mockReturnValueOnce(true).mockReturnValue(false); Cookies.get.mockReturnValueOnce(true).mockReturnValue(false);
describe('#validateRouteAccess', () => { describe('#validateRouteAccess', () => {
it('reset cookies and continues to the login page if the SSO parameters are present', () => { it('reset cookies and continues to the login page if the SSO parameters are present', () => {
validateRouteAccess( validateRouteAccess(
@@ -40,7 +40,7 @@ describe('#validateRouteAccess', () => {
}); });
it('redirects to dashboard if auth cookie is present', () => { it('redirects to dashboard if auth cookie is present', () => {
Cookies.getJSON.mockImplementation(() => true); Cookies.get.mockImplementation(() => true);
validateRouteAccess({ name: 'login' }, next); validateRouteAccess({ name: 'login' }, next);
expect(clearBrowserSessionCookies).not.toHaveBeenCalled(); expect(clearBrowserSessionCookies).not.toHaveBeenCalled();
expect(replaceRouteWithReload).toHaveBeenCalledWith('/app/'); expect(replaceRouteWithReload).toHaveBeenCalledWith('/app/');
@@ -28,6 +28,9 @@
isValidPhoneNumber: $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR'), isValidPhoneNumber: $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR'),
email: $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'), email: $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'),
required: $t('PRE_CHAT_FORM.REQUIRED'), required: $t('PRE_CHAT_FORM.REQUIRED'),
matches: item.regex_cue
? item.regex_cue
: $t('PRE_CHAT_FORM.REGEX_ERROR'),
}" }"
:has-error-in-phone-input="hasErrorInPhoneInput" :has-error-in-phone-input="hasErrorInPhoneInput"
/> />
@@ -68,13 +71,20 @@ import { isEmptyObject } from 'widget/helpers/utils';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin'; import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import customAttributeMixin from '../../../dashboard/mixins/customAttributeMixin';
export default { export default {
components: { components: {
CustomButton, CustomButton,
Spinner, Spinner,
}, },
mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin], mixins: [
routerMixin,
darkModeMixin,
messageFormatterMixin,
configMixin,
customAttributeMixin,
],
props: { props: {
options: { options: {
type: Object, type: Object,
@@ -235,30 +245,37 @@ export default {
} }
return this.formValues[name] || null; return this.formValues[name] || null;
}, },
getValidation({ type, name }) { getValidation({ type, name, field_type, regex_pattern }) {
let regex = regex_pattern ? this.getRegexp(regex_pattern) : null;
const validations = { const validations = {
emailAddress: 'email', emailAddress: 'email',
phoneNumber: 'startsWithPlus|isValidPhoneNumber', phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],
url: 'url', url: 'url',
date: 'date', date: 'date',
text: null, text: null,
select: null, select: null,
number: null, number: null,
checkbox: false, checkbox: false,
contact_attribute: regex ? [['matches', regex]] : null,
conversation_attribute: regex ? [['matches', regex]] : null,
}; };
const validationKeys = Object.keys(validations); const validationKeys = Object.keys(validations);
const isRequired = this.isContactFieldRequired(name); const isRequired = this.isContactFieldRequired(name);
const validation = isRequired ? 'bail|required' : 'bail|optional'; const validation = isRequired
? ['bail', 'required']
: ['bail', 'optional'];
if (validationKeys.includes(name) || validationKeys.includes(type)) { if (
const validationType = validations[type] || validations[name]; validationKeys.includes(name) ||
const validationString = validationType validationKeys.includes(type) ||
? `${validation}|${validationType}` validationKeys.includes(field_type)
: validation; ) {
return validationString; const validationType =
validations[type] || validations[name] || validations[field_type];
return validationType ? validation.concat(validationType) : validation;
} }
return ''; return [];
}, },
findFieldType(type) { findFieldType(type) {
if (type === 'link') { if (type === 'link') {
@@ -24,6 +24,11 @@ export default {
default: () => {}, default: () => {},
}, },
}, },
data() {
return {
timeOutID: null,
};
},
computed: { computed: {
replyToAttachment() { replyToAttachment() {
if (!this.replyTo?.attachments?.length) { if (!this.replyTo?.attachments?.length) {
@@ -34,6 +39,10 @@ export default {
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`); return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
}, },
}, },
beforeDestroy() {
clearTimeout(this.timeOutID);
},
methods: { methods: {
navigateTo(id) { navigateTo(id) {
const elementId = `cwmsg-${id}`; const elementId = `cwmsg-${id}`;
@@ -41,7 +50,7 @@ export default {
const el = document.getElementById(elementId); const el = document.getElementById(elementId);
el.scrollIntoView(); el.scrollIntoView();
el.classList.add('bg-slate-100', 'dark:bg-slate-900'); el.classList.add('bg-slate-100', 'dark:bg-slate-900');
setTimeout(() => { this.timeOutID = setTimeout(() => {
el.classList.remove('bg-slate-100', 'dark:bg-slate-900'); el.classList.remove('bg-slate-100', 'dark:bg-slate-900');
}, 500); }, 500);
}); });
@@ -60,6 +60,7 @@ export default {
scrollPosition: 0, scrollPosition: 0,
ticking: true, ticking: true,
disableBranding: window.chatwootWebChannel.disableBranding || false, disableBranding: window.chatwootWebChannel.disableBranding || false,
requestID: null,
}; };
}, },
computed: { computed: {
@@ -120,6 +121,7 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
this.$el.removeEventListener('scroll', this.updateScrollPosition); this.$el.removeEventListener('scroll', this.updateScrollPosition);
cancelAnimationFrame(this.requestID);
}, },
methods: { methods: {
closeWindow() { closeWindow() {
@@ -128,7 +130,7 @@ export default {
updateScrollPosition(event) { updateScrollPosition(event) {
this.scrollPosition = event.target.scrollTop; this.scrollPosition = event.target.scrollTop;
if (!this.ticking) { if (!this.ticking) {
window.requestAnimationFrame(() => { this.requestID = window.requestAnimationFrame(() => {
this.ticking = false; this.ticking = false;
}); });
+2 -1
View File
@@ -80,7 +80,8 @@
}, },
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation", "CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
"IS_REQUIRED": "is required", "IS_REQUIRED": "is required",
"REQUIRED": "Required" "REQUIRED": "Required",
"REGEX_ERROR": "Please provide a valid input"
}, },
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit", "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"CHAT_FORM": { "CHAT_FORM": {
+4 -2
View File
@@ -1,6 +1,8 @@
class ApplicationJob < ActiveJob::Base class ApplicationJob < ActiveJob::Base
# https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html # https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
discard_on ActiveJob::DeserializationError do |_job, error| discard_on ActiveJob::DeserializationError do |job, error|
Rails.logger.error("Skipping job because of ActiveJob::DeserializationError (#{error.message})") Rails.logger.info("Skipping #{job.class} with #{
job.instance_variable_get(:@serialized_arguments)
} because of ActiveJob::DeserializationError (#{error.message})")
end end
end end
+15 -2
View File
@@ -8,8 +8,12 @@ class DataImportJob < ApplicationJob
def perform(data_import) def perform(data_import)
@data_import = data_import @data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account) @contact_manager = DataImport::ContactManager.new(@data_import.account)
process_import_file begin
send_import_notification_to_admin process_import_file
send_import_notification_to_admin
rescue CSV::MalformedCSVError => e
handle_csv_error(e)
end
end end
private private
@@ -83,7 +87,16 @@ class DataImportJob < ApplicationJob
end end
end end
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
@data_import.update!(status: :failed)
send_import_failed_notification_to_admin
end
def send_import_notification_to_admin def send_import_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end end
def send_import_failed_notification_to_admin
AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
end end
+81 -48
View File
@@ -1,17 +1,22 @@
require 'net/imap' require 'net/imap'
class Inboxes::FetchImapEmailsJob < ApplicationJob class Inboxes::FetchImapEmailsJob < MutexApplicationJob
queue_as :scheduled_jobs queue_as :scheduled_jobs
def perform(channel) def perform(channel)
return unless should_fetch_email?(channel) return unless should_fetch_email?(channel)
process_email_for_channel(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
rescue *ExceptionList::IMAP_EXCEPTIONS => e rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error e Rails.logger.error e
channel.authorization_error! channel.authorization_error!
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
Rails.logger.error e Rails.logger.error e
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end end
@@ -23,7 +28,6 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end end
def process_email_for_channel(channel) def process_email_for_channel(channel)
# fetching email for microsoft provider
if channel.microsoft? if channel.microsoft?
fetch_mail_for_ms_provider(channel) fetch_mail_for_ms_provider(channel)
else else
@@ -34,32 +38,66 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end end
def fetch_mail_for_channel(channel) def fetch_mail_for_channel(channel)
imap_inbox = authenticated_imap_inbox(channel, channel.imap_password, 'PLAIN') imap_client = build_imap_client(channel, channel.imap_password, 'PLAIN')
last_email_time = DateTime.parse(Net::IMAP.format_datetime(last_email_time(channel)))
received_mails(imap_inbox).each do |message_id| message_ids_with_seq = fetch_message_ids_with_sequence(imap_client, channel)
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822'] message_ids_with_seq.each do |message_id_with_seq|
process_message_id(channel, imap_client, message_id_with_seq)
mail_info_logger(channel, inbound_mail, message_id)
next if email_already_present?(channel, inbound_mail, last_email_time)
process_mail(inbound_mail, channel)
end end
end end
def email_already_present?(channel, inbound_mail, _last_email_time) def process_message_id(channel, imap_client, message_id_with_seq)
channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present? seq_no, message_id = message_id_with_seq
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(channel, inbound_mail, seq_no)
process_mail(inbound_mail, channel)
end end
def received_mails(imap_inbox) # Sends a FETCH command to retrieve data associated with a message in the mailbox.
imap_inbox.search(['BEFORE', tomorrow, 'SINCE', yesterday]) # 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.FIELDS (MESSAGE-ID)]')
# .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.FIELDS (MESSAGE-ID)]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
end
end
message_ids_with_seq
end end
def processed_email?(current_email, last_email_time) # Sends a SEARCH command to search the mailbox for messages that were
return current_email.date < last_email_time if current_email.date.present? # created between yesterday and today and returns message sequence numbers.
# Return <message set>
false def fetch_available_mail_sequence_numbers(imap_client)
imap_client.search(['BEFORE', tomorrow, 'SINCE', yesterday])
end end
def fetch_mail_for_ms_provider(channel) def fetch_mail_for_ms_provider(channel)
@@ -69,16 +107,15 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
return unless access_token return unless access_token
imap_inbox = authenticated_imap_inbox(channel, access_token, 'XOAUTH2') imap_client = build_imap_client(channel, access_token, 'XOAUTH2')
process_mails(imap_client, channel)
process_mails(imap_inbox, channel)
end end
def process_mails(imap_inbox, channel) def process_mails(imap_client, channel)
received_mails(imap_inbox).each do |message_id| fetch_available_mail_sequence_numbers(imap_client).each do |seq_no|
inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822'] inbound_mail = Mail.read_from_string imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
mail_info_logger(channel, inbound_mail, message_id) mail_info_logger(channel, inbound_mail, seq_no)
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present? next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
@@ -86,38 +123,26 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end end
end end
def mail_info_logger(channel, inbound_mail, message_id) def mail_info_logger(channel, inbound_mail, uid)
return if Rails.env.test? return if Rails.env.test?
Rails.logger.info(" Rails.logger.info("
#{channel.provider} Email id: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}, message_id: #{message_id}") #{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{uid}")
end end
def authenticated_imap_inbox(channel, access_token, auth_method) def build_imap_client(channel, access_token, auth_method)
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true) imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
imap.authenticate(auth_method, channel.imap_login, access_token) imap.authenticate(auth_method, channel.imap_login, access_token)
imap.select('INBOX') imap.select('INBOX')
imap imap
end end
def last_email_time(channel) def email_already_present?(channel, message_id)
# we are only checking for emails in last 2 day channel.inbox.messages.find_by(source_id: message_id).present?
last_email_incoming_message = channel.inbox.messages.incoming.where('messages.created_at >= ?', 2.days.ago).last
if last_email_incoming_message.present?
time = last_email_incoming_message.content_attributes['email']['date']
time ||= last_email_incoming_message.created_at.to_s
end
time ||= 1.hour.ago.to_s
DateTime.parse(time)
end end
def yesterday def build_mail_from_string(raw_email_content)
(Time.zone.today - 1).strftime('%d-%b-%Y') Mail.read_from_string(raw_email_content)
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
end end
def process_mail(inbound_mail, channel) def process_mail(inbound_mail, channel)
@@ -132,4 +157,12 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
def valid_access_token(channel) def valid_access_token(channel)
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
end
end end
@@ -12,7 +12,9 @@ class Inboxes::UpdateWidgetPreChatCustomFieldsJob < ApplicationJob
pre_chat_field.deep_merge({ pre_chat_field.deep_merge({
'label' => custom_attribute['attribute_display_name'], 'label' => custom_attribute['attribute_display_name'],
'placeholder' => custom_attribute['attribute_display_name'], 'placeholder' => custom_attribute['attribute_display_name'],
'values' => custom_attribute['attribute_values'] 'values' => custom_attribute['attribute_values'],
'regex_pattern' => custom_attribute['regex_pattern'],
'regex_cue' => custom_attribute['regex_cue']
}) })
end end
web_widget.save! web_widget.save!
+27 -13
View File
@@ -14,22 +14,36 @@
class MutexApplicationJob < ApplicationJob class MutexApplicationJob < ApplicationJob
class LockAcquisitionError < StandardError; end class LockAcquisitionError < StandardError; end
def with_lock(key_format, *args) def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
lock_key = format(key_format, *args)
lock_manager = Redis::LockManager.new lock_manager = Redis::LockManager.new
if lock_manager.locked?(lock_key)
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
end
begin begin
lock_manager.lock(lock_key) if lock_manager.lock(lock_key, timeout)
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}" log_attempt(lock_key, executions)
yield yield
ensure # release the lock after the block has been executed
# Ensure that the lock is released even if there's an error in processing lock_manager.unlock(lock_key)
lock_manager.unlock(lock_key) else
handle_failed_lock_acquisition(lock_key)
end
rescue StandardError => e
handle_error(e, lock_manager, lock_key)
end end
end end
private
def log_attempt(lock_key, executions)
Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}"
end
def handle_error(err, lock_manager, lock_key)
lock_manager.unlock(lock_key) unless err.is_a?(LockAcquisitionError)
raise err
end
def handle_failed_lock_acquisition(lock_key)
Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}"
raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}"
end
end end
@@ -0,0 +1,8 @@
class Notification::RemoveOldNotificationJob < ApplicationJob
queue_as :low
def perform
Notification.where('created_at < ?', 1.month.ago)
.find_each(batch_size: 1000, &:delete)
end
end
+2 -1
View File
@@ -3,7 +3,8 @@ class SendOnSlackJob < MutexApplicationJob
retry_on LockAcquisitionError, wait: 1.second, attempts: 8 retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook) def perform(message, hook)
with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
with_lock(key) do
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
end end
end end
+3
View File
@@ -19,5 +19,8 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates # Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to clear notifications which are older than 1 month
Notification::RemoveOldNotificationJob.perform_later
end end
end end
+2 -1
View File
@@ -5,7 +5,8 @@ class Webhooks::FacebookEventsJob < MutexApplicationJob
def perform(message) def perform(message)
response = ::Integrations::Facebook::MessageParser.new(message) response = ::Integrations::Facebook::MessageParser.new(message)
with_lock(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id) do key = format(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id)
with_lock(key) do
process_message(response) process_message(response)
end end
end end
+2 -1
View File
@@ -12,7 +12,8 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
def perform(entries) def perform(entries)
@entries = entries @entries = entries
with_lock(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id) do key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
with_lock(key) do
process_entries(entries) process_entries(entries)
end end
end end
+5 -8
View File
@@ -6,11 +6,12 @@ class ReplyMailbox < ApplicationMailbox
EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
before_processing :conversation_uuid_from_to_address, before_processing :conversation_uuid_from_to_address,
:find_relative_conversation, :find_relative_conversation
:verify_decoded_params,
:decorate_mail
def process def process
return if @conversation.blank?
decorate_mail
create_message create_message
add_attachments_to_message add_attachments_to_message
end end
@@ -78,12 +79,8 @@ class ReplyMailbox < ApplicationMailbox
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank? find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
end end
def verify_decoded_params
raise 'Conversation uuid not found' if conversation_uuid.nil?
end
def validate_resource(resource) def validate_resource(resource)
raise "Email conversation with uuid: #{conversation_uuid} not found" if resource.nil? Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
resource resource
end end
@@ -51,6 +51,15 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return send_mail_with_liquid(to: admin_emails, subject: subject) and return
end end
def contact_import_failed
return unless smtp_config_set_or_development?
subject = 'Contact Import Failed'
@meta = {}
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
def contact_export_complete(file_url) def contact_export_complete(file_url)
return unless smtp_config_set_or_development? return unless smtp_config_set_or_development?
+18 -10
View File
@@ -149,24 +149,32 @@ class Channel::Telegram < ApplicationRecord
}) })
end end
def convert_markdown_to_telegram(text) def convert_markdown_to_telegram_html(text)
## supported characters : https://core.telegram.org/bots/api#markdown-style # ref: https://core.telegram.org/bots/api#html-style
## To implement MarkdownV2, we will need to do a lot of escaping
# Convert bold - double asterisks to single asterisk in Telegram # escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
# Chatwoot uses double asterisks for bold, while telegram used single asterisk text = CGI.escapeHTML(text.gsub("\n", '<br>'))
text.gsub!(/\*\*(.*?)\*\*/, '*\1*')
text # convert markdown to html
html = CommonMarker.render_html(text).strip
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# converted escaped br tags to \n
stripped_html.gsub('&lt;br&gt;', "\n")
end end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil) def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
text_to_md = convert_markdown_to_telegram(text) text_payload = convert_markdown_to_telegram_html(text)
HTTParty.post("#{telegram_api_url}/sendMessage", HTTParty.post("#{telegram_api_url}/sendMessage",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
text: text_to_md, text: text_payload,
reply_markup: reply_markup, reply_markup: reply_markup,
parse_mode: 'Markdown', parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}) })
end end
+1 -1
View File
@@ -35,7 +35,7 @@ class Channel::WebWidget < ApplicationRecord
{ pre_chat_form_options: [:pre_chat_message, :require_email, { pre_chat_form_options: [:pre_chat_message, :require_email,
{ pre_chat_fields: { pre_chat_fields:
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required, [:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
:locale, { values: [] }] }] }, :locale, { values: [] }, :regex_pattern, :regex_cue] }] },
{ selected_feature_flags: [] }].freeze { selected_feature_flags: [] }].freeze
before_validation :validate_pre_chat_options before_validation :validate_pre_chat_options
@@ -2,7 +2,7 @@ module AccountCacheRevalidator
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
after_commit :update_account_cache, on: [:create, :update] after_commit :update_account_cache, on: [:create, :update, :destroy]
end end
def update_account_cache def update_account_cache
+11 -9
View File
@@ -4,6 +4,8 @@ module CacheKeys
include CacheKeysHelper include CacheKeysHelper
include Events::Types include Events::Types
CACHE_KEYS_EXPIRY = 72.hours
included do included do
class_attribute :cacheable_models class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team] self.cacheable_models = [Label, Inbox, Team]
@@ -18,26 +20,26 @@ module CacheKeys
keys keys
end end
def invalidate_cache_key_for(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
Redis::Alfred.delete(prefixed_cache_key)
dispatch_cache_update_event
end
def update_cache_key(key) def update_cache_key(key)
prefixed_cache_key = get_prefixed_cache_key(id, key) update_cache_key_for_account(id, key)
Redis::Alfred.set(prefixed_cache_key, Time.now.utc.to_i)
dispatch_cache_update_event dispatch_cache_update_event
end end
def reset_cache_keys def reset_cache_keys
self.class.cacheable_models.each do |model| self.class.cacheable_models.each do |model|
invalidate_cache_key_for(model.name.underscore) update_cache_key_for_account(id, model.name.underscore)
end end
dispatch_cache_update_event
end end
private private
def update_cache_key_for_account(account_id, key)
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
Redis::Alfred.setex(prefixed_cache_key, Time.now.utc.to_i, CACHE_KEYS_EXPIRY)
end
def dispatch_cache_update_event def dispatch_cache_update_event
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self) Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
end end
+7
View File
@@ -6,10 +6,15 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# additional_attributes :jsonb # additional_attributes :jsonb
# contact_type :integer default("visitor")
# country_code :string default("")
# custom_attributes :jsonb # custom_attributes :jsonb
# email :string # email :string
# identifier :string # identifier :string
# last_activity_at :datetime # last_activity_at :datetime
# last_name :string default("")
# location :string default("")
# middle_name :string default("")
# name :string default("") # name :string default("")
# phone_number :string # phone_number :string
# created_at :datetime not null # created_at :datetime not null
@@ -55,6 +60,8 @@ class Contact < ApplicationRecord
after_update_commit :dispatch_update_event after_update_commit :dispatch_update_event
after_destroy_commit :dispatch_destroy_event after_destroy_commit :dispatch_destroy_event
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
scope :order_on_last_activity_at, lambda { |direction| scope :order_on_last_activity_at, lambda { |direction|
order( order(
Arel::Nodes::SqlLiteral.new( Arel::Nodes::SqlLiteral.new(
@@ -10,6 +10,8 @@
# attribute_model :integer default("conversation_attribute") # attribute_model :integer default("conversation_attribute")
# attribute_values :jsonb # attribute_values :jsonb
# default_value :integer # default_value :integer
# regex_cue :string
# regex_pattern :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint # account_id :bigint
+4 -1
View File
@@ -33,9 +33,12 @@ class DashboardApp < ApplicationRecord
'required' => %w[url type], 'required' => %w[url type],
'properties' => { 'properties' => {
'type' => { 'enum': ['frame'] }, 'type' => { 'enum': ['frame'] },
'url' => { :type => 'string', 'format' => 'uri' } 'url' => { '$ref' => '#/definitions/saneUrl' }
} }
}, },
'definitions' => {
'saneUrl' => { 'format' => 'uri', 'pattern' => '^https?://' }
},
'additionalProperties' => false, 'additionalProperties' => false,
'minItems' => 1 'minItems' => 1
} }
+1 -1
View File
@@ -66,7 +66,7 @@ class Inbox < ApplicationRecord
has_many :inbox_members, dependent: :destroy_async has_many :inbox_members, dependent: :destroy_async
has_many :members, through: :inbox_members, source: :user has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy_async has_many :conversations, dependent: :destroy_async
has_many :messages, through: :conversations has_many :messages, dependent: :destroy_async
has_one :agent_bot_inbox, dependent: :destroy_async has_one :agent_bot_inbox, dependent: :destroy_async
has_one :agent_bot, through: :agent_bot_inbox has_one :agent_bot, through: :agent_bot_inbox
+1
View File
@@ -60,6 +60,7 @@ class Notification < ApplicationRecord
secondary_actor: secondary_actor&.push_event_data, secondary_actor: secondary_actor&.push_event_data,
user: user&.push_event_data, user: user&.push_event_data,
created_at: created_at.to_i, created_at: created_at.to_i,
last_activity_at: last_activity_at.to_i,
account_id: account_id account_id: account_id
} }
@@ -0,0 +1,56 @@
class AutomationRules::ConditionValidationService
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def initialize(rule)
@rule = rule
@account = rule.account
file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
end
def perform
@rule.conditions.each do |condition|
return false unless valid_condition?(condition)
end
true
end
private
def valid_condition?(condition)
key = condition['attribute_key']
conversation_filter = @conversation_filters[key]
contact_filter = @contact_filters[key]
message_filter = @message_filters[key]
if conversation_filter || contact_filter || message_filter
operation_valid?(condition, conversation_filter || contact_filter || message_filter)
else
custom_attribute_present?(key, condition['custom_attribute_type'])
end
end
def operation_valid?(condition, filter)
filter_operator = condition['filter_operator']
# attribute changed is a special case
return true if filter_operator == 'attribute_changed'
filter['filter_operators'].include?(filter_operator)
end
def custom_attribute_present?(attribute_key, attribute_model)
attribute_model = attribute_model.presence || self.class::ATTRIBUTE_MODEL
@account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key).present?
end
end
@@ -5,19 +5,25 @@ class AutomationRules::ConditionsFilterService < FilterService
def initialize(rule, conversation = nil, options = {}) def initialize(rule, conversation = nil, options = {})
super([], nil) super([], nil)
# assign rule, conversation and account to instance variables
@rule = rule @rule = rule
@conversation = conversation @conversation = conversation
@account = conversation.account @account = conversation.account
# setup filters from json file
file = File.read('./lib/filters/filter_keys.json') file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file) @filters = JSON.parse(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
@options = options @options = options
@changed_attributes = options[:changed_attributes] @changed_attributes = options[:changed_attributes]
end end
def perform def perform
@conversation_filters = @filters['conversations'] return false unless rule_valid?
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
@attribute_changed_query_filter = [] @attribute_changed_query_filter = []
@rule.conditions.each_with_index do |query_hash, current_index| @rule.conditions.each_with_index do |query_hash, current_index|
@@ -36,6 +42,14 @@ class AutomationRules::ConditionsFilterService < FilterService
false false
end end
def rule_valid?
is_valid = AutomationRules::ConditionValidationService.new(@rule).perform
Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid
is_valid
end
def filter_operation(query_hash, current_index) def filter_operation(query_hash, current_index)
if query_hash[:filter_operator] == 'starts_with' if query_hash[:filter_operator] == 'starts_with'
@filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%" @filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%"
@@ -7,7 +7,12 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
def perform_reply def perform_reply
send_message_to_facebook fb_text_message_params if message.content.present? send_message_to_facebook fb_text_message_params if message.content.present?
send_message_to_facebook fb_attachment_message_params if message.attachments.present?
if message.attachments.present?
message.attachments.each do |attachment|
send_message_to_facebook fb_attachment_message_params(attachment)
end
end
rescue Facebook::Messenger::FacebookError => e rescue Facebook::Messenger::FacebookError => e
# TODO : handle specific errors or else page will get disconnected # TODO : handle specific errors or else page will get disconnected
handle_facebook_error(e) handle_facebook_error(e)
@@ -41,8 +46,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
"#{error_code} - #{error_message}" "#{error_code} - #{error_message}"
end end
def fb_attachment_message_params def fb_attachment_message_params(attachment)
attachment = message.attachments.first
{ {
recipient: { id: contact.get_source_id(inbox.id) }, recipient: { id: contact.get_source_id(inbox.id) },
message: { message: {
@@ -64,14 +68,6 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
'file' 'file'
end end
def fb_message_params
if message.attachments.blank?
fb_text_message_params
else
fb_attachment_message_params
end
end
def sent_first_outgoing_message_after_24_hours? def sent_first_outgoing_message_after_24_hours?
# we can send max 1 message after 24 hour window # we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1 conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
@@ -14,8 +14,13 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService
end end
def perform_reply def perform_reply
send_to_facebook_page attachament_message_params if message.attachments.present? if message.attachments.present?
send_to_facebook_page message_params message.attachments.each do |attachment|
send_to_facebook_page attachment_message_params(attachment)
end
end
send_to_facebook_page message_params if message.content.present?
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
# TODO : handle specific errors or else page will get disconnected # TODO : handle specific errors or else page will get disconnected
@@ -33,8 +38,7 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService
merge_human_agent_tag(params) merge_human_agent_tag(params)
end end
def attachament_message_params def attachment_message_params(attachment)
attachment = message.attachments.first
params = { params = {
recipient: { id: contact.get_source_id(inbox.id) }, recipient: { id: contact.get_source_id(inbox.id) },
message: { message: {
@@ -24,7 +24,9 @@ class Instagram::WebhooksBaseService
def update_instagram_profile_link(user) def update_instagram_profile_link(user)
return unless user['username'] return unless user['username']
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } }) @contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
@contact.save @contact.save
end end
end end
@@ -139,7 +139,14 @@ class Line::IncomingMessageService
def contact_attributes def contact_attributes
{ {
name: line_contact_info['displayName'], name: line_contact_info['displayName'],
avatar_url: line_contact_info['pictureUrl'] avatar_url: line_contact_info['pictureUrl'],
additional_attributes: additional_attributes
}
end
def additional_attributes
{
social_line_user_id: line_contact_info['userId']
} }
end end
@@ -78,8 +78,11 @@ class Telegram::IncomingMessageService
def additional_attributes def additional_attributes
{ {
# TODO: Remove this once we show the social_telegram_user_name in the UI instead of the username
username: telegram_params_username, username: telegram_params_username,
language_code: telegram_params_language_code language_code: telegram_params_language_code,
social_telegram_user_id: telegram_params_from_id,
social_telegram_user_name: telegram_params_username
} }
end end
@@ -127,7 +127,7 @@ class Twilio::IncomingMessageService
def download_attachment_file def download_attachment_file
download_with_auth download_with_auth
rescue Down::Error => e rescue Down::Error, Down::ClientError => e
handle_download_attachment_error(e) handle_download_attachment_error(e)
end end
@@ -141,12 +141,10 @@ class Twilio::IncomingMessageService
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future. # This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
def handle_download_attachment_error(error) def handle_download_attachment_error(error)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}" Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
if error.message.include?('401 Unauthorized') Down.download(params[:MediaUrl0])
Down.download(params[:MediaUrl0]) rescue StandardError => e
else Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception nil
nil
end
end end
end end
@@ -19,7 +19,13 @@ class Whatsapp::IncomingMessageBaseService
private private
def process_messages def process_messages
# message allready exists so we don't need to process # We don't support reactions & ephemeral message now, we need to skip processing the message
# if the webhook event is a reaction or an ephermal message or an unsupported message.
return if unprocessable_message_type?(message_type)
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
# business manager account. While we have not found the core reason yet, the following line ensure that
# there are no duplicate messages created.
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process? return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
cache_message_source_id_in_redis cache_message_source_id_in_redis
@@ -49,8 +55,6 @@ class Whatsapp::IncomingMessageBaseService
end end
def create_messages def create_messages
return if unprocessable_message_type?(message_type)
message = @processed_params[:messages].first message = @processed_params[:messages].first
log_error(message) && return if error_webhook_event?(message) log_error(message) && return if error_webhook_event?(message)
@@ -44,7 +44,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end end
def unprocessable_message_type?(message_type) def unprocessable_message_type?(message_type)
%w[reaction ephemeral unsupported].include?(message_type) %w[reaction ephemeral unsupported request_welcome].include?(message_type)
end end
def brazil_phone_number?(phone_number) def brazil_phone_number?(phone_number)
@@ -19,6 +19,7 @@ json.data do
json.secondary_actor notification.secondary_actor&.push_event_data json.secondary_actor notification.secondary_actor&.push_event_data
json.user notification.user.push_event_data json.user notification.user.push_event_data
json.created_at notification.created_at.to_i json.created_at notification.created_at.to_i
json.last_activity_at notification.last_activity_at.to_i
end end
end end
end end
@@ -3,6 +3,8 @@ json.attribute_display_name resource.attribute_display_name
json.attribute_display_type resource.attribute_display_type json.attribute_display_type resource.attribute_display_type
json.attribute_description resource.attribute_description json.attribute_description resource.attribute_description
json.attribute_key resource.attribute_key json.attribute_key resource.attribute_key
json.regex_pattern resource.regex_pattern
json.regex_cue resource.regex_cue
json.attribute_values resource.attribute_values json.attribute_values resource.attribute_values
json.attribute_model resource.attribute_model json.attribute_model resource.attribute_model
json.default_value resource.default_value json.default_value resource.default_value
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>woot-log</title>
<desc>Created with Sketch.</desc>
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="woot-log" fill-rule="nonzero">
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
<path d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="6" fill="#FFFFFF"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 846 B

@@ -0,0 +1,3 @@
<p>Hello,</p>
<p>Your contact import has failed. It appears that the CSV file you uploaded may not be valid. We kindly request that you review the file and ensure it complies with the required format.<p/>
@@ -2,9 +2,17 @@
<footer class="pt-16 pb-8 flex flex-col items-center justify-center"> <footer class="pt-16 pb-8 flex flex-col items-center justify-center">
<div class="mx-auto max-w-2xl text-center py-2"> <div class="mx-auto max-w-2xl text-center py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= render partial: 'icons/chatwoot-logo' %>
<img
class="w-4 h-4"
alt="<%= @global_config['BRAND_NAME'] %>"
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
/>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center"> <p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %> <a class="hover:underline" href="https://www.chatwoot.com" target="_blank" rel="noopener noreferrer nofoll/ow">Chatwoot</a> <%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
</p> </p>
</div> </div>
</div> </div>
@@ -30,9 +30,8 @@
</h1> </h1>
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center"> <div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
<div class="flex items-start space-x-1"> <div class="flex items-start space-x-1">
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]"> <span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">
<%= render "public/api/v1/portals/thumbnail", author: article.author, size: 5 %> <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
</div> </span>
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400"><%= "#{I18n.t('public_portal.common.by')} #{article.author&.available_name || article.author&.name || ''}" %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div> </div>
</div> </div>
@@ -32,17 +32,7 @@
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3> <h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p> <p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
</div> </div>
<div class="flex flex-row items-center gap-1"> <span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
<div class="flex flex-row items-center gap-1">
<% author = article.author %>
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
<% if author&.name.present? %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= "#{I18n.t('public_portal.common.by')} #{author&.name}" %></span>
<% end %>
</div>
<span class="text-slate-600 dark:text-slate-400">•</span>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
</div> </div>
</a> </a>
</div> </div>
+26 -16
View File
@@ -34,16 +34,24 @@ as well as a link to its edit page.
<section class="main-content__body"> <section class="main-content__body">
<dl> <dl>
<% page.attributes.each do |attribute| %> <% page.attributes.each do |title, attributes| %>
<dt class="attribute-label" id="<%= attribute.name %>"> <fieldset class="<%= "field-unit--nested" if title.present? %>">
<%= t( <% if title.present? %>
"helpers.label.#{resource_name}.#{attribute.name}", <legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
default: attribute.name.titleize, <% end %>
) %>
</dt>
<dd class="attribute-data attribute-data--<%=attribute.html_class%>" <% attributes.each do |attribute| %>
><%= render_field attribute, page: page %></dd> <dt class="attribute-label" id="<%= attribute.name %>">
<%= t(
"helpers.label.#{resource_name}.#{attribute.name}",
default: page.resource.class.human_attribute_name(attribute.name),
) %>
</dt>
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
><%= render_field attribute, page: page %></dd>
<% end %>
</fieldset>
<% end %> <% end %>
</dl> </dl>
</section> </section>
@@ -69,13 +77,15 @@ as well as a link to its edit page.
</div> </div>
<% end %> <% end %>
<% account_user_page.attributes.each do |attribute| -%> <% account_user_page.attributes.each do |title, attributes| -%>
<% if attribute.name == "account" %> <% attributes.each do |attribute| %>
<%= f.hidden_field('account_id', value: page.resource.id) %> <% if attribute.name == "account" %>
<% else %> <%= f.hidden_field('account_id', value: page.resource.id) %>
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>"> <% else %>
<%= render_field attribute, f: f %> <div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
</div> <%= render_field attribute, f: f %>
</div>
<% end %>
<% end %> <% end %>
<% end -%> <% end -%>
+27 -17
View File
@@ -34,16 +34,24 @@ as well as a link to its edit page.
<section class="main-content__body"> <section class="main-content__body">
<dl> <dl>
<% page.attributes.each do |attribute| %> <% page.attributes.each do |title, attributes| %>
<dt class="attribute-label" id="<%= attribute.name %>"> <fieldset class="<%= "field-unit--nested" if title.present? %>">
<%= t( <% if title.present? %>
"helpers.label.#{resource_name}.#{attribute.name}", <legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
default: attribute.name.titleize, <% end %>
) %>
</dt>
<dd class="attribute-data attribute-data--<%=attribute.html_class%>" <% attributes.each do |attribute| %>
><%= render_field attribute, page: page %></dd> <dt class="attribute-label" id="<%= attribute.name %>">
<%= t(
"helpers.label.#{resource_name}.#{attribute.name}",
default: page.resource.class.human_attribute_name(attribute.name),
) %>
</dt>
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
><%= render_field attribute, page: page %></dd>
<% end %>
</fieldset>
<% end %> <% end %>
</dl> </dl>
</section> </section>
@@ -69,14 +77,16 @@ as well as a link to its edit page.
</div> </div>
<% end %> <% end %>
<% account_user_page.attributes.each do |attribute| -%> <% account_user_page.attributes.each do |title, attributes| -%>
<% if attribute.name == "user" %> <% attributes.each do |attribute| %>
<%= f.hidden_field('user_id', value: page.resource.id) %> <% if attribute.name == "user" %>
<% else %> <%= f.hidden_field('user_id', value: page.resource.id) %>
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>"> <% else %>
<%= render_field attribute, f: f %> <div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
</div> <%= render_field attribute, f: f %>
<% end %> </div>
<% end %>
<% end %>
<% end -%> <% end -%>
<div class="form-actions"> <div class="form-actions">
+4 -1
View File
@@ -8,6 +8,9 @@ class EmailReplyWorker
return unless message.email_notifiable_message? return unless message.email_notifiable_message?
# send the email # send the email
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_later ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
message.update!(status: :failed, external_error: e.message)
end end
end end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '3.5.1' version: '3.5.2'
development: development:
<<: *shared <<: *shared
@@ -0,0 +1,6 @@
class AddRegexToCustomAttributeDefinition < ActiveRecord::Migration[7.0]
def change
add_column :custom_attribute_definitions, :regex_pattern, :string
add_column :custom_attribute_definitions, :regex_cue, :string
end
end
@@ -0,0 +1,8 @@
class RefactorSlaPolicyColumns < ActiveRecord::Migration[7.0]
def change
rename_column :sla_policies, :rt_threshold, :next_response_time_threshold
rename_column :sla_policies, :frt_threshold, :first_response_time_threshold
add_column :sla_policies, :description, :string
add_column :sla_policies, :resolution_time_threshold, :float
end
end
@@ -0,0 +1,13 @@
class CreateAppliedSlas < ActiveRecord::Migration[7.0]
def change
create_table :applied_slas do |t|
t.references :account, null: false
t.references :sla_policy, null: false
t.references :conversation, null: false
t.string :sla_status
t.timestamps
end
end
end
@@ -0,0 +1,5 @@
class AddContactTypeToContacts < ActiveRecord::Migration[7.0]
def change
add_column :contacts, :contact_type, :integer, default: 0
end
end
@@ -0,0 +1,6 @@
class AddMiddleNameAndLastNameToContacts < ActiveRecord::Migration[7.0]
def change
add_column :contacts, :middle_name, :string, default: ''
add_column :contacts, :last_name, :string, default: ''
end
end
@@ -0,0 +1,6 @@
class AddLocationAndCountryCodeToContacts < ActiveRecord::Migration[7.0]
def change
add_column :contacts, :location, :string, default: ''
add_column :contacts, :country_code, :string, default: ''
end
end
+24 -3
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do ActiveRecord::Schema[7.0].define(version: 2024_01_29_080827) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -115,6 +115,18 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
t.index ["account_id"], name: "index_agent_bots_on_account_id" t.index ["account_id"], name: "index_agent_bots_on_account_id"
end end
create_table "applied_slas", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "sla_policy_id", null: false
t.bigint "conversation_id", null: false
t.string "sla_status"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
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"
end
create_table "articles", force: :cascade do |t| create_table "articles", force: :cascade do |t|
t.integer "account_id", null: false t.integer "account_id", null: false
t.integer "portal_id", null: false t.integer "portal_id", null: false
@@ -406,6 +418,11 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
t.string "identifier" t.string "identifier"
t.jsonb "custom_attributes", default: {} t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil t.datetime "last_activity_at", precision: nil
t.integer "contact_type", default: 0
t.string "middle_name", default: ""
t.string "last_name", default: ""
t.string "location", default: ""
t.string "country_code", default: ""
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id"], name: "index_contacts_on_account_id" t.index ["account_id"], name: "index_contacts_on_account_id"
@@ -501,6 +518,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.text "attribute_description" t.text "attribute_description"
t.jsonb "attribute_values", default: [] t.jsonb "attribute_values", default: []
t.string "regex_pattern"
t.string "regex_cue"
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
end end
@@ -822,12 +841,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do
create_table "sla_policies", force: :cascade do |t| create_table "sla_policies", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.float "frt_threshold" t.float "first_response_time_threshold"
t.float "rt_threshold" t.float "next_response_time_threshold"
t.boolean "only_during_business_hours", default: false t.boolean "only_during_business_hours", default: false
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "description"
t.float "resolution_time_threshold"
t.index ["account_id"], name: "index_sla_policies_on_account_id" t.index ["account_id"], name: "index_sla_policies_on_account_id"
end end
@@ -22,7 +22,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
end end
def permitted_params def permitted_params
params.require(:sla_policy).permit(:name, :rt_threshold, :frt_threshold, :only_during_business_hours) params.require(:sla_policy).permit(:name, :description, :first_response_time_threshold, :next_response_time_threshold,
:resolution_time_threshold, :only_during_business_hours)
end end
def fetch_sla def fetch_sla

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