From c6bfd1eed3e65a9c2dab4531dedb657b8dbec505 Mon Sep 17 00:00:00 2001 From: "Cayo P. R. Oliveira" Date: Thu, 19 Mar 2026 22:51:14 -0300 Subject: [PATCH] feat: schedule messages recurrence (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scheduled-messages): add recurring scheduled messages Implements the recurring scheduled messages feature allowing agents to configure recurrence rules when scheduling messages, with automatic creation of subsequent scheduled messages after each send. Backend: - RecurringScheduledMessage model with JSONB recurrence_rule validation - RecurrenceCalculatorService for next occurrence date calculation - RecurrenceDescriptionService for human-readable rule descriptions - CreateNextOccurrenceService for auto-creating child ScheduledMessages - RecurringScheduledMessagesController with CRUD operations - RecurringScheduledMessagePolicy for authorization - Modified SendScheduledMessageJob to handle recurrence after send - Updated due_for_sending scope to exclude resolved conversations - ActionCable events for real-time updates - Activity message i18n (en + pt-BR) Frontend: - RecurrenceDropdown.vue with contextual shortcut options - RecurrenceCustomModal.vue for custom recurrence configuration - RecurringScheduledMessageItem.vue for sidebar display - Integration into ScheduledMessageModal.vue - Updated ScheduledMessages.vue with recurrence section and filtering - Vuex store module for recurring scheduled messages - API client for CRUD operations - WebSocket handlers in actionCable.js - recurrenceHelpers.js utility functions - i18n keys for en and pt-BR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): fix creation and edit visibility - Fix API payload key mismatch (snake_case vs camelCase) in modal submit - Add status: 'active' to recurring creation payload - Fix strong params to permit recurrence_rule array fields (week_days) - Cast string values from strong params to integers for JSONB validation - Show RecurrenceDropdown when editing (remove isEditing gate) - Populate recurrenceRule from scheduled message's recurring parent - Include recurring_scheduled_message_id and recurrence_rule in scheduled message jbuilder response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): fix dropdown toggle and locale tag errors - Use DropdownItem :click prop instead of @click to use the injected closeMenu from DropdownContainer context (default slot doesn't expose toggle) - Normalize locale from pt_BR to pt-BR for Intl.DateTimeFormat compatibility in RecurringScheduledMessageItem Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(recurring-messages): add separator line and expandable history - Add border separator between recurring messages section and pending/draft messages, matching the history section separator - Replace static 'N enviadas' counter with clickable toggle that expands to show individual sent/failed child messages with status badges and formatted timestamps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(recurring-messages): add click-to-navigate on sent children Make sent child messages in recurring message history clickable. Clicking navigates to the actual message in the conversation using the messageId query param, same pattern as ScheduledMessageItem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(recurring-messages): allow editing recurring messages - Add edit button to RecurringScheduledMessageItem (active only) - Transform recurring message into scheduledMessage-compatible shape with recurring_scheduled_message_id set, so the modal reuses the existing update path - Handle edge case of removing recurrence from a recurring message (cancels series without trying to update a non-existent standalone) - Sent history is preserved by the backend update action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): show recurrence field without date selection Remove v-if="scheduledDate" gate so the recurrence dropdown is always visible in the modal. Falls back to today's date for contextual shortcut labels when no date is selected yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): ensure recurrence visible on edit and add pending_scheduled_message to API - Add pending_scheduled_message to recurring_scheduled_message jbuilder so REST API data matches WebSocket push_event_data - Add fallback in openEditRecurringModal to find pending child from scheduled_messages array when pending_scheduled_message is absent - Add same fallback in nextSendLabel computed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scheduled-messages): add sections for Drafts and Pending messages in the UI * refactor(scheduled-messages): merge RecurringScheduledMessageItem into ScheduledMessageItem Consolidate the recurring message card into the existing ScheduledMessageItem component instead of maintaining a separate component. The unified component detects recurring messages via recurrence_rule and conditionally shows: - Recurrence description header with repeat icon - Next send time label - Expandable sent/failed children history with click-to-navigate - Stop button (replaces delete) with confirmation modal - Active/completed/cancelled status badges Delete the now-unused RecurringScheduledMessageItem.vue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scheduled-messages): use blue badge for active, keep green for sent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scheduled-messages): remove draft and pending sections from UI and update recurrence title * fix(recurring-messages): use Teleport for recurrence dropdown Replace DropdownContainer with Teleport-based floating dropdown so options render outside the modal. Fixes: - Dropdown no longer enlarges the modal or causes scrolling - Dropdown closes before Custom modal opens (no overlap) - Auto-detects available space and opens above/below trigger Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): recalculate next date when recurrence rule changes When editing a recurring message and changing the recurrence rule, the pending occurrence date is now validated against the new rule. If the user-provided date doesn't match (e.g. Thursday removed from weekly days), the system computes the next valid date using RecurrenceCalculatorService instead of blindly using the old date. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scheduled-messages): resolve deprecated onClose and disabled type warnings - Replace :on-close prop with @close event on woot-modal components - Cast hasTemplate computed to boolean to fix disabled prop type check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scheduled-messages): replace remaining deprecated on-close props - ScheduledMessages.vue delete confirm modal - RecurrenceCustomModal.vue modal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): address review feedback on PR #240 - Fix v-if/v-else bug hiding message list behind resolved warning - Fix occurrences_sent incrementing on failed sends (skip_increment flag) - Fix compute_next_valid_date using .min instead of .max - Fix Vuex delete action to update state on cancel (not remove) - Use atomic update_counters for occurrences_sent increment - Add safe Date.iso8601 parsing with rescue in should_complete? - Add null: false to occurrences_sent migration column - Fix pt-BR accent: Recorrencia → Recorrências - Use I18n.with_locale(account.locale) for all activity messages - Fix N+1 in jbuilder partials (Ruby filtering + eager loading) - Add interval >= 1 validation to RecurrenceCustomModal isValid - Validate recurrence_rule presence when status is active - Add ISO8601 date format validation for end_date - Add unknown_agent i18n key for fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): wrap create/update in transactions, clean pending on deactivation - Wrap create and update flows in ActiveRecord transactions - Move attachment purge after save! to prevent data loss on validation failure - Destroy pending children when status transitions to non-active - Fixes critical bug where stopping recurrence could leave armed pending messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): prevent monthly/yearly day-of-month drift Store the original intended day in recurrence_rule JSONB as month_day (for monthly) and year_day/year_month (for yearly). The calculator now uses these stored values instead of @last_date.day, preventing drift after short months cap the day (e.g., Jan 31 → Feb 28 → all subsequent months stuck on 28). Backend: - RecurrenceCalculatorService: use rule[:month_day] for monthly and rule[:year_day]/rule[:year_month] for yearly calculations - Controller: permit and cast the new integer keys Frontend: - recurrenceHelpers: yearly shortcuts include year_day/year_month - RecurrenceCustomModal: emit month_day for monthly day_of_month rules and year_day/year_month for yearly rules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): i18n description services, align due_for_sending scope Backend: - RecurrenceDescriptionService: replace hardcoded English with I18n.t() calls; accept locale parameter and use I18n.with_locale - RecurringScheduledMessage model: pass account locale to description service - ScheduledMessage: align due_for_sending? instance method with scope by checking conversation status (open/pending) Frontend: - buildRecurrenceDescription: use t() i18n function instead of manual isPt locale branching - Add DESCRIPTION i18n keys to en/conversation.json and pt_BR/conversation.json - Update RecurrenceDropdown and ScheduledMessageItem callers to pass t i18n: - Add recurring_scheduled_messages.description.* keys to en.yml and pt_BR.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): keep pending child when deactivating recurrence When editing a recurring message to disable recurrence while setting a future send date, the pending child message is now preserved instead of being destroyed. This allows a 'final send' without creating new recurrences (the send job already guards with recurring&.active?). Backend: - Add update_pending_on_deactivation: updates pending child's scheduled_at or creates a final pending occurrence - Replace destroy_all in update's non-active branch Frontend: - activeRecurringMessages now includes non-active recurring messages that still have a pending child (pending_scheduled_message) - Stop button hidden for already-cancelled recurring messages - inactiveRecurringMessages excludes messages with pending children Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): prevent removing recurrence from existing recurring message Once a scheduled message has recurrence, the 'Does not repeat' option is hidden from the RecurrenceDropdown when editing. This avoids edge cases where deactivating recurrence leaves the message in an ambiguous display state. - RecurrenceDropdown: add hideNoRepeat prop, filter NO_REPEAT from shortcuts - ScheduledMessageModal: pass hideNoRepeat when isEditingRecurring - Revert update_pending_on_deactivation (no longer reachable from UI) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): address CodeRabbit review feedback - ScheduledMessage#push_event_data: expose recurring_scheduled_message_id in ActionCable payloads so frontend correctly classifies children - RecurringScheduledMessagePolicy: add agent_bot? check for parity with ScheduledMessagePolicy - RecurrenceCalculatorService: guard against nil/empty week_days - Factory: bind inbox and account to conversation to prevent cross-account flakiness in specs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(recurring-messages): update schema to enforce non-null constraint on occurrences_sent --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: gabrieljablonski --- ...recurring_scheduled_messages_controller.rb | 176 ++++++++++ .../scheduled_messages_controller.rb | 1 + .../api/recurringScheduledMessages.js | 81 +++++ .../components/ScheduledMessageItem.vue | 222 +++++++++++- .../dashboard/helper/actionCable.js | 18 + .../dashboard/helper/recurrenceHelpers.js | 311 +++++++++++++++++ .../i18n/locale/en/conversation.json | 82 +++++ .../i18n/locale/pt_BR/conversation.json | 82 +++++ .../RecurrenceCustomModal.vue | 317 ++++++++++++++++++ .../scheduledMessages/RecurrenceDropdown.vue | 149 ++++++++ .../ScheduledMessageModal.vue | 83 ++++- .../scheduledMessages/ScheduledMessages.vue | 122 ++++++- app/javascript/dashboard/store/index.js | 2 + .../store/modules/conversations/actions.js | 30 ++ .../modules/recurringScheduledMessages.js | 214 ++++++++++++ .../dashboard/store/mutation-types.js | 7 + .../send_scheduled_message_job.rb | 43 +++ app/listeners/action_cable_listener.rb | 24 ++ app/models/account.rb | 1 + app/models/conversation.rb | 1 + app/models/inbox.rb | 1 + app/models/recurring_scheduled_message.rb | 174 ++++++++++ app/models/scheduled_message.rb | 72 ++-- app/models/user.rb | 1 + .../recurring_scheduled_message_policy.rb | 51 +++ .../create_next_occurrence_service.rb | 97 ++++++ .../recurrence_calculator_service.rb | 109 ++++++ .../recurrence_description_service.rb | 71 ++++ .../create.json.jbuilder | 1 + .../destroy.json.jbuilder | 1 + .../index.json.jbuilder | 5 + .../update.json.jbuilder | 1 + ..._recurring_scheduled_message.json.jbuilder | 37 ++ .../models/_scheduled_message.json.jbuilder | 5 + config/locales/en.yml | 31 ++ config/locales/pt_BR.yml | 31 ++ config/routes.rb | 1 + ...000_create_recurring_scheduled_messages.rb | 22 ++ ...eduled_message_id_to_scheduled_messages.rb | 6 + db/schema.rb | 28 ++ lib/events/types.rb | 5 + .../factories/recurring_scheduled_messages.rb | 18 + 42 files changed, 2682 insertions(+), 52 deletions(-) create mode 100644 app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb create mode 100644 app/javascript/dashboard/api/recurringScheduledMessages.js create mode 100644 app/javascript/dashboard/helper/recurrenceHelpers.js create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceCustomModal.vue create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceDropdown.vue create mode 100644 app/javascript/dashboard/store/modules/recurringScheduledMessages.js create mode 100644 app/models/recurring_scheduled_message.rb create mode 100644 app/policies/recurring_scheduled_message_policy.rb create mode 100644 app/services/recurring_scheduled_messages/create_next_occurrence_service.rb create mode 100644 app/services/recurring_scheduled_messages/recurrence_calculator_service.rb create mode 100644 app/services/recurring_scheduled_messages/recurrence_description_service.rb create mode 100644 app/views/api/v1/accounts/conversations/recurring_scheduled_messages/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/conversations/recurring_scheduled_messages/destroy.json.jbuilder create mode 100644 app/views/api/v1/accounts/conversations/recurring_scheduled_messages/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/conversations/recurring_scheduled_messages/update.json.jbuilder create mode 100644 app/views/api/v1/models/_recurring_scheduled_message.json.jbuilder create mode 100644 db/migrate/20260318180000_create_recurring_scheduled_messages.rb create mode 100644 db/migrate/20260318180001_add_recurring_scheduled_message_id_to_scheduled_messages.rb create mode 100644 spec/factories/recurring_scheduled_messages.rb diff --git a/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb new file mode 100644 index 000000000..1d325361c --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb @@ -0,0 +1,176 @@ +class Api::V1::Accounts::Conversations::RecurringScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + + before_action :set_recurring_scheduled_message, only: [:update, :destroy] + + MAX_LIMIT = 50 + + def index + authorize build_recurring_scheduled_message + @recurring_scheduled_messages = @conversation.recurring_scheduled_messages + .includes(:scheduled_messages, :author) + .order(Arel.sql('CASE status WHEN 1 THEN 0 WHEN 0 THEN 1 ELSE 2 END, created_at DESC')) + .limit(MAX_LIMIT) + end + + def create + @recurring_scheduled_message = build_recurring_scheduled_message + authorize @recurring_scheduled_message + @recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params) + + ActiveRecord::Base.transaction do + @recurring_scheduled_message.save! + create_first_occurrence if @recurring_scheduled_message.active? + end + + dispatch_event(RECURRING_SCHEDULED_MESSAGE_CREATED) + end + + def update + @recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params) + + ActiveRecord::Base.transaction do + @recurring_scheduled_message.save! + @recurring_scheduled_message.attachment.purge if params[:remove_attachment].present? && @recurring_scheduled_message.attachment.attached? + + if @recurring_scheduled_message.active? + reschedule_pending_occurrence + else + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + end + end + + dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED) + end + + def destroy + cancel_recurring_message + dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED) + end + + private + + def set_recurring_scheduled_message + @recurring_scheduled_message = @conversation.recurring_scheduled_messages.find(params[:id]) + authorize @recurring_scheduled_message + end + + def build_recurring_scheduled_message + @conversation.recurring_scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user) + end + + def recurring_scheduled_message_params + permitted = params.permit( + :content, + :status, + :attachment, + template_params: {}, + recurrence_rule: [:frequency, :interval, :end_type, :end_date, :end_count, + :monthly_type, :monthly_week, :monthly_weekday, :month_day, + :year_day, :year_month, { week_days: [] }] + ) + + permitted[:recurrence_rule] = cast_recurrence_rule(permitted[:recurrence_rule].to_h) if permitted[:recurrence_rule].present? + + permitted + end + + def cast_recurrence_rule(rule) + integer_keys = %w[interval end_count monthly_week monthly_weekday month_day year_day year_month] + rule.each_with_object({}) do |(key, value), hash| + hash[key] = if key == 'week_days' && value.is_a?(Array) + value.map(&:to_i) + elsif integer_keys.include?(key) + value.to_i + else + value + end + end + end + + def create_first_occurrence + scheduled_at = params[:scheduled_at] + return if scheduled_at.blank? + + sm = @recurring_scheduled_message.scheduled_messages.create!( + content: @recurring_scheduled_message.content, + template_params: @recurring_scheduled_message.template_params, + scheduled_at: scheduled_at, + status: :pending, + account: @recurring_scheduled_message.account, + conversation: @recurring_scheduled_message.conversation, + inbox: @recurring_scheduled_message.inbox, + author: @recurring_scheduled_message.author + ) + copy_attachment(sm) if @recurring_scheduled_message.attachment.attached? + end + + def reschedule_pending_occurrence + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + + next_scheduled_at = compute_next_valid_date + return if next_scheduled_at.blank? + + sm = @recurring_scheduled_message.scheduled_messages.create!( + content: @recurring_scheduled_message.content, + template_params: @recurring_scheduled_message.template_params, + scheduled_at: next_scheduled_at, + status: :pending, + account: @recurring_scheduled_message.account, + conversation: @recurring_scheduled_message.conversation, + inbox: @recurring_scheduled_message.inbox, + author: @recurring_scheduled_message.author + ) + copy_attachment(sm) if @recurring_scheduled_message.attachment.attached? + end + + def compute_next_valid_date + user_date = params[:scheduled_at].present? ? Time.zone.parse(params[:scheduled_at].to_s) : nil + rule = @recurring_scheduled_message.recurrence_rule + + return user_date if user_date.present? && date_matches_rule?(user_date, rule) + + base = [user_date, Time.current].compact.max + RecurringScheduledMessages::RecurrenceCalculatorService + .new(recurrence_rule: rule, last_date: base) + .next_date + end + + def date_matches_rule?(date, rule) + return true unless rule.is_a?(Hash) + + rule = rule.with_indifferent_access + return true unless rule[:frequency] == 'weekly' && rule[:week_days].present? + + rule[:week_days].map(&:to_i).include?(date.wday) + end + + def cancel_recurring_message + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + @recurring_scheduled_message.update!(status: :cancelled) + + I18n.with_locale(@recurring_scheduled_message.account.locale) do + @recurring_scheduled_message.conversation.messages.create!( + account: @recurring_scheduled_message.account, + inbox: @recurring_scheduled_message.inbox, + message_type: :activity, + content: I18n.t( + 'conversations.activity.recurring_message_cancelled', + agent: @recurring_scheduled_message.author&.name || I18n.t('conversations.activity.unknown_agent') + ) + ) + end + end + + def copy_attachment(scheduled_message) + scheduled_message.attachment.attach(@recurring_scheduled_message.attachment.blob) + end + + def dispatch_event(event_name) + Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, recurring_scheduled_message: @recurring_scheduled_message) + end +end + +Api::V1::Accounts::Conversations::RecurringScheduledMessagesController.prepend_mod_with( + 'Api::V1::Accounts::Conversations::RecurringScheduledMessagesController' +) diff --git a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb index f50d327d6..9ae9ac6bc 100644 --- a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb @@ -8,6 +8,7 @@ class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::A def index authorize build_scheduled_message @scheduled_messages = @conversation.scheduled_messages + .includes(:recurring_scheduled_message) .order(scheduled_at: :desc) .limit(MAX_LIMIT) end diff --git a/app/javascript/dashboard/api/recurringScheduledMessages.js b/app/javascript/dashboard/api/recurringScheduledMessages.js new file mode 100644 index 000000000..c0af4b82d --- /dev/null +++ b/app/javascript/dashboard/api/recurringScheduledMessages.js @@ -0,0 +1,81 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildRecurringScheduledMessagePayload = ({ + content, + status, + scheduledAt, + templateParams, + attachment, + removeAttachment, + recurrenceRule, +} = {}) => { + if (!attachment) { + return { + content, + status, + scheduled_at: scheduledAt, + template_params: templateParams, + remove_attachment: removeAttachment || undefined, + recurrence_rule: recurrenceRule, + }; + } + + 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)); + } + if (recurrenceRule) { + Object.entries(recurrenceRule).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => + payload.append(`recurrence_rule[${key}][]`, String(v)) + ); + } else { + payload.append(`recurrence_rule[${key}]`, String(value)); + } + }); + } + + return payload; +}; + +class RecurringScheduledMessagesAPI extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + get(conversationId) { + return axios.get( + `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages` + ); + } + + create(conversationId, payload) { + return axios({ + method: 'post', + url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`, + data: buildRecurringScheduledMessagePayload(payload), + }); + } + + update(conversationId, recurringScheduledMessageId, payload) { + return axios({ + method: 'patch', + url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`, + data: buildRecurringScheduledMessagePayload(payload), + }); + } + + delete(conversationId, recurringScheduledMessageId) { + return axios.delete( + `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}` + ); + } +} + +export default new RecurringScheduledMessagesAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue index 2d1df490a..f9ce8680e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue @@ -9,6 +9,7 @@ import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue'; +import { buildRecurrenceDescription } from 'dashboard/helper/recurrenceHelpers'; const props = defineProps({ scheduledMessage: { @@ -33,15 +34,23 @@ const props = defineProps({ }, }); -const emit = defineEmits(['edit', 'delete']); +const emit = defineEmits(['edit', 'delete', 'stop']); const noteContentRef = useTemplateRef('noteContentRef'); const [isExpanded, toggleExpanded] = useToggle(); const showToggle = ref(false); +const showHistory = ref(false); +const showStopConfirm = ref(false); const { t, locale } = useI18n(); const { formatMessage } = useMessageFormatter(); const route = useRoute(); const router = useRouter(); +const normalizedLocale = computed(() => locale.value.replace('_', '-')); + +const isRecurring = computed(() => + Boolean(props.scheduledMessage?.recurrence_rule) +); + const statusConfig = { draft: { labelKey: 'SCHEDULED_MESSAGES.STATUS.DRAFT', @@ -59,6 +68,18 @@ const statusConfig = { labelKey: 'SCHEDULED_MESSAGES.STATUS.FAILED', class: 'bg-n-ruby-9/10 text-n-ruby-11', }, + active: { + labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_ACTIVE', + class: 'bg-n-brand/10 text-n-blue-text', + }, + completed: { + labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_COMPLETED', + class: 'bg-n-slate-3 text-n-slate-11', + }, + cancelled: { + labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_CANCELLED', + class: 'bg-n-ruby-3 text-n-ruby-11', + }, }; const author = computed(() => props.scheduledMessage?.author || null); @@ -87,7 +108,28 @@ const statusBadge = computed(() => { label: t(config.labelKey), }; }); -const scheduledAt = computed(() => props.scheduledMessage?.scheduled_at); + +const recurrenceDescription = computed(() => { + if (!isRecurring.value) return ''; + return buildRecurrenceDescription( + props.scheduledMessage.recurrence_rule, + t, + normalizedLocale.value + ); +}); + +const scheduledAt = computed(() => { + if (isRecurring.value) { + const pending = + props.scheduledMessage.pending_scheduled_message || + props.scheduledMessage.scheduled_messages?.find( + sm => sm.status === 'pending' + ); + return pending?.scheduled_at || null; + } + return props.scheduledMessage?.scheduled_at; +}); + const formattedScheduledTime = computed(() => { if (!scheduledAt.value) return ''; const date = fromUnixTime(scheduledAt.value); @@ -104,7 +146,7 @@ const formattedScheduledTime = computed(() => { options.year = 'numeric'; } - return date.toLocaleString(locale.value.replace('_', '-'), options); + return date.toLocaleString(normalizedLocale.value, options); }); const templateName = computed(() => { @@ -138,6 +180,44 @@ const hasPreviewContent = computed(() => Boolean(previewContent.value)); const formattedContent = computed(() => formatMessage(previewContent.value)); +// Recurring: completed children history +const completedChildren = computed(() => { + if (!isRecurring.value) return []; + const children = props.scheduledMessage.scheduled_messages || []; + return children + .filter(m => ['sent', 'failed'].includes(m.status)) + .sort((a, b) => (b.scheduled_at || 0) - (a.scheduled_at || 0)); +}); + +const hasCompletedChildren = computed(() => completedChildren.value.length > 0); + +const formatChildTime = childScheduledAt => { + if (!childScheduledAt) return ''; + const date = new Date(childScheduledAt * 1000); + const now = new Date(); + const options = { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }; + if (date.getFullYear() !== now.getFullYear()) { + options.year = 'numeric'; + } + return date.toLocaleString(normalizedLocale.value, options); +}; + +const canNavigateToChild = child => + child.status === 'sent' && Boolean(child.message_id); + +const scrollToChildMessage = child => { + if (!canNavigateToChild(child)) return; + router.replace({ + ...route, + query: { ...route.query, messageId: child.message_id }, + }); +}; + const checkOverflow = () => { if (!props.collapsible) { showToggle.value = false; @@ -151,10 +231,21 @@ const checkOverflow = () => { }; const onEdit = () => emit('edit', props.scheduledMessage); -const onDelete = () => emit('delete', props.scheduledMessage); +const onDelete = () => { + if (isRecurring.value) { + showStopConfirm.value = true; + } else { + emit('delete', props.scheduledMessage); + } +}; +const confirmStop = () => { + emit('stop', props.scheduledMessage); + showStopConfirm.value = false; +}; const canScrollToMessage = computed( () => + !isRecurring.value && props.scheduledMessage?.status === 'sent' && Boolean(props.scheduledMessage?.message_id) ); @@ -190,6 +281,15 @@ watch(previewContent, () => { " @click="scrollToMessage" > + +
+ + {{ recurrenceDescription }} +
+
{ class="flex items-center gap-1 text-xs text-n-slate-11 mb-0" > - {{ formattedScheduledTime }} + {{ + isRecurring + ? t('SCHEDULED_MESSAGES.RECURRENCE.NEXT_SEND', { + time: formattedScheduledTime, + }) + : formattedScheduledTime + }}

{{ t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE') }} @@ -235,15 +341,15 @@ watch(previewContent, () => { color="slate" size="xs" icon="i-lucide-pencil" - @click="onEdit" + @click.stop="onEdit" />

@@ -316,5 +422,105 @@ watch(previewContent, () => { }} + + +
+ +
+ + +
+
+
+ + + {{ formatChildTime(child.scheduled_at) }} + +
+ + {{ + t( + child.status === 'sent' + ? 'SCHEDULED_MESSAGES.STATUS.SENT' + : 'SCHEDULED_MESSAGES.STATUS.FAILED' + ) + }} + +
+
+ + + +
+

+ {{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.TITLE') }} +

+

+ {{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.MESSAGE') }} +

+
+
+
+
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 3c10853d8..60cbc42d2 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -39,6 +39,12 @@ class ActionCableConnector extends BaseActionCableConnector { 'scheduled_message.created': this.onScheduledMessageCreated, 'scheduled_message.updated': this.onScheduledMessageUpdated, 'scheduled_message.deleted': this.onScheduledMessageDeleted, + 'recurring_scheduled_message.created': + this.onRecurringScheduledMessageCreated, + 'recurring_scheduled_message.updated': + this.onRecurringScheduledMessageUpdated, + 'recurring_scheduled_message.deleted': + this.onRecurringScheduledMessageDeleted, }; } @@ -143,6 +149,18 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('handleScheduledMessageDeleted', data); }; + onRecurringScheduledMessageCreated = data => { + this.app.$store.dispatch('handleRecurringScheduledMessageCreated', data); + }; + + onRecurringScheduledMessageUpdated = data => { + this.app.$store.dispatch('handleRecurringScheduledMessageUpdated', data); + }; + + onRecurringScheduledMessageDeleted = data => { + this.app.$store.dispatch('handleRecurringScheduledMessageDeleted', data); + }; + onTypingOn = ({ conversation, user }) => { const conversationId = conversation.id; diff --git a/app/javascript/dashboard/helper/recurrenceHelpers.js b/app/javascript/dashboard/helper/recurrenceHelpers.js new file mode 100644 index 000000000..5106fe6d8 --- /dev/null +++ b/app/javascript/dashboard/helper/recurrenceHelpers.js @@ -0,0 +1,311 @@ +const WEEKDAY_NAMES_EN = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; + +const WEEKDAY_NAMES_PT = [ + 'domingo', + 'segunda-feira', + 'terça-feira', + 'quarta-feira', + 'quinta-feira', + 'sexta-feira', + 'sábado', +]; + +const WEEKDAY_NAMES_SHORT_EN = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', +]; + +const WEEKDAY_NAMES_SHORT_PT = [ + 'dom', + 'seg', + 'ter', + 'qua', + 'qui', + 'sex', + 'sáb', +]; + +const ORDINALS_EN = { + 1: 'first', + 2: 'second', + 3: 'third', + 4: 'fourth', + 5: 'fifth', + '-1': 'last', +}; + +const ORDINALS_PT = { + 1: '1º', + 2: '2º', + 3: '3º', + 4: '4º', + 5: '5º', + '-1': 'último(a)', +}; + +const MONTH_NAMES_EN = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const MONTH_NAMES_PT = [ + 'janeiro', + 'fevereiro', + 'março', + 'abril', + 'maio', + 'junho', + 'julho', + 'agosto', + 'setembro', + 'outubro', + 'novembro', + 'dezembro', +]; + +export const FREQUENCY_OPTIONS = ['daily', 'weekly', 'monthly', 'yearly']; + +/** + * Get which week occurrence of a weekday a date falls on in its month, + * and whether it is the last occurrence of that weekday in the month. + */ +export function getWeekOccurrence(date) { + const dayOfMonth = date.getDate(); + const week = Math.ceil(dayOfMonth / 7); + + const nextWeek = new Date(date); + nextWeek.setDate(dayOfMonth + 7); + const isLast = nextWeek.getMonth() !== date.getMonth(); + + return { week, isLast }; +} + +/** + * Generate contextual recurrence shortcut options based on a selected date. + */ +export function getRecurrenceShortcuts(date) { + if (!date) return []; + + const dayOfWeek = date.getDay(); + const { week, isLast } = getWeekOccurrence(date); + + return [ + { + label: 'NO_REPEAT', + value: null, + }, + { + label: 'DAILY', + value: { + frequency: 'daily', + interval: 1, + end_type: 'never', + }, + }, + { + label: 'WEEKLY', + labelParams: { day: dayOfWeek }, + value: { + frequency: 'weekly', + interval: 1, + week_days: [dayOfWeek], + end_type: 'never', + }, + }, + { + label: isLast ? 'MONTHLY_LAST' : 'MONTHLY_NTH', + labelParams: { + nth: isLast ? -1 : week, + day: dayOfWeek, + }, + value: { + frequency: 'monthly', + interval: 1, + monthly_type: 'day_of_week', + monthly_week: isLast ? -1 : week, + monthly_weekday: dayOfWeek, + end_type: 'never', + }, + }, + { + label: 'YEARLY', + labelParams: { + month: date.getMonth(), + dayNum: date.getDate(), + }, + value: { + frequency: 'yearly', + interval: 1, + year_day: date.getDate(), + year_month: date.getMonth() + 1, + end_type: 'never', + }, + }, + { + label: 'WEEKDAYS', + value: { + frequency: 'weekly', + interval: 1, + week_days: [1, 2, 3, 4, 5], + end_type: 'never', + }, + }, + { + label: 'CUSTOM', + value: 'custom', + }, + ]; +} + +function getWeekdayNames(locale) { + return locale?.startsWith('pt') ? WEEKDAY_NAMES_PT : WEEKDAY_NAMES_EN; +} + +function getWeekdayShortNames(locale) { + return locale?.startsWith('pt') + ? WEEKDAY_NAMES_SHORT_PT + : WEEKDAY_NAMES_SHORT_EN; +} + +function getOrdinals(locale) { + return locale?.startsWith('pt') ? ORDINALS_PT : ORDINALS_EN; +} + +function getMonthNames(locale) { + return locale?.startsWith('pt') ? MONTH_NAMES_PT : MONTH_NAMES_EN; +} + +/** + * Build a human-readable description of a recurrence rule. + */ +export function buildRecurrenceDescription(rule, t, locale = 'en') { + if (!rule || !rule.frequency) return ''; + + const weekdayShortNames = getWeekdayShortNames(locale); + const weekdayNames = getWeekdayNames(locale); + const ordinals = getOrdinals(locale); + const descKey = key => `SCHEDULED_MESSAGES.RECURRENCE.DESCRIPTION.${key}`; + + const intervalDesc = (interval, oneKey, otherKey) => { + if (interval === 1) return t(descKey(oneKey)); + return t(descKey(otherKey), { count: interval }); + }; + + let description = ''; + + switch (rule.frequency) { + case 'daily': + description = intervalDesc( + rule.interval || 1, + 'DAILY_ONE', + 'DAILY_OTHER' + ); + break; + + case 'weekly': { + const days = (rule.week_days || []) + .sort((a, b) => a - b) + .map(d => weekdayShortNames[d]); + const prefix = intervalDesc( + rule.interval || 1, + 'WEEKLY_ONE', + 'WEEKLY_OTHER' + ); + description = days.length + ? t(descKey('WEEKLY_ON'), { prefix, days: days.join(', ') }) + : prefix; + break; + } + + case 'monthly': { + const prefix = intervalDesc( + rule.interval || 1, + 'MONTHLY_ONE', + 'MONTHLY_OTHER' + ); + if (rule.monthly_type === 'day_of_week') { + const ordinal = + ordinals[String(rule.monthly_week)] || rule.monthly_week; + const weekday = weekdayNames[rule.monthly_weekday] || ''; + description = t(descKey('MONTHLY_ON_WEEKDAY'), { + prefix, + ordinal, + weekday, + }); + } else { + description = prefix; + } + break; + } + + case 'yearly': + description = intervalDesc( + rule.interval || 1, + 'YEARLY_ONE', + 'YEARLY_OTHER' + ); + break; + + default: + return ''; + } + + if (rule.end_type === 'on_date' && rule.end_date) { + description += ` · ${t(descKey('UNTIL_DATE'), { date: rule.end_date })}`; + } else if (rule.end_type === 'after_count' && rule.end_count) { + description += ` · ${t(descKey('AFTER_COUNT'), { count: rule.end_count })}`; + } + + return description; +} + +/** + * Format a shortcut label with its parameters for display. + */ +export function formatShortcutLabel(shortcut, t, locale = 'en') { + const { label, labelParams } = shortcut; + const weekdayNames = getWeekdayNames(locale); + const ordinals = getOrdinals(locale); + const monthNames = getMonthNames(locale); + + if (!labelParams) return t(`SCHEDULED_MESSAGES.RECURRENCE.${label}`); + + const params = {}; + if (labelParams.day !== undefined) { + params.day = weekdayNames[labelParams.day]; + } + if (labelParams.nth !== undefined) { + params.nth = ordinals[String(labelParams.nth)] || labelParams.nth; + } + if (labelParams.month !== undefined) { + params.month = monthNames[labelParams.month]; + } + if (labelParams.dayNum !== undefined) { + params.dayNum = labelParams.dayNum; + } + + return t(`SCHEDULED_MESSAGES.RECURRENCE.${label}`, params); +} diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 433676385..92fe4b5ed 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -494,6 +494,88 @@ "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." + }, + "RECURRENCE": { + "SECTION_TITLE": "Recurrences", + "NO_REPEAT": "Does not repeat", + "DAILY": "Every day", + "WEEKLY": "Weekly on {day}", + "MONTHLY_NTH": "Monthly on the {nth} {day}", + "MONTHLY_LAST": "Monthly on the last {day}", + "YEARLY": "Yearly on {month} {dayNum}", + "WEEKDAYS": "Every weekday (Mon-Fri)", + "CUSTOM": "Custom...", + "CUSTOM_MODAL": { + "TITLE": "Custom recurrence", + "REPEAT_EVERY": "Repeat every", + "FREQ_DAILY": "day(s)", + "FREQ_WEEKLY": "week(s)", + "FREQ_MONTHLY": "month(s)", + "FREQ_YEARLY": "year(s)", + "UNIT_DAY": "day | days", + "UNIT_WEEK": "week | weeks", + "UNIT_MONTH": "month | months", + "UNIT_YEAR": "year | years", + "REPEAT_ON": "Repeat on", + "MONTHLY_ON_DAY": "Day of month", + "MONTHLY_ON_WEEKDAY": "Day of week", + "ENDS": "Ends", + "ENDS_NEVER": "Never", + "ENDS_ON_DATE": "On date", + "ENDS_AFTER": "After", + "ENDS_OCCURRENCES": "occurrences", + "DONE": "Done", + "CANCEL": "Cancel" + }, + "STATUS_ACTIVE": "Active", + "STATUS_DRAFT": "Draft", + "STATUS_COMPLETED": "Completed", + "STATUS_CANCELLED": "Cancelled", + "OCCURRENCES_SENT": "{count} sent", + "NEXT_SEND": "Next: {time}", + "EXPAND": "Expand history", + "COLLAPSE": "Collapse history", + "STOP": "Stop recurrence", + "STOP_CONFIRM": { + "TITLE": "Stop recurrence", + "MESSAGE": "The pending message will be removed and the recurrence will be permanently stopped.", + "CONFIRM": "Stop", + "CANCEL": "Cancel" + }, + "EDIT": "Edit recurrence", + "EDIT_WARNING": "Next send: {oldDate} → {newDate}", + "WEEKDAYS_SHORT": { + "SUN": "S", + "MON": "M", + "TUE": "T", + "WED": "W", + "THU": "T", + "FRI": "F", + "SAT": "S" + }, + "ORDINALS": { + "FIRST": "first", + "SECOND": "second", + "THIRD": "third", + "FOURTH": "fourth", + "FIFTH": "fifth", + "LAST": "last" + }, + "DESCRIPTION": { + "DAILY_ONE": "Every day", + "DAILY_OTHER": "Every {count} days", + "WEEKLY_ONE": "Every week", + "WEEKLY_OTHER": "Every {count} weeks", + "WEEKLY_ON": "{prefix}: {days}", + "MONTHLY_ONE": "Monthly", + "MONTHLY_OTHER": "Every {count} months", + "MONTHLY_ON_WEEKDAY": "{prefix} on the {ordinal} {weekday}", + "YEARLY_ONE": "Every year", + "YEARLY_OTHER": "Every {count} years", + "UNTIL_DATE": "until {date}", + "AFTER_COUNT": "{count} occurrences" + }, + "RESOLVED_WARNING": "Scheduled messages will not be sent while the conversation is resolved. Reopen the conversation to resume sending." } }, "CONVERSATION_CUSTOM_ATTRIBUTES": { diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json index f6e7b07d4..784f797ef 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json @@ -483,6 +483,88 @@ "AUTHOR_YOU": "{name} (Você)", "AUTOMATION": "Automação", "UNKNOWN_AUTHOR": "Desconhecido" + }, + "RECURRENCE": { + "SECTION_TITLE": "Recorrências", + "NO_REPEAT": "Não se repete", + "DAILY": "Todos os dias", + "WEEKLY": "Semanal: cada {day}", + "MONTHLY_NTH": "Mensal no(a) {nth} {day}", + "MONTHLY_LAST": "Mensal no(a) último(a) {day}", + "YEARLY": "Anual em {dayNum} de {month}", + "WEEKDAYS": "Todos os dias úteis (seg-sex)", + "CUSTOM": "Personalizado...", + "CUSTOM_MODAL": { + "TITLE": "Recorrência personalizada", + "REPEAT_EVERY": "Repetir a cada", + "FREQ_DAILY": "dia(s)", + "FREQ_WEEKLY": "semana(s)", + "FREQ_MONTHLY": "mês(es)", + "FREQ_YEARLY": "ano(s)", + "UNIT_DAY": "dia | dias", + "UNIT_WEEK": "semana | semanas", + "UNIT_MONTH": "mês | meses", + "UNIT_YEAR": "ano | anos", + "REPEAT_ON": "Repetir em", + "MONTHLY_ON_DAY": "Dia do mês", + "MONTHLY_ON_WEEKDAY": "Dia da semana", + "ENDS": "Termina", + "ENDS_NEVER": "Nunca", + "ENDS_ON_DATE": "Em data", + "ENDS_AFTER": "Após", + "ENDS_OCCURRENCES": "ocorrências", + "DONE": "Concluir", + "CANCEL": "Cancelar" + }, + "STATUS_ACTIVE": "Ativa", + "STATUS_DRAFT": "Rascunho", + "STATUS_COMPLETED": "Concluída", + "STATUS_CANCELLED": "Cancelada", + "OCCURRENCES_SENT": "{count} enviadas", + "NEXT_SEND": "Próxima: {time}", + "EXPAND": "Expandir histórico", + "COLLAPSE": "Recolher histórico", + "STOP": "Parar recorrência", + "STOP_CONFIRM": { + "TITLE": "Parar recorrência", + "MESSAGE": "A mensagem pendente será removida e a recorrência será encerrada permanentemente.", + "CONFIRM": "Parar", + "CANCEL": "Cancelar" + }, + "EDIT": "Editar recorrência", + "EDIT_WARNING": "Próximo envio: {oldDate} → {newDate}", + "WEEKDAYS_SHORT": { + "SUN": "D", + "MON": "S", + "TUE": "T", + "WED": "Q", + "THU": "Q", + "FRI": "S", + "SAT": "S" + }, + "ORDINALS": { + "FIRST": "1º", + "SECOND": "2º", + "THIRD": "3º", + "FOURTH": "4º", + "FIFTH": "5º", + "LAST": "último(a)" + }, + "DESCRIPTION": { + "DAILY_ONE": "Todos os dias", + "DAILY_OTHER": "A cada {count} dias", + "WEEKLY_ONE": "Semanal", + "WEEKLY_OTHER": "A cada {count} semanas", + "WEEKLY_ON": "{prefix}: {days}", + "MONTHLY_ONE": "Mensal", + "MONTHLY_OTHER": "A cada {count} meses", + "MONTHLY_ON_WEEKDAY": "{prefix} no(a) {ordinal} {weekday}", + "YEARLY_ONE": "Anual", + "YEARLY_OTHER": "A cada {count} anos", + "UNTIL_DATE": "até {date}", + "AFTER_COUNT": "{count} ocorrências" + }, + "RESOLVED_WARNING": "Mensagens agendadas não serão enviadas enquanto a conversa estiver resolvida. Reabra a conversa para retomar o envio." } }, "CONVERSATION_CUSTOM_ATTRIBUTES": { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceCustomModal.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceCustomModal.vue new file mode 100644 index 000000000..63d235fed --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceCustomModal.vue @@ -0,0 +1,317 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceDropdown.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceDropdown.vue new file mode 100644 index 000000000..d79d5958c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceDropdown.vue @@ -0,0 +1,149 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue index 920609c1d..3855dbc65 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue @@ -18,6 +18,8 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import WhatsappTemplates from 'dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue'; import ScheduleDateShortcuts from './ScheduleDateShortcuts.vue'; +import RecurrenceDropdown from './RecurrenceDropdown.vue'; +import RecurrenceCustomModal from './RecurrenceCustomModal.vue'; const props = defineProps({ show: { @@ -55,6 +57,11 @@ const inboxGetter = useMapGetter('inboxes/getInbox'); const uiFlags = useMapGetter('scheduledMessages/getUIFlags'); const isEditing = computed(() => !!props.scheduledMessage?.id); +const isEditingRecurring = computed( + () => + isEditing.value && + String(props.scheduledMessage?.id).startsWith('recurring-') +); const isCreating = computed(() => uiFlags.value.isCreating); const isUpdating = computed(() => uiFlags.value.isUpdating); const isSubmitting = computed(() => isCreating.value || isUpdating.value); @@ -78,6 +85,8 @@ const showWhatsAppTemplatesModal = ref(false); const contentError = ref(false); const contentLengthError = ref(false); const dateTimeError = ref(''); +const recurrenceRule = ref(null); +const showRecurrenceCustomModal = ref(false); // Original values for change detection const originalContent = ref(''); @@ -98,6 +107,7 @@ const resetForm = () => { templateParams.value = null; contentError.value = false; dateTimeError.value = ''; + recurrenceRule.value = null; // Reset original values originalContent.value = ''; originalScheduledAt.value = null; @@ -114,6 +124,7 @@ const setFormFromMessage = scheduledMessage => { templateParams.value = scheduledMessage.template_params || null; existingAttachment.value = scheduledMessage.attachment || null; attachments.value = []; + recurrenceRule.value = scheduledMessage.recurrence_rule || null; if (scheduledMessage.scheduled_at) { const dateValue = new Date(scheduledMessage.scheduled_at * 1000); @@ -161,7 +172,7 @@ const scheduledAt = computed(() => { const hasContent = computed(() => Boolean(messageContent.value?.trim())); const hasNewAttachment = computed(() => attachments.value.length > 0); const hasTemplate = computed( - () => templateParams.value && Object.keys(templateParams.value).length + () => !!(templateParams.value && Object.keys(templateParams.value).length) ); const hasExistingAttachment = computed(() => !!existingAttachment.value); const showAttachmentUpload = computed( @@ -394,7 +405,54 @@ const submit = async status => { if (!validatePayload(status)) return; try { - if (isEditing.value) { + const hasRecurrence = !!recurrenceRule.value; + const existingRecurringId = + props.scheduledMessage?.recurring_scheduled_message_id; + + if (hasRecurrence && status === 'pending') { + const recurringPayload = { + content: messageContent.value, + scheduledAt: scheduledAt.value ? scheduledAt.value.toISOString() : null, + recurrenceRule: recurrenceRule.value, + attachment: resolveAttachmentPayload(), + templateParams: templateParams.value, + status: 'active', + }; + + if (isEditing.value && existingRecurringId) { + // Update existing recurring series + await store.dispatch('recurringScheduledMessages/update', { + conversationId: props.conversationId, + recurringScheduledMessageId: existingRecurringId, + payload: recurringPayload, + }); + } else { + // Create new recurring series (new message or standalone gaining recurrence) + await store.dispatch('recurringScheduledMessages/create', { + conversationId: props.conversationId, + payload: recurringPayload, + }); + // If converting a standalone message, delete the old one + if (isEditing.value) { + await store.dispatch('scheduledMessages/delete', { + conversationId: props.conversationId, + scheduledMessageId: props.scheduledMessage.id, + }); + } + } + } else if (isEditing.value) { + // Editing without recurrence - if it had a recurring parent and user removed it, cancel the series + if (existingRecurringId && !hasRecurrence) { + await store.dispatch('recurringScheduledMessages/delete', { + conversationId: props.conversationId, + recurringScheduledMessageId: existingRecurringId, + }); + // If this was a direct recurring message edit, just close — no standalone to update + if (isEditingRecurring.value) { + closeModal(); + return; + } + } await store.dispatch('scheduledMessages/update', { conversationId: props.conversationId, scheduledMessageId: props.scheduledMessage.id, @@ -473,10 +531,10 @@ watch(