From f9d1146cb08ae207cdae9f6df5d2fce1c0c2d95f Mon Sep 17 00:00:00 2001 From: "Cayo P. R. Oliveira" Date: Fri, 30 Jan 2026 22:08:16 -0300 Subject: [PATCH] feat: mensagens agendadas (#198) * feat: Adds model for scheduling messages * feat: Implement scheduled message handling and processing jobs * feat: Add ScheduledMessagesController and associated specs for managing scheduled messages * refactor: Simplify scheduled message job specs and improve metadata handling * feat: Add ScheduledMessagePolicy for managing access to scheduled messages * feat: Add routes for managing scheduled messages * feat: Add scheduled message event handling and broadcasting * feat: Add JSON views for scheduled messages creation, destruction, updating, and indexing * feat: Update scheduled message status and dispatch update event after message creation * feat: Ensure scheduled message updates trigger dispatch event * feat: Add mutation types for managing scheduled messages * feat: Add additionalAttributes prop to Message component and provider * feat: Implement scheduled message handling in ActionCable and Vuex store * feat: Add unit tests for scheduled messages actions and mutations * feat: implement scheduled messages functionality - Added support for scheduling messages in the conversation dashboard. - Introduced new components: ScheduledMessageModal and ScheduledMessages for managing scheduled messages. - Enhanced ReplyBottomPanel to include scheduling options. - Updated Base.vue to handle scheduled message styling. - Integrated Vuex store module for managing scheduled messages state. - Added necessary translations for scheduled messages in English and Portuguese. * feat: add pagination to scheduled messages index and update tests accordingly * chore: update scheduled messages specs for future time validation and response status * chore: enhance scheduled messages API with pagination and add skeleton loader component * feat: add create_scheduled_message action to automation rule attributes * feat: implement create_scheduled_message action and enhance attachment handling * feat: add scheduled message functionality with UI components and localization * test: enhance scheduledMessages mutations tests with meta handling and structure * chore: update label to display file name upon successful upload in AutomationFileInput component * feat: add initialAttachment prop to ScheduledMessageModal and update ReplyBox to pass attachment * chore: prepend_mod_with to ScheduledMessagesController for better module handling * fix: attachment visibility in ScheduledMessageItem component * chore: enhance ScheduledMessage model with validations and reduce controller load * refactor: simplify ScheduledMessagesAPI methods by removing unnecessary instance variable * chore: update event emission for scheduled message creation in ReplyBox and ScheduledMessageModal * refactor: update status configuration to use label keys * chore: update date formatting in ScheduledMessageItem component * refactor: collapse logic to checkOverflow and update related functionality * chore: add author indication for current user in scheduled messages * chore: enhance scheduled message metadata with author information and localization * fix: send message shortcut * chore: handle errors in scheduled message submission * chore: update scheduled message modal to use combined date and time input * chore: refactor scheduled messages handling to remove pagination and update related tests * fix: ensure scheduled messages update status and dispatch on failure * fix: update scheduled message due date logic and simplify sending checks * refactor: rename build_message method for send_message * fix: update scheduled message creation time and improve test reliability * chore: ignore unnecessary check * chore: add scheduled message metadata handling in message builder, add scheduled message factorie and update specs * refactor: use scheduled message factorie creation in specs * chore: streamline error handling in scheduled message job and remove dispatch logic * fix: change scheduled_messages association to destroy dependent records * refactor: remove unused attributes from scheduled message payload builder * chore: update scheduled message retrieval to use conversation association * chore: correct cron format for scheduled messages job * chore: remove migration for author_type in scheduled_messages * feat: enhance scheduled messages management with delete confirmation and error handling * chore: set cron poll interval to 10 seconds for improved scheduling precision * feat: include additional_attributes in message JSON response * feat: enhance scheduled message validation and localization support * chore: update scheduled message display * Merge branch 'main' into Cayo-Oliveira/CU-86aenh268/Mensagens-agendadas * feat: add scheduled message indicators and validation for message length * fix: remove unnecessary condition from line-clamp class binding * feat: update scheduled messages localization and enhance content validation * feat: update scheduled messages order, enhance scheduledAt computation, and add message association * fix: reorder condition for Facebook channel message length computation * fix: change detection for attachments in scheduled messages * fix: remove unnecessary colon from close-on-backdrop-click prop in ScheduledMessageModal * chore: add error handling for scheduled message deletion and update localization for delete failure * fix: enforce minimum delay of 1 minute for scheduled messages and update validation * fix: remove unused private property and improve locale formatting for scheduled messages * fix: adjust positioning of DropdownBody in ReplyBottomPanel and clean up schema foreign keys * docs: add scheduled messages management APIs and payload definitions --------- Co-authored-by: gabrieljablonski --- app/builders/messages/message_builder.rb | 19 +- .../scheduled_messages_controller.rb | 65 ++ .../concerns/attachment_concern.rb | 30 +- .../dashboard/api/scheduledMessages.js | 66 ++ .../api/specs/scheduledMessages.spec.js | 77 +++ .../components/ScheduledMessageItem.vue | 277 ++++++++ .../components-next/message/Message.vue | 3 + .../components-next/message/MessageMeta.vue | 98 ++- .../components-next/message/bubbles/Base.vue | 23 +- .../message/bubbles/Text/FormattedContent.vue | 109 ++- .../components-next/message/provider.js | 1 + .../widgets/AutomationActionInput.vue | 12 +- .../AutomationActionScheduledMessageInput.vue | 93 +++ .../widgets/AutomationFileInput.vue | 3 +- .../widgets/WootWriter/ReplyBottomPanel.vue | 58 +- .../widgets/conversation/ReplyBox.vue | 32 + .../composables/useEditableAutomation.js | 3 + .../dashboard/composables/useUISettings.js | 7 +- .../dashboard/helper/actionCable.js | 15 + .../dashboard/helper/actionQueryGenerator.js | 8 + .../dashboard/helper/automationHelper.js | 23 +- .../dashboard/helper/validations.js | 16 + .../dashboard/i18n/locale/en/automation.json | 5 +- .../i18n/locale/en/conversation.json | 67 +- .../i18n/locale/pt_BR/automation.json | 5 +- .../i18n/locale/pt_BR/conversation.json | 67 +- .../dashboard/conversation/ContactPanel.vue | 22 +- .../ScheduledMessageModal.vue | 636 ++++++++++++++++++ .../ScheduledMessageSkeletonLoader.vue | 37 + .../scheduledMessages/ScheduledMessages.vue | 253 +++++++ .../settings/automation/constants.js | 25 + app/javascript/dashboard/store/index.js | 2 + .../store/modules/conversations/actions.js | 12 + .../store/modules/scheduledMessages.js | 189 ++++++ .../specs/scheduledMessages/actions.spec.js | 130 ++++ .../specs/scheduledMessages/mutations.spec.js | 43 ++ .../dashboard/store/mutation-types.js | 5 + .../send_scheduled_message_job.rb | 69 ++ .../trigger_scheduled_messages_job.rb | 9 + app/listeners/action_cable_listener.rb | 26 +- app/models/account.rb | 1 + app/models/automation_rule.rb | 27 +- .../concerns/scheduled_message_handler.rb | 46 ++ app/models/conversation.rb | 1 + app/models/inbox.rb | 1 + app/models/message.rb | 1 + app/models/scheduled_message.rb | 154 +++++ app/models/user.rb | 2 + app/policies/scheduled_message_policy.rb | 51 ++ app/services/action_service.rb | 42 ++ .../automation_rules/action_service.rb | 7 + .../scheduled_messages/create.json.jbuilder | 1 + .../scheduled_messages/destroy.json.jbuilder | 1 + .../scheduled_messages/index.json.jbuilder | 5 + .../scheduled_messages/update.json.jbuilder | 1 + .../api/v1/models/_message.json.jbuilder | 1 + .../models/_scheduled_message.json.jbuilder | 26 + config/initializers/sidekiq.rb | 3 + config/locales/en.yml | 4 + config/locales/pt_BR.yml | 4 + config/routes.rb | 1 + config/schedule.yml | 6 + ...0260121190545_create_scheduled_messages.rb | 32 + db/schema.rb | 32 +- lib/events/types.rb | 5 + .../builders/messages/message_builder_spec.rb | 41 ++ .../scheduled_messages_controller_spec.rb | 126 ++++ spec/factories/scheduled_messages.rb | 11 + .../send_scheduled_message_job_spec.rb | 88 +++ .../trigger_scheduled_messages_job_spec.rb | 36 + spec/listeners/action_cable_listener_spec.rb | 27 + .../scheduled_message_handler_spec.rb | 72 ++ spec/models/scheduled_message_spec.rb | 238 +++++++ spec/services/action_service_spec.rb | 29 + .../automation_rules/action_service_spec.rb | 17 + swagger/definitions/index.yml | 6 + .../create_update_payload.yml | 30 + .../resource/scheduled_message.yml | 67 ++ swagger/index.yml | 3 + swagger/parameters/index.yml | 5 +- swagger/parameters/scheduled_message_id.yml | 6 + .../scheduled_messages/create.yml | 53 ++ .../scheduled_messages/delete.yml | 42 ++ .../conversation/scheduled_messages/index.yml | 38 ++ .../scheduled_messages/update.yml | 48 ++ swagger/paths/index.yml | 21 + swagger/swagger.json | 471 ++++++++++++- swagger/tag_groups/application_swagger.json | 457 +++++++++++++ swagger/tag_groups/client_swagger.json | 163 ++++- swagger/tag_groups/other_swagger.json | 151 +++++ swagger/tag_groups/platform_swagger.json | 151 +++++ 91 files changed, 5455 insertions(+), 37 deletions(-) create mode 100644 app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb create mode 100644 app/javascript/dashboard/api/scheduledMessages.js create mode 100644 app/javascript/dashboard/api/specs/scheduledMessages.spec.js create mode 100644 app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue create mode 100644 app/javascript/dashboard/components/widgets/AutomationActionScheduledMessageInput.vue create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageSkeletonLoader.vue create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessages.vue create mode 100644 app/javascript/dashboard/store/modules/scheduledMessages.js create mode 100644 app/javascript/dashboard/store/modules/specs/scheduledMessages/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/scheduledMessages/mutations.spec.js create mode 100644 app/jobs/scheduled_messages/send_scheduled_message_job.rb create mode 100644 app/jobs/scheduled_messages/trigger_scheduled_messages_job.rb create mode 100644 app/models/concerns/scheduled_message_handler.rb create mode 100644 app/models/scheduled_message.rb create mode 100644 app/policies/scheduled_message_policy.rb create mode 100644 app/views/api/v1/accounts/conversations/scheduled_messages/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/conversations/scheduled_messages/destroy.json.jbuilder create mode 100644 app/views/api/v1/accounts/conversations/scheduled_messages/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/conversations/scheduled_messages/update.json.jbuilder create mode 100644 app/views/api/v1/models/_scheduled_message.json.jbuilder create mode 100644 db/migrate/20260121190545_create_scheduled_messages.rb create mode 100644 spec/controllers/api/v1/accounts/conversations/scheduled_messages_controller_spec.rb create mode 100644 spec/factories/scheduled_messages.rb create mode 100644 spec/jobs/scheduled_messages/send_scheduled_message_job_spec.rb create mode 100644 spec/jobs/scheduled_messages/trigger_scheduled_messages_job_spec.rb create mode 100644 spec/models/concerns/scheduled_message_handler_spec.rb create mode 100644 spec/models/scheduled_message_spec.rb create mode 100644 swagger/definitions/request/scheduled_message/create_update_payload.yml create mode 100644 swagger/definitions/resource/scheduled_message.yml create mode 100644 swagger/parameters/scheduled_message_id.yml create mode 100644 swagger/paths/application/conversation/scheduled_messages/create.yml create mode 100644 swagger/paths/application/conversation/scheduled_messages/delete.yml create mode 100644 swagger/paths/application/conversation/scheduled_messages/index.yml create mode 100644 swagger/paths/application/conversation/scheduled_messages/update.yml diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index b5f516946..f0a37b01a 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -167,6 +167,22 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength @params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {} end + def scheduled_message_metadata + return {} if @params[:scheduled_message].blank? + + sm = @params[:scheduled_message] + scheduled_by = { 'id' => sm.author_id, 'type' => sm.author_type } + scheduled_by['name'] = sm.author.name if sm.author.respond_to?(:name) + + { + additional_attributes: { + scheduled_message_id: sm.id, + scheduled_by: scheduled_by, + scheduled_at: sm.updated_at.to_i + } + } + end + def message_sender return if @params[:sender_type] != 'AgentBot' @@ -192,7 +208,8 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength is_reaction: @is_reaction, echo_id: @params[:echo_id], source_id: @params[:source_id] - }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params).merge(zapi_args) + }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id) + .merge(template_params).merge(zapi_args).merge(scheduled_message_metadata) end def email_inbox? diff --git a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb new file mode 100644 index 000000000..70c4dc7de --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb @@ -0,0 +1,65 @@ +class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + + before_action :scheduled_message, only: [:update, :destroy] + + MAX_LIMIT = 100 + + def index + authorize build_scheduled_message + @scheduled_messages = @conversation.scheduled_messages + .order(scheduled_at: :desc) + .limit(MAX_LIMIT) + end + + def create + @scheduled_message = build_scheduled_message + authorize @scheduled_message + @scheduled_message.assign_attributes(scheduled_message_params) + @scheduled_message.save! + dispatch_event(SCHEDULED_MESSAGE_CREATED, scheduled_message: @scheduled_message) + end + + def update + @scheduled_message.assign_attributes(scheduled_message_params) + @scheduled_message.save! + dispatch_event(SCHEDULED_MESSAGE_UPDATED, scheduled_message: @scheduled_message) + end + + def destroy + scheduled_message = @scheduled_message + scheduled_message.destroy! + dispatch_event(SCHEDULED_MESSAGE_DELETED, scheduled_message: scheduled_message) + rescue ActiveRecord::RecordNotDestroyed => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity + end + + private + + def scheduled_message + @scheduled_message ||= @conversation.scheduled_messages.find(params[:id]) + authorize @scheduled_message + end + + def build_scheduled_message + @conversation.scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user) + end + + def scheduled_message_params + params.permit( + :content, + :scheduled_at, + :status, + :attachment, + template_params: {} + ) + end + + def dispatch_event(event_name, data) + Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, data) + end +end + +Api::V1::Accounts::Conversations::ScheduledMessagesController.prepend_mod_with( + 'Api::V1::Accounts::Conversations::ScheduledMessagesController' +) diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb index 2652f04be..6eae2d6f5 100644 --- a/app/controllers/concerns/attachment_concern.rb +++ b/app/controllers/concerns/attachment_concern.rb @@ -6,7 +6,7 @@ module AttachmentConcern return [blobs, actions, nil] if actions.blank? sanitized = actions.map do |action| - next action unless action[:action_name] == 'send_attachment' + next action unless attachment_action?(action) result = process_attachment_action(action, record, blobs) return [nil, nil, I18n.t('errors.attachments.invalid')] unless result @@ -20,15 +20,39 @@ module AttachmentConcern private def process_attachment_action(action, record, blobs) - blob_id = action[:action_params].first + blob_id = attachment_blob_id(action) + return action if action[:action_name] == 'create_scheduled_message' && blob_id.blank? + blob = ActiveStorage::Blob.find_signed(blob_id.to_s) - return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present? + return action.merge(action_params: attachment_action_params(action, blob.id)).tap { blobs << blob } if blob.present? return action if blob_already_attached?(record, blob_id) nil end + def attachment_action?(action) + %w[send_attachment create_scheduled_message].include?(action[:action_name]) + end + + def attachment_blob_id(action) + return action[:action_params].first unless action[:action_name] == 'create_scheduled_message' + + params = action[:action_params].first + params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h) + params&.with_indifferent_access&.dig(:blob_id) + end + + def attachment_action_params(action, blob_id) + return [blob_id] unless action[:action_name] == 'create_scheduled_message' + + params = action[:action_params].first + params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h) + params = params.with_indifferent_access + params[:blob_id] = blob_id + [params] + end + def blob_already_attached?(record, blob_id) record&.files&.any? { |f| f.blob_id == blob_id.to_i } end diff --git a/app/javascript/dashboard/api/scheduledMessages.js b/app/javascript/dashboard/api/scheduledMessages.js new file mode 100644 index 000000000..052cb4f42 --- /dev/null +++ b/app/javascript/dashboard/api/scheduledMessages.js @@ -0,0 +1,66 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildScheduledMessagePayload = ({ + content, + status, + scheduledAt, + templateParams, + attachment, +} = {}) => { + if (!attachment) { + return { + content, + status, + scheduled_at: scheduledAt, + template_params: templateParams, + }; + } + + const payload = new FormData(); + if (content) payload.append('content', content); + if (scheduledAt) payload.append('scheduled_at', scheduledAt); + if (status) payload.append('status', status); + payload.append('attachment', attachment); + if (templateParams) { + payload.append('template_params', JSON.stringify(templateParams)); + } + + return payload; +}; + +class ScheduledMessagesAPI extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + get(conversationId) { + return axios.get( + `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages` + ); + } + + create(conversationId, payload) { + return axios({ + method: 'post', + url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`, + data: buildScheduledMessagePayload(payload), + }); + } + + update(conversationId, scheduledMessageId, payload) { + return axios({ + method: 'patch', + url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`, + data: buildScheduledMessagePayload(payload), + }); + } + + delete(conversationId, scheduledMessageId) { + return axios.delete( + `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}` + ); + } +} + +export default new ScheduledMessagesAPI(); diff --git a/app/javascript/dashboard/api/specs/scheduledMessages.spec.js b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js new file mode 100644 index 000000000..6358b478f --- /dev/null +++ b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js @@ -0,0 +1,77 @@ +import ScheduledMessagesAPI, { + buildScheduledMessagePayload, +} from '../scheduledMessages'; + +describe('#ScheduledMessagesAPI', () => { + describe('#buildScheduledMessagePayload', () => { + it('builds object payload without attachment or FormData with attachment', () => { + const objectPayload = buildScheduledMessagePayload({ + content: 'Hello', + scheduledAt: '2025-01-01T10:00:00Z', + status: 'pending', + }); + + expect(objectPayload).toEqual({ + content: 'Hello', + scheduled_at: '2025-01-01T10:00:00Z', + status: 'pending', + private: undefined, + template_params: undefined, + content_attributes: undefined, + additional_attributes: undefined, + }); + + const formPayload = buildScheduledMessagePayload({ + content: 'Hello', + attachment: new Blob(['test'], { type: 'text/plain' }), + }); + + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('content')).toEqual('Hello'); + }); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = Object.assign( + vi.fn(() => Promise.resolve()), + { delete: vi.fn(() => Promise.resolve()) } + ); + + beforeEach(() => { + axiosMock.mockClear(); + axiosMock.delete.mockClear(); + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/inbox'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('calls correct endpoints for create, update, and delete', () => { + ScheduledMessagesAPI.create(12, { content: 'Hello' }); + expect(axiosMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/api/v1/accounts/1/conversations/12/scheduled_messages', + }) + ); + + ScheduledMessagesAPI.update(12, 7, { status: 'pending' }); + expect(axiosMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'patch', + url: '/api/v1/accounts/1/conversations/12/scheduled_messages/7', + }) + ); + + ScheduledMessagesAPI.delete(12, 7); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/conversations/12/scheduled_messages/7' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue new file mode 100644 index 000000000..05e47f5cf --- /dev/null +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue @@ -0,0 +1,277 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 48a660e1e..6d0256beb 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -94,6 +94,7 @@ import ContextMenu from 'dashboard/modules/conversations/components/MessageConte * @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox * @property {number} conversationId - The ID of the conversation to which the message belongs * @property {number} inboxId - The ID of the inbox to which the message belongs + * @property {Object} [additionalAttributes={}] - Additional attributes of the message */ // eslint-disable-next-line vue/define-macros-order @@ -117,6 +118,8 @@ const props = defineProps({ default: 'text', validator: value => Object.values(CONTENT_TYPES).includes(value), }, + // eslint-disable-next-line vue/no-unused-properties + additionalAttributes: { type: Object, default: () => ({}) }, conversationId: { type: Number, required: true }, createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index 1e26c3f57..f215b15a5 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -1,6 +1,8 @@ + + diff --git a/app/javascript/dashboard/components/widgets/AutomationFileInput.vue b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue index 1eff406bf..ffbf999aa 100644 --- a/app/javascript/dashboard/components/widgets/AutomationFileInput.vue +++ b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue @@ -36,7 +36,8 @@ export default { ); this.$emit('update:modelValue', [id]); this.uploadState = 'uploaded'; - this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED'); + this.label = + file?.name || this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED'); } catch (error) { this.uploadState = 'failed'; this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED'); diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index dc2e8ebdb..ca58c1d62 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -13,10 +13,23 @@ import AIAssistanceButton from '../AIAssistanceButton.vue'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { mapGetters } from 'vuex'; import NextButton from 'dashboard/components-next/button/Button.vue'; +import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue'; +import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; +import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue'; +import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; export default { name: 'ReplyBottomPanel', - components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton }, + components: { + NextButton, + FileUpload, + VideoCallButton, + AIAssistanceButton, + DropdownContainer, + DropdownBody, + DropdownSection, + DropdownItem, + }, mixins: [inboxMixin], props: { isNote: { @@ -122,6 +135,10 @@ export default { type: Boolean, default: false, }, + showScheduleOptions: { + type: Boolean, + default: false, + }, }, emits: [ 'replaceText', @@ -129,6 +146,7 @@ export default { 'selectWhatsappTemplate', 'selectContentTemplate', 'toggleQuotedReply', + 'scheduleMessage', ], setup() { const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } = @@ -272,6 +290,9 @@ export default { toggleInsertArticle() { this.$emit('toggleInsertArticle'); }, + openScheduleModal() { + this.$emit('scheduleMessage'); + }, }, }; @@ -399,7 +420,42 @@ export default { />
+
+ + + + + + + + + +
+ + { // If the sidebar order doesn't have the new elements, then add them to the list. DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER.forEach(item => { if (!itemsOrderCopy.find(i => i.name === item.name)) { - itemsOrderCopy.push(item); + if (item.name === 'scheduled_messages') { + itemsOrderCopy.unshift(item); + } else { + itemsOrderCopy.push(item); + } } }); return itemsOrderCopy; diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 991576e66..78177d563 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -34,6 +34,9 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.updated': this.onConversationUpdated, 'account.cache_invalidated': this.onCacheInvalidate, 'copilot.message.created': this.onCopilotMessageCreated, + 'scheduled_message.created': this.onScheduledMessageCreated, + 'scheduled_message.updated': this.onScheduledMessageUpdated, + 'scheduled_message.deleted': this.onScheduledMessageDeleted, }; } @@ -119,6 +122,18 @@ class ActionCableConnector extends BaseActionCableConnector { this.fetchConversationStats(); }; + onScheduledMessageCreated = data => { + this.app.$store.dispatch('handleScheduledMessageCreated', data); + }; + + onScheduledMessageUpdated = data => { + this.app.$store.dispatch('handleScheduledMessageUpdated', data); + }; + + onScheduledMessageDeleted = data => { + this.app.$store.dispatch('handleScheduledMessageDeleted', data); + }; + onTypingOn = ({ conversation, user }) => { const conversationId = conversation.id; diff --git a/app/javascript/dashboard/helper/actionQueryGenerator.js b/app/javascript/dashboard/helper/actionQueryGenerator.js index 4ecae3df4..4cbfd3983 100644 --- a/app/javascript/dashboard/helper/actionQueryGenerator.js +++ b/app/javascript/dashboard/helper/actionQueryGenerator.js @@ -6,11 +6,19 @@ const allElementsNumbers = arr => { return arr.every(elem => typeof elem === 'number'); }; +const allElementsPlainObjects = arr => { + return arr.every( + elem => typeof elem === 'object' && elem !== null && !elem.id + ); +}; + const formatArray = params => { if (params.length <= 0) { params = []; } else if (allElementsString(params) || allElementsNumbers(params)) { params = [...params]; + } else if (allElementsPlainObjects(params)) { + params = [...params]; } else { params = params.map(val => val.id); } diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js index 472256156..e34773cdc 100644 --- a/app/javascript/dashboard/helper/automationHelper.js +++ b/app/javascript/dashboard/helper/automationHelper.js @@ -158,10 +158,21 @@ export const getConditionOptions = ({ }; export const getFileName = (action, files = []) => { - const blobId = action.action_params[0]; + const scheduledParams = Array.isArray(action.action_params) + ? action.action_params[0] + : action.action_params; + const blobId = + action.action_name === 'create_scheduled_message' + ? scheduledParams?.blob_id + : action.action_params?.[0]; if (!blobId) return ''; - if (action.action_name === 'send_attachment') { - const file = files.find(item => item.blob_id === blobId); + if ( + action.action_name === 'send_attachment' || + action.action_name === 'create_scheduled_message' + ) { + const file = files.find( + item => item.blob_id?.toString() === blobId.toString() + ); if (file) return file.filename.toString(); } return ''; @@ -335,7 +346,11 @@ export const getCustomAttributeType = (automationTypes, automation, key) => { * @returns {boolean} True if the action input should be shown, false otherwise. */ export const showActionInput = (automationActionTypes, action) => { - if (action === 'send_email_to_team' || action === 'send_message') + if ( + action === 'send_email_to_team' || + action === 'send_message' || + action === 'create_scheduled_message' + ) return false; const type = automationActionTypes.find(i => i.key === action)?.inputType; return !!type; diff --git a/app/javascript/dashboard/helper/validations.js b/app/javascript/dashboard/helper/validations.js index edebc4656..cd96d3b62 100644 --- a/app/javascript/dashboard/helper/validations.js +++ b/app/javascript/dashboard/helper/validations.js @@ -136,6 +136,22 @@ const validateSingleAction = action => { return ACTION_PARAMETERS_REQUIRED; } + if (action.action_name === 'create_scheduled_message') { + const params = action.action_params?.[0]; + if (!params || typeof params !== 'object') { + return ACTION_PARAMETERS_REQUIRED; + } + const hasContent = params.content?.trim?.(); + const hasAttachment = params.blob_id; + const hasDelay = params.delay_minutes && params.delay_minutes >= 1; + if (!hasContent && !hasAttachment) { + return ACTION_PARAMETERS_REQUIRED; + } + if (!hasDelay) { + return ACTION_PARAMETERS_REQUIRED; + } + } + return null; }; diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 43245a1d5..153376f0c 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -96,7 +96,9 @@ "TEAM_MESSAGE_INPUT_PLACEHOLDER": "Enter your message here", "TEAM_DROPDOWN_PLACEHOLDER": "Select teams", "EMAIL_INPUT_PLACEHOLDER": "Enter email", - "URL_INPUT_PLACEHOLDER": "Enter URL" + "URL_INPUT_PLACEHOLDER": "Enter URL", + "SCHEDULED_MESSAGE_DELAY_LABEL": "Delay (minutes)", + "SCHEDULED_MESSAGE_DELAY_PLACEHOLDER": "Enter delay in minutes" }, "TOGGLE": { "ACTIVATION_TITLE": "Activate Automation Rule", @@ -147,6 +149,7 @@ "SEND_WEBHOOK_EVENT": "Send Webhook Event", "SEND_ATTACHMENT": "Send Attachment", "SEND_MESSAGE": "Send a Message", + "CREATE_SCHEDULED_MESSAGE": "Create Scheduled Message", "ADD_PRIVATE_NOTE": "Add a Private Note", "CHANGE_PRIORITY": "Change Priority", "ADD_SLA": "Add SLA", diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 99ecf8881..6715f951d 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -237,7 +237,8 @@ "REMOVE_PREVIEW": "Remove quoted email thread", "COLLAPSE": "Collapse preview", "EXPAND": "Expand preview" - } + }, + "SCHEDULE_SEND": "Schedule send" }, "VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team", "CHANGE_STATUS": "Conversation status changed", @@ -363,6 +364,7 @@ }, "ACCORDION": { "CONTACT_DETAILS": "Contact Details", + "SCHEDULED_MESSAGES": "Scheduled Messages", "CONVERSATION_ACTIONS": "Conversation Actions", "CONVERSATION_LABELS": "Conversation Labels", "CONVERSATION_INFO": "Conversation Information", @@ -393,6 +395,69 @@ } } }, + "SCHEDULED_MESSAGES": { + "NEW_BUTTON": "Schedule message", + "PAST_MESSAGES_SECTION": "Sent", + "EMPTY_STATE": "There are no scheduled messages yet.", + "STATUS": { + "DRAFT": "Draft", + "PENDING": "Pending", + "SENT": "Sent", + "FAILED": "Failed" + }, + "ITEM": { + "SCHEDULED_FOR": "Scheduled for {time}", + "NO_SCHEDULE": "No schedule", + "TEMPLATE_PREVIEW": "Template: {name}", + "ATTACHMENT_PREVIEW": "Attachment: {filename}", + "ATTACHMENT_LABEL": "Attachment: {filename}", + "EMPTY_PREVIEW": "No content", + "EXPAND": "Expand", + "COLLAPSE": "Collapse" + }, + "MODAL": { + "TITLE_NEW": "Schedule a message", + "TITLE_EDIT": "Edit scheduled message", + "MESSAGE_LABEL": "Message", + "MESSAGE_PLACEHOLDER": "Write your message...", + "DATETIME_LABEL": "Date and time to send", + "DATETIME_PLACEHOLDER": "Select date and time", + "ATTACHMENT_LABEL": "Attachment", + "ATTACHMENT_ADD": "Attach file", + "ATTACHMENT_CURRENT": "Current attachment: {filename}", + "CANCEL": "Cancel", + "SAVE_DRAFT": "Save as draft", + "SCHEDULE": "Schedule" + }, + "CONFIRM_CLOSE": { + "TITLE": "Unsaved changes", + "MESSAGE": "You have unsaved content. Would you like to discard your changes?", + "CONTINUE_EDITING": "Continue editing", + "DISCARD": "Discard", + "CANCEL": "Cancel" + }, + "CONFIRM_DELETE": { + "TITLE": "Delete scheduled message", + "MESSAGE": "Are you sure you want to delete this scheduled message? This action cannot be undone.", + "CANCEL": "Cancel", + "DELETE": "Delete" + }, + "META": { + "TOOLTIP": "Scheduled at {time} by {author}", + "YOU": "You", + "AUTHOR_YOU": "{name} (You)", + "AUTOMATION": "Automation", + "UNKNOWN_AUTHOR": "Unknown" + }, + "ERRORS": { + "CONTENT_REQUIRED": "Add a message, template, or attachment before saving.", + "CONTENT_TOO_LONG": "Message is too long. Maximum {maxLength} characters allowed.", + "DATETIME_REQUIRED": "Select a date and time to schedule the message.", + "SCHEDULE_IN_PAST": "Scheduled time must be in the future.", + "SAVE_FAILED": "Unable to save scheduled message. Please try again.", + "DELETE_FAILED": "Unable to delete scheduled message. Please try again." + } + }, "CONVERSATION_CUSTOM_ATTRIBUTES": { "ADD_BUTTON_TEXT": "Create attribute", "NO_RECORDS_FOUND": "No attributes found", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/automation.json b/app/javascript/dashboard/i18n/locale/pt_BR/automation.json index f7fd35889..6fa3e8fae 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/automation.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/automation.json @@ -96,7 +96,9 @@ "TEAM_MESSAGE_INPUT_PLACEHOLDER": "Escreva sua mensagem aqui", "TEAM_DROPDOWN_PLACEHOLDER": "Selecione times", "EMAIL_INPUT_PLACEHOLDER": "Insira o e-mail", - "URL_INPUT_PLACEHOLDER": "Insira a URL" + "URL_INPUT_PLACEHOLDER": "Insira a URL", + "SCHEDULED_MESSAGE_DELAY_LABEL": "Atraso (minutos)", + "SCHEDULED_MESSAGE_DELAY_PLACEHOLDER": "Insira o atraso em minutos" }, "TOGGLE": { "ACTIVATION_TITLE": "Ativar regra de automação", @@ -147,6 +149,7 @@ "SEND_WEBHOOK_EVENT": "Enviar evento de Webhook", "SEND_ATTACHMENT": "Enviar Anexo", "SEND_MESSAGE": "Enviar Mensagem", + "CREATE_SCHEDULED_MESSAGE": "Criar Mensagem Agendada", "ADD_PRIVATE_NOTE": "Adicionar uma Nota Privada", "CHANGE_PRIORITY": "Alterar Prioridade", "ADD_SLA": "Adicionar SLA", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json index befaa0a58..aa65c89c7 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json @@ -237,7 +237,8 @@ "REMOVE_PREVIEW": "Remover o encadeamento de e-mails citado", "COLLAPSE": "Recolher a prévia", "EXPAND": "Expandir a prévia" - } + }, + "SCHEDULE_SEND": "Agendar envio" }, "VISIBLE_TO_AGENTS": "Mensagem Privada: Apenas visível para você e seu time", "CHANGE_STATUS": "Estado da conversa mudou", @@ -362,6 +363,7 @@ }, "ACCORDION": { "CONTACT_DETAILS": "Detalhes do contato", + "SCHEDULED_MESSAGES": "Mensagens agendadas", "CONVERSATION_ACTIONS": "Ações da conversa", "CONVERSATION_LABELS": "Etiquetas da conversa", "CONVERSATION_INFO": "Informação da conversa", @@ -392,6 +394,69 @@ } } }, + "SCHEDULED_MESSAGES": { + "NEW_BUTTON": "Agendar mensagem", + "PAST_MESSAGES_SECTION": "Enviadas", + "EMPTY_STATE": "Ainda não há mensagens agendadas.", + "STATUS": { + "DRAFT": "Rascunho", + "PENDING": "Pendente", + "SENT": "Enviada", + "FAILED": "Falhou" + }, + "ITEM": { + "SCHEDULED_FOR": "Agendada para {time}", + "NO_SCHEDULE": "Sem agendamento", + "TEMPLATE_PREVIEW": "Template: {name}", + "ATTACHMENT_PREVIEW": "Anexo: {filename}", + "ATTACHMENT_LABEL": "Anexo: {filename}", + "EMPTY_PREVIEW": "Sem conteúdo", + "EXPAND": "Expandir", + "COLLAPSE": "Recolher" + }, + "MODAL": { + "TITLE_NEW": "Agendar mensagem", + "TITLE_EDIT": "Editar mensagem agendada", + "MESSAGE_LABEL": "Mensagem", + "MESSAGE_PLACEHOLDER": "Escreva sua mensagem...", + "DATETIME_LABEL": "Data e hora de envio", + "DATETIME_PLACEHOLDER": "Selecione data e hora", + "ATTACHMENT_LABEL": "Anexo", + "ATTACHMENT_ADD": "Anexar arquivo", + "ATTACHMENT_CURRENT": "Anexo atual: {filename}", + "CANCEL": "Cancelar", + "SAVE_DRAFT": "Salvar como rascunho", + "SCHEDULE": "Agendar" + }, + "CONFIRM_CLOSE": { + "TITLE": "Alterações não salvas", + "MESSAGE": "Você tem conteúdo não salvo. Deseja descartar suas alterações?", + "CONTINUE_EDITING": "Continuar editando", + "DISCARD": "Descartar", + "CANCEL": "Cancelar" + }, + "CONFIRM_DELETE": { + "TITLE": "Excluir mensagem agendada", + "MESSAGE": "Tem certeza de que deseja excluir esta mensagem agendada? Esta ação não pode ser desfeita.", + "CANCEL": "Cancelar", + "DELETE": "Excluir" + }, + "ERRORS": { + "CONTENT_REQUIRED": "Adicione uma mensagem, template ou anexo antes de salvar.", + "CONTENT_TOO_LONG": "A mensagem é muito longa. Máximo de {maxLength} caracteres permitidos.", + "DATETIME_REQUIRED": "Selecione uma data e hora para agendar a mensagem.", + "SCHEDULE_IN_PAST": "O horário agendado deve ser no futuro.", + "SAVE_FAILED": "Não foi possível salvar a mensagem agendada. Por favor, tente novamente.", + "DELETE_FAILED": "Não foi possível excluir a mensagem agendada. Por favor, tente novamente." + }, + "META": { + "TOOLTIP": "Agendada em {time} por {author}", + "YOU": "Você", + "AUTHOR_YOU": "{name} (Você)", + "AUTOMATION": "Automação", + "UNKNOWN_AUTHOR": "Desconhecido" + } + }, "CONVERSATION_CUSTOM_ATTRIBUTES": { "ADD_BUTTON_TEXT": "Criar atributo", "NO_RECORDS_FOUND": "Nenhum atributo encontrado", diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index b163bfdc8..c148625fa 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -15,6 +15,7 @@ import ConversationAction from './ConversationAction.vue'; import ConversationParticipant from './ConversationParticipant.vue'; import ContactInfo from './contact/ContactInfo.vue'; import ContactNotes from './contact/ContactNotes.vue'; +import ScheduledMessages from './scheduledMessages/ScheduledMessages.vue'; import ConversationInfo from './ConversationInfo.vue'; import CustomAttributes from './customAttributes/CustomAttributes.vue'; import Draggable from 'vuedraggable'; @@ -150,7 +151,26 @@ onMounted(() => { >