diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 281ff95de..d0d8f6d5b 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -36,6 +36,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end end + def update + @conversation.update!(permitted_update_params) + end + def filter result = ::Conversations::FilterService.new(params.permit!, current_user).perform @conversations = result[:conversations] @@ -110,6 +114,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro private + def permitted_update_params + # TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute + params.permit(:priority) + end + def update_last_seen_on_conversation(last_seen_at, update_assignee) # rubocop:disable Rails/SkipsModelValidations @conversation.update_column(:agent_last_seen_at, last_seen_at) @@ -176,3 +185,5 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro @conversation.assignee_id? && Current.user == @conversation.assignee end end + +Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController') diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index cd760f7ce..2ee9f9854 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,6 +1,6 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create], + 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update], 'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/assignments' => ['create'] }.freeze diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue index a9127ac16..bc24ed90f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue @@ -7,11 +7,12 @@ }" >
- +
+ +
+
@@ -82,6 +122,7 @@ export default { ol { padding-left: var(--space-two); } + table { margin: 0; border: 0; diff --git a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue index 2b324527e..b7b146040 100644 --- a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue @@ -8,45 +8,66 @@ >
-
-
+
+

{{ senderDetails.name }}

{{ readableTime }}
- {{ fileNameFromDataUrl }} - + v-dompurify-html="fileNameFromDataUrl" + class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis" + />
+ +
-
+
-
+
-
+
@@ -174,6 +198,9 @@ const ALLOWED_FILE_TYPES = { AUDIO: 'audio', }; +const MAX_ZOOM_LEVEL = 2; +const MIN_ZOOM_LEVEL = 1; + export default { components: { Thumbnail, @@ -195,6 +222,7 @@ export default { }, data() { return { + zoomScale: 1, activeAttachment: {}, activeFileType: '', activeImageIndex: @@ -212,12 +240,9 @@ export default { return this.allAttachments.length > 1; }, readableTime() { - if (!this.activeAttachment.created_at) return ''; - const time = this.messageTimestamp( - this.activeAttachment.created_at, - 'LLL d yyyy, h:mm a' - ); - return time || ''; + const { created_at: createdAt } = this.activeAttachment; + if (!createdAt) return ''; + return this.messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || ''; }, isImage() { return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE; @@ -246,11 +271,12 @@ export default { const { data_url: dataUrl } = this.activeAttachment; if (!dataUrl) return ''; const fileName = dataUrl?.split('/').pop(); - return fileName || ''; + return decodeURIComponent(fileName || ''); }, imageRotationStyle() { return { - transform: `rotate(${this.activeImageRotation}deg)`, + transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`, + cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out', }; }, }, @@ -268,6 +294,7 @@ export default { this.activeImageIndex = index; this.setImageAndVideoSrc(attachment); this.activeImageRotation = 0; + this.zoomScale = 1; }, setImageAndVideoSrc(attachment) { const { file_type: type } = attachment; @@ -316,6 +343,37 @@ export default { this.activeImageRotation += rotation; } }, + onClickZoomImage() { + this.onZoom(0.1); + }, + onZoom(scale) { + if (!this.isImage) { + return; + } + + const newZoomScale = this.zoomScale + scale; + // Check if the new zoom scale is within the allowed range + if (newZoomScale > MAX_ZOOM_LEVEL) { + // Set zoom to max but do not reset to default + this.zoomScale = MAX_ZOOM_LEVEL; + return; + } + if (newZoomScale < MIN_ZOOM_LEVEL) { + // Set zoom to min but do not reset to default + this.zoomScale = MIN_ZOOM_LEVEL; + return; + } + // If within bounds, update the zoom scale + this.zoomScale = newZoomScale; + }, + + onWheelImageZoom(e) { + if (!this.isImage) { + return; + } + const scale = e.deltaY > 0 ? -0.1 : 0.1; + this.onZoom(scale); + }, }, }; diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index 30e45d489..e1235bf51 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -126,6 +126,14 @@ "location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z", "lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z", "lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z", + "zoom-in-outline": [ + "M13.5 10a.75.75 0 0 0-.75-.75h-2v-2a.75.75 0 0 0-1.5 0v2h-2a.75.75 0 1 0 0 1.5h2v2a.75.75 0 0 0 1.5 0v-2h2a.75.75 0 0 0 .75-.75Z", + "M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5 5.75 5.75 0 0 0 0-11.5Z" + ], + "zoom-out-outline": [ + "M12.75 9.25a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5h5.5Z", + "M17.25 10a7.25 7.25 0 1 0-2.681 5.63l4.9 4.9.085.073a.75.75 0 0 0 .976-1.133l-4.9-4.901A7.22 7.22 0 0 0 17.25 10Zm-13 0a5.75 5.75 0 1 1 11.5 0 5.75 5.75 0 0 1-11.5 0Z" + ], "mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z", "mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z", "mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z", @@ -233,7 +241,6 @@ "M14 0L13.9093 0.00622212C13.7497 0.0281315 13.6035 0.107093 13.4976 0.228504C13.3917 0.349915 13.3333 0.505562 13.3333 0.666661V1.33332H12.6667L12.576 1.33954C12.4164 1.36145 12.2701 1.44041 12.1642 1.56183C12.0584 1.68324 12 1.83888 12 1.99998L12.0062 2.09065C12.0281 2.25025 12.1071 2.39652 12.2285 2.50241C12.3499 2.60829 12.5056 2.66664 12.6667 2.66664H13.3333V3.3333L13.3396 3.42397C13.3615 3.58357 13.4404 3.72984 13.5618 3.83573C13.6833 3.94162 13.8389 3.99996 14 3.99996L14.0907 3.99374C14.416 3.9493 14.6667 3.67108 14.6667 3.3333V2.66664H15.3333L15.424 2.66042C15.7493 2.61598 16 2.33776 16 1.99998L15.9938 1.90932C15.9719 1.74971 15.8929 1.60345 15.7715 1.49756C15.6501 1.39167 15.4944 1.33333 15.3333 1.33332H14.6667V0.666661L14.6605 0.575995C14.6385 0.416393 14.5596 0.270123 14.4382 0.164236C14.3168 0.0583485 14.1611 6.79363e-06 14 0Z", "M16 12.0001L15.8187 12.0125C15.4995 12.0563 15.2069 12.2143 14.9951 12.4571C14.7834 12.6999 14.6667 13.0112 14.6667 13.3334V14.6667H13.3333L13.152 14.6792C12.8328 14.723 12.5403 14.8809 12.3285 15.1237C12.1167 15.3665 12 15.6778 12 16L12.0124 16.1814C12.0563 16.5006 12.2142 16.7931 12.457 17.0049C12.6998 17.2167 13.0111 17.3333 13.3333 17.3334H14.6667V18.6667L14.6791 18.848C14.7229 19.1672 14.8809 19.4598 15.1237 19.6715C15.3665 19.8833 15.6778 20 16 20L16.1813 19.9876C16.832 19.8987 17.3333 19.3422 17.3333 18.6667V17.3334H18.6667L18.848 17.3209C19.4987 17.232 20 16.6756 20 16L19.9876 15.8187C19.9437 15.4995 19.7858 15.207 19.543 14.9952C19.3002 14.7834 18.9889 14.6667 18.6667 14.6667H17.3333V13.3334L17.3209 13.1521C17.2771 12.8329 17.1191 12.5403 16.8763 12.3285C16.6335 12.1168 16.3222 12.0001 16 12.0001Z" ], - "book-copy-outline": [ "M3.66675 15.3334V5.33341C3.66675 4.89139 3.84234 4.46746 4.1549 4.1549C4.46746 3.84234 4.89139 3.66675 5.33341 3.66675H14.5001", "M6.16675 13.6667H5.33341C4.89139 13.6667 4.46746 13.8423 4.1549 14.1549C3.84234 14.4675 3.66675 14.8914 3.66675 15.3334C3.66675 15.7754 3.84234 16.1994 4.1549 16.5119C4.46746 16.8245 4.89139 17.0001 5.33341 17.0001H6.16675", diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb index 3c53b6798..d49442f10 100644 --- a/app/models/concerns/activity_message_handler.rb +++ b/app/models/concerns/activity_message_handler.rb @@ -2,14 +2,48 @@ module ActivityMessageHandler extend ActiveSupport::Concern include PriorityActivityMessageHandler + include LabelActivityMessageHandler + include SlaActivityMessageHandler + include TeamActivityMessageHandler private def create_activity - user_name = Current.user.name if Current.user.present? - status_change_activity(user_name) if saved_change_to_status? - priority_change_activity(user_name) if saved_change_to_priority? - create_label_change(activity_message_ownner(user_name)) if saved_change_to_label_list? + user_name = determine_user_name + + handle_status_change(user_name) + handle_priority_change(user_name) + handle_label_change(user_name) + handle_sla_policy_change(user_name) + end + + def determine_user_name + Current.user&.name + end + + def handle_status_change(user_name) + return unless saved_change_to_status? + + status_change_activity(user_name) + end + + def handle_priority_change(user_name) + return unless saved_change_to_priority? + + priority_change_activity(user_name) + end + + def handle_label_change(user_name) + return unless saved_change_to_label_list? + + create_label_change(activity_message_owner(user_name)) + end + + def handle_sla_policy_change(user_name) + return unless saved_change_to_sla_policy_id? + + sla_change_type = determine_sla_change_type + create_sla_change_activity(sla_change_type, activity_message_owner(user_name)) end def status_change_activity(user_name) @@ -45,21 +79,6 @@ module ActivityMessageHandler { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content } end - def create_label_added(user_name, labels = []) - create_label_change_activity('added', user_name, labels) - end - - def create_label_removed(user_name, labels = []) - create_label_change_activity('removed', user_name, labels) - end - - def create_label_change_activity(change_type, user_name, labels = []) - return unless labels.size.positive? - - content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', ')) - ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content - end - def create_muted_message create_mute_change_activity('muted') end @@ -75,30 +94,6 @@ module ActivityMessageHandler ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content end - def generate_team_change_activity_key - team = Team.find_by(id: team_id) - key = team.present? ? 'assigned' : 'removed' - key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee - key - end - - def generate_team_name_for_activity - previous_team_id = previous_changes[:team_id][0] - Team.find_by(id: previous_team_id)&.name if previous_team_id.present? - end - - def create_team_change_activity(user_name) - user_name = activity_message_ownner(user_name) - return unless user_name - - key = generate_team_change_activity_key - params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name } - params[:team_name] = generate_team_name_for_activity if key == 'removed' - content = I18n.t("conversations.activity.team.#{key}", **params) - - ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content - end - def generate_assignee_change_activity_content(user_name) params = { assignee_name: assignee&.name, user_name: user_name }.compact key = assignee_id ? 'assigned' : 'removed' @@ -107,7 +102,7 @@ module ActivityMessageHandler end def create_assignee_change_activity(user_name) - user_name = activity_message_ownner(user_name) + user_name = activity_message_owner(user_name) return unless user_name @@ -115,7 +110,7 @@ module ActivityMessageHandler ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content end - def activity_message_ownner(user_name) + def activity_message_owner(user_name) user_name = 'Automation System' if !user_name && Current.executed_by.present? user_name end diff --git a/app/models/concerns/label_activity_message_handler.rb b/app/models/concerns/label_activity_message_handler.rb new file mode 100644 index 000000000..d5097e00f --- /dev/null +++ b/app/models/concerns/label_activity_message_handler.rb @@ -0,0 +1,20 @@ +module LabelActivityMessageHandler + extend ActiveSupport::Concern + + private + + def create_label_added(user_name, labels = []) + create_label_change_activity('added', user_name, labels) + end + + def create_label_removed(user_name, labels = []) + create_label_change_activity('removed', user_name, labels) + end + + def create_label_change_activity(change_type, user_name, labels = []) + return unless labels.size.positive? + + content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', ')) + ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + end +end diff --git a/app/models/concerns/sla_activity_message_handler.rb b/app/models/concerns/sla_activity_message_handler.rb new file mode 100644 index 000000000..94603161e --- /dev/null +++ b/app/models/concerns/sla_activity_message_handler.rb @@ -0,0 +1,31 @@ +module SlaActivityMessageHandler + extend ActiveSupport::Concern + + private + + def create_sla_change_activity(change_type, user_name) + content = case change_type + when 'added' + I18n.t('conversations.activity.sla.added', user_name: user_name, sla_name: sla_policy_name) + when 'removed' + I18n.t('conversations.activity.sla.removed', user_name: user_name, sla_name: sla_policy_name) + when 'updated' + I18n.t('conversations.activity.sla.updated', user_name: user_name, sla_name: sla_policy_name) + end + ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + end + + def sla_policy_name + SlaPolicy.find_by(id: sla_policy_id)&.name || '' + end + + def determine_sla_change_type + sla_policy_id_before, sla_policy_id_after = previous_changes[:sla_policy_id] + + if sla_policy_id_before.nil? && sla_policy_id_after.present? + 'added' + elsif sla_policy_id_before.present? && sla_policy_id_after.nil? + 'removed' + end + end +end diff --git a/app/models/concerns/team_activity_message_handler.rb b/app/models/concerns/team_activity_message_handler.rb new file mode 100644 index 000000000..aba5237c4 --- /dev/null +++ b/app/models/concerns/team_activity_message_handler.rb @@ -0,0 +1,29 @@ +module TeamActivityMessageHandler + extend ActiveSupport::Concern + + private + + def create_team_change_activity(user_name) + user_name = activity_message_owner(user_name) + return unless user_name + + key = generate_team_change_activity_key + params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name } + params[:team_name] = generate_team_name_for_activity if key == 'removed' + content = I18n.t("conversations.activity.team.#{key}", **params) + + ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + end + + def generate_team_change_activity_key + team = Team.find_by(id: team_id) + key = team.present? ? 'assigned' : 'removed' + key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee + key + end + + def generate_team_name_for_activity + previous_team_id = previous_changes[:team_id][0] + Team.find_by(id: previous_team_id)&.name if previous_team_id.present? + end +end diff --git a/app/models/contact.rb b/app/models/contact.rb index 1e9ab8dc6..1f3d2fb90 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -61,6 +61,7 @@ class Contact < ApplicationRecord after_create_commit :dispatch_create_event, :ip_lookup after_update_commit :dispatch_update_event after_destroy_commit :dispatch_destroy_event + before_save :update_contact_location_and_country_code enum contact_type: { visitor: 0, lead: 1, customer: 2 } @@ -206,6 +207,13 @@ class Contact < ApplicationRecord self.custom_attributes = {} if custom_attributes.blank? end + def update_contact_location_and_country_code + # TODO: Ensure that location and country_code are updated from additional_attributes. + # We will remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app. + self.location = additional_attributes['city'] + self.country_code = additional_attributes['country'] + end + def dispatch_create_event Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self) end diff --git a/app/views/api/v1/accounts/conversations/update.json.jbuilder b/app/views/api/v1/accounts/conversations/update.json.jbuilder new file mode 100644 index 000000000..c273dd3c6 --- /dev/null +++ b/app/views/api/v1/accounts/conversations/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: @conversation diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index cef56fed4..2e90e073a 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -47,3 +47,4 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat json.last_activity_at conversation.last_activity_at.to_i json.priority conversation.priority json.waiting_since conversation.waiting_since.to_i.to_i +json.sla_policy_id conversation.sla_policy_id diff --git a/config/locales/en.yml b/config/locales/en.yml index 5a3816867..c19499fa5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -158,6 +158,9 @@ en: labels: added: "%{user_name} added %{labels}" removed: "%{user_name} removed %{labels}" + sla: + added: "%{user_name} added SLA policy %{sla_name}" + removed: "%{user_name} removed SLA policy %{sla_name}" muted: "%{user_name} has muted the conversation" unmuted: "%{user_name} has unmuted the conversation" templates: diff --git a/config/routes.rb b/config/routes.rb index 90317c7b2..cfa14c854 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,7 +78,7 @@ Rails.application.routes.draw do namespace :channels do resource :twilio_channel, only: [:create] end - resources :conversations, only: [:index, :create, :show] do + resources :conversations, only: [:index, :create, :show, :update] do collection do get :meta get :search diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb new file mode 100644 index 000000000..be8dfbddf --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb @@ -0,0 +1,5 @@ +module Enterprise::Api::V1::Accounts::ConversationsController + def permitted_update_params + super.merge(params.permit(:sla_policy_id)) + end +end diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index cb5ca508d..48fd852e4 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -23,6 +23,13 @@ class AppliedSla < ApplicationRecord belongs_to :conversation validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] } + before_validation :ensure_account_id enum sla_status: { active: 0, hit: 1, missed: 2 } + + private + + def ensure_account_id + self.account_id ||= sla_policy&.account_id + end end diff --git a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb b/enterprise/app/models/enterprise/enterprise_conversation_concern.rb index fc39a61a9..721f069dc 100644 --- a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb +++ b/enterprise/app/models/enterprise/enterprise_conversation_concern.rb @@ -3,5 +3,35 @@ module Enterprise::EnterpriseConversationConcern included do belongs_to :sla_policy, optional: true + has_one :applied_sla, dependent: :destroy + before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } + around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? } + end + + private + + def validate_sla_policy + # TODO: remove these validations once we figure out how to deal with these cases + if sla_policy_id.nil? && changes[:sla_policy_id].first.present? + errors.add(:sla_policy, 'cannot remove sla policy from conversation') + return + end + + if changes[:sla_policy_id].first.present? + errors.add(:sla_policy, 'conversation already has a different sla') + return + end + + errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id + end + + # handling inside a transaction to ensure applied sla record is also created + def ensure_applied_sla_is_created + ActiveRecord::Base.transaction do + yield + create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank? + end + rescue ActiveRecord::RecordInvalid + raise ActiveRecord::Rollback end end diff --git a/enterprise/app/models/sla_policy.rb b/enterprise/app/models/sla_policy.rb index 647db2cfc..f53f00ed5 100644 --- a/enterprise/app/models/sla_policy.rb +++ b/enterprise/app/models/sla_policy.rb @@ -22,6 +22,7 @@ class SlaPolicy < ApplicationRecord validates :name, presence: true has_many :conversations, dependent: :nullify + has_many :applied_slas, dependent: :destroy def push_event_data { diff --git a/enterprise/app/services/enterprise/action_service.rb b/enterprise/app/services/enterprise/action_service.rb index 1e4165b09..f0c3bbf9f 100644 --- a/enterprise/app/services/enterprise/action_service.rb +++ b/enterprise/app/services/enterprise/action_service.rb @@ -8,16 +8,5 @@ module Enterprise::ActionService Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}" @conversation.update!(sla_policy_id: sla_policy.id) - create_applied_sla(sla_policy) - end - - def create_applied_sla(sla_policy) - Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}" - AppliedSla.create!( - account_id: @conversation.account_id, - sla_policy_id: sla_policy.id, - conversation_id: @conversation.id, - sla_status: 'active' - ) end end diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 72e6603f7..70f5b82ee 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -210,6 +210,55 @@ RSpec.describe 'Conversations API', type: :request do end end + describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do + let(:conversation) { create(:conversation, account: account) } + let(:params) { { priority: 'high' } } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'does not update the conversation if you do not have access to it' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'updates the conversation if you are an administrator' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high') + end + + it 'updates the conversation if you are an agent with access to inbox' do + create(:inbox_member, user: agent, inbox: conversation.inbox) + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high') + end + end + end + describe 'POST /api/v1/accounts/{account.id}/conversations' do let(:contact) { create(:contact, account: account) } let(:inbox) { create(:inbox, account: account) } @@ -411,21 +460,6 @@ RSpec.describe 'Conversations API', type: :request do expect(conversation.reload.status).to eq('snoozed') expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until) end - - # TODO: remove this spec when we remove the condition check in controller - # Added for backwards compatibility for bot status - # remove in next release - # it 'toggles the conversation status to pending status when parameter bot is passed' do - # expect(conversation.status).to eq('open') - - # post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", - # headers: agent.create_new_auth_token, - # params: { status: 'bot' }, - # as: :json - - # expect(response).to have_http_status(:success) - # expect(conversation.reload.status).to eq('pending') - # end end context 'when it is an authenticated bot' do diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb new file mode 100644 index 000000000..fb028b76f --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Conversations API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do + let(:conversation) { create(:conversation, account: account) } + let(:sla_policy) { create(:sla_policy, account: account) } + let(:params) { { sla_policy_id: sla_policy.id } } + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + + before do + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + it 'updates the conversation if you are an agent with access to inbox' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:sla_policy_id]).to eq(sla_policy.id) + end + + it 'throws error if conversation already has a different sla' do + conversation.update(sla_policy: create(:sla_policy, account: account)) + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body, symbolize_names: true)[:message]).to eq('Sla policy conversation already has a different sla') + end + end + end +end diff --git a/spec/enterprise/models/conversation_spec.rb b/spec/enterprise/models/conversation_spec.rb index e91f3f885..e9fe811ca 100644 --- a/spec/enterprise/models/conversation_spec.rb +++ b/spec/enterprise/models/conversation_spec.rb @@ -5,6 +5,37 @@ RSpec.describe Conversation, type: :model do it { is_expected.to belong_to(:sla_policy).optional } end + describe 'SLA policy updates' do + let!(:conversation) { create(:conversation) } + let!(:sla_policy) { create(:sla_policy, account: conversation.account) } + + it 'generates an activity message when the SLA policy is updated' do + conversation.update!(sla_policy_id: sla_policy.id) + + perform_enqueued_jobs + + activity_message = conversation.messages.where(message_type: 'activity').last + + expect(activity_message).not_to be_nil + expect(activity_message.message_type).to eq('activity') + expect(activity_message.content).to include('added SLA policy') + end + + # TODO: Reenable this when we let the SLA policy be removed from a conversation + # it 'generates an activity message when the SLA policy is removed' do + # conversation.update!(sla_policy_id: sla_policy.id) + # conversation.update!(sla_policy_id: nil) + + # perform_enqueued_jobs + + # activity_message = conversation.messages.where(message_type: 'activity').last + + # expect(activity_message).not_to be_nil + # expect(activity_message.message_type).to eq('activity') + # expect(activity_message.content).to include('removed SLA policy') + # end + end + describe 'conversation sentiments' do include ActiveJob::TestHelper @@ -34,4 +65,43 @@ RSpec.describe Conversation, type: :model do expect(sentiments[:label]).to eq('positive') end end + + describe 'sla_policy' do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:sla_policy) { create(:sla_policy, account: account) } + let(:different_account_sla_policy) { create(:sla_policy) } + + context 'when sla_policy is getting updated' do + it 'throws error if sla policy belongs to different account' do + conversation.sla_policy = different_account_sla_policy + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch') + end + + it 'creates applied sla record if sla policy is present' do + conversation.sla_policy = sla_policy + conversation.save! + expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id) + end + end + + context 'when conversation already has a different sla' do + before do + conversation.update(sla_policy: create(:sla_policy, account: account)) + end + + it 'throws error if trying to assing a different sla' do + conversation.sla_policy = sla_policy + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla']) + end + + it 'throws error if trying to set sla to nil' do + conversation.sla_policy = nil + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation']) + end + end + end end diff --git a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb index 17c09cb0e..12cb59d35 100644 --- a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb +++ b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb @@ -5,14 +5,21 @@ RSpec.describe Sla::EvaluateAppliedSlaService do let!(:user_1) { create(:user, account: account) } let!(:user_2) { create(:user, account: account) } let!(:admin) { create(:user, account: account, role: :administrator) } - let!(:conversation) { create(:conversation, created_at: 6.hours.ago, assignee: user_1, account: account) } + let!(:sla_policy) do - create(:sla_policy, account: conversation.account, - first_response_time_threshold: nil, - next_response_time_threshold: nil, - resolution_time_threshold: nil) + create(:sla_policy, + account: account, + first_response_time_threshold: nil, + next_response_time_threshold: nil, + resolution_time_threshold: nil) end - let!(:applied_sla) { create(:applied_sla, conversation: conversation, sla_policy: sla_policy, sla_status: 'active') } + let!(:conversation) do + create(:conversation, + created_at: 6.hours.ago, assignee: user_1, + account: sla_policy.account, + sla_policy: sla_policy) + end + let!(:applied_sla) { conversation.applied_sla } describe '#perform - SLA misses' do context 'when first response SLA is missed' do diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb index 7029c89fe..ff186466a 100644 --- a/spec/models/contact_spec.rb +++ b/spec/models/contact_spec.rb @@ -75,4 +75,12 @@ RSpec.describe Contact do expect(contact.email).to eq 'test@test.com' end end + + context 'when city and country code passed in additional attributes' do + it 'updates location and country code' do + contact = create(:contact, additional_attributes: { city: 'New York', country: 'US' }) + expect(contact.location).to eq 'New York' + expect(contact.country_code).to eq 'US' + end + end end diff --git a/swagger/paths/application/conversation/update.yml b/swagger/paths/application/conversation/update.yml new file mode 100644 index 000000000..3add02635 --- /dev/null +++ b/swagger/paths/application/conversation/update.yml @@ -0,0 +1,29 @@ +tags: + - Conversations +operationId: update-conversation +summary: Update Conversation +description: Update Conversation Attributes +security: + - userApiKey: [] + - agentBotApiKey: [] +parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + priority: + type: string + enum: ["urgent", "high", "medium", "low", "none"] + description: "The priority of the conversation" + sla_policy_id: + type: number + description: "The ID of the SLA policy (Available only in Enterprise edition)" +responses: + 200: + description: Success + 404: + description: Conversation not found + 401: + description: Unauthorized diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 2c6dd3f87..6df22d06e 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -339,6 +339,8 @@ - $ref: '#/parameters/conversation_id' get: $ref: ./application/conversation/show.yml + patch: + $ref: ./application/conversation/update.yml /api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status: parameters: - $ref: '#/parameters/account_id' diff --git a/swagger/swagger.json b/swagger/swagger.json index ec88a224b..d5f94f730 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -3319,6 +3319,64 @@ "description": "Access denied" } } + }, + "patch": { + "tags": [ + "Conversations" + ], + "operationId": "update-conversation", + "summary": "Update Conversation", + "description": "Update Conversation Attributes", + "security": [ + { + "userApiKey": [ + + ] + }, + { + "agentBotApiKey": [ + + ] + } + ], + "parameters": [ + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "priority": { + "type": "string", + "enum": [ + "urgent", + "high", + "medium", + "low", + "none" + ], + "description": "The priority of the conversation" + }, + "sla_policy_id": { + "type": "number", + "description": "The ID of the SLA policy (Available only in Enterprise edition)" + } + } + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Conversation not found" + }, + "401": { + "description": "Unauthorized" + } + } } }, "/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status": {