feat: schedule messages recurrence (#240)

* 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 <contact@gabrieljablonski.com>
This commit is contained in:
Cayo P. R. Oliveira 2026-03-19 22:51:14 -03:00 committed by GitHub
parent 8ffdc16faf
commit c6bfd1eed3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2682 additions and 52 deletions

View File

@ -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'
)

View File

@ -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

View File

@ -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();

View File

@ -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"
>
<!-- Recurrence description header -->
<div
v-if="isRecurring"
class="flex items-center gap-1.5 text-xs text-n-slate-11"
>
<Icon icon="i-lucide-repeat" class="size-3 shrink-0" />
<span class="truncate">{{ recurrenceDescription }}</span>
</div>
<div class="flex items-center gap-3">
<Avatar
:name="avatarName"
@ -211,7 +311,13 @@ watch(previewContent, () => {
class="flex items-center gap-1 text-xs text-n-slate-11 mb-0"
>
<Icon icon="i-lucide-alarm-clock" class="size-3 shrink-0" />
{{ formattedScheduledTime }}
{{
isRecurring
? t('SCHEDULED_MESSAGES.RECURRENCE.NEXT_SEND', {
time: formattedScheduledTime,
})
: formattedScheduledTime
}}
</p>
<p v-else class="text-xs text-n-slate-11 mb-0">
{{ 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"
/>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
@click="onDelete"
:icon="isRecurring ? 'i-lucide-square' : 'i-lucide-trash'"
@click.stop="onDelete"
/>
</div>
</div>
@ -316,5 +422,105 @@ watch(previewContent, () => {
}}
</span>
</div>
<!-- Recurring: sent/failed history toggle -->
<div v-if="isRecurring && hasCompletedChildren" class="text-xs">
<button
class="flex items-center gap-1 text-n-slate-10 hover:text-n-slate-12 cursor-pointer transition-colors"
@click.stop="showHistory = !showHistory"
>
<Icon
:icon="showHistory ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="size-3"
/>
<span>
{{
t('SCHEDULED_MESSAGES.RECURRENCE.OCCURRENCES_SENT', {
count: completedChildren.length,
})
}}
</span>
</button>
</div>
<!-- Recurring: expanded history list -->
<div
v-if="isRecurring && showHistory && hasCompletedChildren"
class="flex flex-col gap-1 border-t border-n-weak pt-2"
>
<div
v-for="child in completedChildren"
:key="child.id"
class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors"
:class="{
'cursor-pointer hover:bg-n-alpha-2': canNavigateToChild(child),
}"
@click.stop="scrollToChildMessage(child)"
>
<div class="flex items-center gap-2 min-w-0">
<Icon
:icon="
child.status === 'sent'
? 'i-lucide-check-circle'
: 'i-lucide-x-circle'
"
class="size-3 shrink-0"
:class="
child.status === 'sent' ? 'text-n-teal-11' : 'text-n-ruby-11'
"
/>
<span class="text-n-slate-11">
{{ formatChildTime(child.scheduled_at) }}
</span>
</div>
<span
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0"
:class="
child.status === 'sent'
? 'bg-n-teal-9/10 text-n-teal-11'
: 'bg-n-ruby-9/10 text-n-ruby-11'
"
>
{{
t(
child.status === 'sent'
? 'SCHEDULED_MESSAGES.STATUS.SENT'
: 'SCHEDULED_MESSAGES.STATUS.FAILED'
)
}}
</span>
</div>
</div>
<!-- Stop recurrence confirmation modal -->
<woot-modal
v-if="isRecurring"
v-model:show="showStopConfirm"
size="small"
@close="() => (showStopConfirm = false)"
>
<div class="flex w-full flex-col gap-4 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.MESSAGE') }}
</p>
<div class="flex items-center justify-end gap-3">
<Button
variant="faded"
color="slate"
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CANCEL')"
@click="showStopConfirm = false"
/>
<Button
variant="solid"
color="ruby"
:label="t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.CONFIRM')"
@click="confirmStop"
/>
</div>
</div>
</woot-modal>
</div>
</template>

View File

@ -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;

View File

@ -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);
}

View File

@ -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": {

View File

@ -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": {

View File

@ -0,0 +1,317 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { FREQUENCY_OPTIONS } from 'dashboard/helper/recurrenceHelpers';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
modelValue: {
type: Object,
default: null,
},
scheduledDate: {
type: Date,
default: null,
},
});
const emit = defineEmits(['update:modelValue', 'close']);
const { t } = useI18n();
const frequency = ref('weekly');
const interval = ref(1);
const weekDays = ref([]);
const monthlyType = ref('day_of_month');
const monthlyWeek = ref(1);
const monthlyWeekday = ref(0);
const endType = ref('never');
const endDate = ref('');
const endCount = ref(10);
const WEEKDAY_LABELS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
const isWeekly = computed(() => frequency.value === 'weekly');
const isMonthly = computed(() => frequency.value === 'monthly');
const hasValidWeekDays = computed(
() => !isWeekly.value || weekDays.value.length > 0
);
const hasValidEndDate = computed(
() => endType.value !== 'on_date' || endDate.value
);
const hasValidEndCount = computed(
() => endType.value !== 'after_count' || endCount.value >= 1
);
const hasValidInterval = computed(() => interval.value >= 1);
const isValid = computed(
() =>
hasValidInterval.value &&
hasValidWeekDays.value &&
hasValidEndDate.value &&
hasValidEndCount.value
);
const toggleWeekDay = day => {
const idx = weekDays.value.indexOf(day);
if (idx === -1) {
weekDays.value.push(day);
} else if (weekDays.value.length > 1) {
weekDays.value.splice(idx, 1);
}
};
const resetToDefaults = () => {
frequency.value = 'weekly';
interval.value = 1;
weekDays.value = props.scheduledDate ? [props.scheduledDate.getDay()] : [1];
monthlyType.value = 'day_of_month';
monthlyWeek.value = 1;
monthlyWeekday.value = 0;
endType.value = 'never';
endDate.value = '';
endCount.value = 10;
};
const initFromRule = rule => {
if (!rule) {
resetToDefaults();
return;
}
frequency.value = rule.frequency || 'weekly';
interval.value = rule.interval || 1;
weekDays.value = rule.week_days ? [...rule.week_days] : [];
monthlyType.value = rule.monthly_type || 'day_of_month';
monthlyWeek.value = rule.monthly_week || 1;
monthlyWeekday.value = rule.monthly_weekday || 0;
endType.value = rule.end_type || 'never';
endDate.value = rule.end_date || '';
endCount.value = rule.end_count || 10;
};
const buildRule = () => {
const rule = {
frequency: frequency.value,
interval: interval.value,
end_type: endType.value,
};
if (isWeekly.value) {
rule.week_days = [...weekDays.value].sort();
}
if (isMonthly.value) {
rule.monthly_type = monthlyType.value;
if (monthlyType.value === 'day_of_week') {
rule.monthly_week = monthlyWeek.value;
rule.monthly_weekday = monthlyWeekday.value;
} else if (props.scheduledDate) {
rule.month_day = props.scheduledDate.getDate();
}
}
if (frequency.value === 'yearly' && props.scheduledDate) {
rule.year_day = props.scheduledDate.getDate();
rule.year_month = props.scheduledDate.getMonth() + 1;
}
if (endType.value === 'on_date') {
rule.end_date = endDate.value;
}
if (endType.value === 'after_count') {
rule.end_count = endCount.value;
}
return rule;
};
const onDone = () => {
if (!isValid.value) return;
emit('update:modelValue', buildRule());
emit('close');
};
const onCancel = () => {
emit('close');
};
watch(
() => props.show,
isVisible => {
if (isVisible) {
initFromRule(props.modelValue);
if (!weekDays.value.length && props.scheduledDate) {
weekDays.value = [props.scheduledDate.getDay()];
}
}
}
);
watch(frequency, newFrequency => {
if (newFrequency === 'weekly' && weekDays.value.length === 0) {
weekDays.value = props.scheduledDate ? [props.scheduledDate.getDay()] : [1];
}
});
</script>
<template>
<woot-modal :show="show" size="small" @close="onCancel">
<div class="flex w-full flex-col gap-5 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.TITLE') }}
</h3>
<!-- Frequency -->
<div class="flex items-center gap-3">
<span class="text-sm text-n-slate-12 whitespace-nowrap">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.REPEAT_EVERY') }}
</span>
<input
v-model.number="interval"
type="number"
min="1"
class="w-16 rounded-lg border border-n-weak px-2 py-1.5 text-sm text-n-slate-12"
/>
<select
v-model="frequency"
class="rounded-lg border border-n-weak px-2 py-1.5 text-sm text-n-slate-12"
>
<option v-for="freq in FREQUENCY_OPTIONS" :key="freq" :value="freq">
{{
t(
`SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.FREQ_${freq.toUpperCase()}`
)
}}
</option>
</select>
</div>
<!-- Week Days (weekly only) -->
<div v-if="isWeekly" class="flex flex-col gap-2">
<span class="text-sm text-n-slate-12">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.REPEAT_ON') }}
</span>
<div class="flex gap-1">
<button
v-for="(label, index) in WEEKDAY_LABELS"
:key="index"
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium transition-colors"
:class="
weekDays.includes(index)
? 'bg-n-blue-9 text-white'
: 'bg-n-alpha-1 text-n-slate-11 hover:bg-n-alpha-2'
"
@click="toggleWeekDay(index)"
>
{{ t(`SCHEDULED_MESSAGES.RECURRENCE.WEEKDAYS_SHORT.${label}`) }}
</button>
</div>
</div>
<!-- Monthly Type (monthly only) -->
<div v-if="isMonthly" class="flex flex-col gap-2">
<label class="flex items-center gap-2 text-sm text-n-slate-12">
<input
v-model="monthlyType"
type="radio"
value="day_of_month"
class="accent-n-blue-9"
/>
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.MONTHLY_ON_DAY') }}
</label>
<label class="flex items-center gap-2 text-sm text-n-slate-12">
<input
v-model="monthlyType"
type="radio"
value="day_of_week"
class="accent-n-blue-9"
/>
{{
t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.MONTHLY_ON_WEEKDAY')
}}
</label>
</div>
<!-- End Condition -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.ENDS') }}
</span>
<label class="flex items-center gap-2 text-sm text-n-slate-12">
<input
v-model="endType"
type="radio"
value="never"
class="accent-n-blue-9"
/>
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.ENDS_NEVER') }}
</label>
<label class="flex items-center gap-2 text-sm text-n-slate-12">
<input
v-model="endType"
type="radio"
value="on_date"
class="accent-n-blue-9"
/>
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.ENDS_ON_DATE') }}
<input
v-if="endType === 'on_date'"
v-model="endDate"
type="date"
class="ml-2 rounded-lg border border-n-weak px-2 py-1 text-sm"
/>
</label>
<label class="flex items-center gap-2 text-sm text-n-slate-12">
<input
v-model="endType"
type="radio"
value="after_count"
class="accent-n-blue-9"
/>
{{ t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.ENDS_AFTER') }}
<input
v-if="endType === 'after_count'"
v-model.number="endCount"
type="number"
min="1"
class="ml-2 w-16 rounded-lg border border-n-weak px-2 py-1 text-sm"
/>
<span
v-if="endType === 'after_count'"
class="text-sm text-n-slate-11"
>
{{
t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.ENDS_OCCURRENCES')
}}
</span>
</label>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-2">
<NextButton
ghost
slate
:label="t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.CANCEL')"
@click="onCancel"
/>
<NextButton
solid
blue
:label="t('SCHEDULED_MESSAGES.RECURRENCE.CUSTOM_MODAL.DONE')"
:disabled="!isValid"
@click="onDone"
/>
</div>
</div>
</woot-modal>
</template>

View File

@ -0,0 +1,149 @@
<script setup>
import { computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import {
getRecurrenceShortcuts,
formatShortcutLabel,
buildRecurrenceDescription,
} from 'dashboard/helper/recurrenceHelpers';
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
scheduledDate: {
type: Date,
default: null,
},
hideNoRepeat: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'openCustom']);
const { t, locale } = useI18n();
const isOpen = ref(false);
const triggerRef = ref(null);
const dropdownStyle = ref({});
const shortcuts = computed(() => {
const all = getRecurrenceShortcuts(props.scheduledDate || new Date());
return props.hideNoRepeat ? all.filter(s => s.label !== 'NO_REPEAT') : all;
});
const selectedLabel = computed(() => {
if (!props.modelValue) {
return t('SCHEDULED_MESSAGES.RECURRENCE.NO_REPEAT');
}
const match = shortcuts.value.find(
s => s.value && JSON.stringify(s.value) === JSON.stringify(props.modelValue)
);
if (match) {
return formatShortcutLabel(match, t, locale.value);
}
return buildRecurrenceDescription(props.modelValue, t, locale.value);
});
const updatePosition = () => {
if (!triggerRef.value) return;
const rect = triggerRef.value.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownHeight = shortcuts.value.length * 40 + 16;
const openAbove = spaceBelow < dropdownHeight && rect.top > spaceBelow;
dropdownStyle.value = {
position: 'fixed',
left: `${rect.left}px`,
width: `${rect.width}px`,
zIndex: 10001,
...(openAbove
? { bottom: `${window.innerHeight - rect.top + 4}px` }
: { top: `${rect.bottom + 4}px` }),
};
};
const toggleDropdown = async () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
await nextTick();
updatePosition();
}
};
const close = () => {
isOpen.value = false;
};
const onSelect = shortcut => {
close();
if (shortcut.value === 'custom') {
emit('openCustom');
} else {
emit('update:modelValue', shortcut.value);
}
};
const isSelected = shortcut => {
return JSON.stringify(shortcut.value) === JSON.stringify(props.modelValue);
};
</script>
<template>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-n-slate-12">
{{ t('SCHEDULED_MESSAGES.RECURRENCE.SECTION_TITLE') }}
</span>
<button
ref="triggerRef"
class="flex items-center gap-2 rounded-lg border border-n-weak px-3 py-2 text-sm text-n-slate-12 hover:bg-n-alpha-1 w-full justify-between"
@click="toggleDropdown"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-repeat"
class="size-4"
:class="modelValue ? 'text-n-blue-10' : 'text-n-slate-11'"
/>
<span>{{ selectedLabel }}</span>
</div>
<Icon icon="i-lucide-chevron-down" class="size-4 text-n-slate-11" />
</button>
<Teleport to="body">
<div
v-if="isOpen"
v-on-click-outside="close"
:style="dropdownStyle"
class="bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak rounded-xl shadow-lg py-1"
>
<button
v-for="shortcut in shortcuts"
:key="shortcut.label"
class="flex items-center gap-3 w-full px-3 py-2 text-sm text-n-slate-12 hover:bg-n-alpha-2 cursor-pointer transition-colors text-left"
:class="{ 'bg-n-alpha-1': isSelected(shortcut) }"
@click="onSelect(shortcut)"
>
<Icon
:icon="
shortcut.label === 'CUSTOM'
? 'i-lucide-settings-2'
: 'i-lucide-repeat'
"
class="size-4 text-n-slate-11 shrink-0"
/>
<span>{{ formatShortcutLabel(shortcut, t, locale) }}</span>
</button>
</div>
</Teleport>
</div>
</template>

View File

@ -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(
<template>
<woot-modal
v-model:show="showModal"
:on-close="handleClose"
close-on-backdrop-click
class="[&_.modal-container]:!w-[45rem] [&_.modal-container]:!max-w-[90%]"
size="medium"
@close="handleClose"
>
<div class="flex w-full flex-col gap-6 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
@ -503,7 +561,7 @@ watch(
:placeholder="t('SCHEDULED_MESSAGES.MODAL.MESSAGE_PLACEHOLDER')"
:channel-type="currentInbox?.channel_type"
:medium="currentInbox?.medium"
:disabled="hasTemplate"
:disabled="!!hasTemplate"
:enable-copilot="false"
override-line-breaks
@update:model-value="
@ -591,6 +649,21 @@ watch(
</span>
</div>
<RecurrenceDropdown
v-model="recurrenceRule"
:scheduled-date="scheduledDateTime"
:hide-no-repeat="isEditingRecurring"
@open-custom="showRecurrenceCustomModal = true"
/>
<RecurrenceCustomModal
:show="showRecurrenceCustomModal"
:model-value="recurrenceRule"
:scheduled-date="scheduledDateTime"
@update:model-value="recurrenceRule = $event"
@close="showRecurrenceCustomModal = false"
/>
<div class="flex items-center justify-end gap-3">
<NextButton
faded
@ -639,9 +712,9 @@ watch(
<woot-modal
v-model:show="showConfirmClose"
:on-close="() => {}"
:show-close-button="false"
size="small"
@close="() => {}"
>
<div class="flex w-full flex-col gap-4 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">

View File

@ -28,6 +28,16 @@ const scheduledMessagesGetter = useMapGetter(
'scheduledMessages/getAllByConversation'
);
const uiFlags = useMapGetter('scheduledMessages/getUIFlags');
const recurringMessagesGetter = useMapGetter(
'recurringScheduledMessages/getAllByConversation'
);
const conversationGetter = useMapGetter('getConversationById');
const isConversationResolved = computed(() => {
if (!props.conversationId) return false;
const conversation = conversationGetter.value(props.conversationId);
return conversation?.status === 'resolved';
});
const isFetching = computed(() => uiFlags.value.isFetching);
const isDeleting = computed(() => uiFlags.value.isDeleting);
@ -42,18 +52,48 @@ const scheduledMessages = computed(() => {
return scheduledMessagesGetter.value(props.conversationId) || [];
});
const recurringMessages = computed(() => {
if (!props.conversationId) return [];
return recurringMessagesGetter.value(props.conversationId) || [];
});
const activeRecurringMessages = computed(() =>
recurringMessages.value.filter(m => m.status === 'active')
);
const inactiveRecurringMessages = computed(() =>
recurringMessages.value.filter(m => {
if (!['completed', 'cancelled'].includes(m.status)) return false;
const children = m.scheduled_messages || [];
return children.some(c => ['sent', 'failed'].includes(c.status));
})
);
// IDs of scheduled messages that belong to a recurring series (to filter from one-off list)
const recurringChildIds = computed(() => {
const ids = new Set();
scheduledMessages.value.forEach(m => {
if (m.recurring_scheduled_message_id) ids.add(m.id);
});
return ids;
});
const standaloneMessages = computed(() =>
scheduledMessages.value.filter(m => !recurringChildIds.value.has(m.id))
);
const draftMessages = computed(() =>
scheduledMessages.value.filter(message => message.status === 'draft')
standaloneMessages.value.filter(message => message.status === 'draft')
);
const pendingMessages = computed(() =>
scheduledMessages.value
standaloneMessages.value
.filter(message => message.status === 'pending')
.sort((a, b) => (a.scheduled_at || 0) - (b.scheduled_at || 0))
);
const historyMessages = computed(() =>
scheduledMessages.value
standaloneMessages.value
.filter(message => ['sent', 'failed'].includes(message.status))
.sort((a, b) => (b.scheduled_at || 0) - (a.scheduled_at || 0))
);
@ -67,6 +107,7 @@ const hasHistory = computed(() => historyMessages.value.length > 0);
const fetchScheduledMessages = conversationId => {
if (!conversationId) return;
store.dispatch('scheduledMessages/get', { conversationId });
store.dispatch('recurringScheduledMessages/get', { conversationId });
};
const getWrittenBy = scheduledMessage => {
@ -94,6 +135,26 @@ const openEditModal = message => {
shouldShowModal.value = true;
};
const openEditRecurringModal = recurringMessage => {
// Transform recurring message into a scheduledMessage-compatible shape
// so the modal can reuse the same edit flow
const pendingChild =
recurringMessage.pending_scheduled_message ||
recurringMessage.scheduled_messages?.find(sm => sm.status === 'pending');
editingMessage.value = {
id: `recurring-${recurringMessage.id}`,
content: recurringMessage.content,
scheduled_at: pendingChild?.scheduled_at || null,
recurrence_rule: recurringMessage.recurrence_rule,
recurring_scheduled_message_id: recurringMessage.id,
template_params: recurringMessage.template_params,
attachment: recurringMessage.attachment,
author: recurringMessage.author,
author_type: recurringMessage.author_type,
};
shouldShowModal.value = true;
};
const closeModal = () => {
shouldShowModal.value = false;
editingMessage.value = null;
@ -123,6 +184,17 @@ const confirmDelete = async () => {
}
};
const stopRecurring = async recurringMessage => {
try {
await store.dispatch('recurringScheduledMessages/delete', {
conversationId: props.conversationId,
recurringScheduledMessageId: recurringMessage.id,
});
} catch (error) {
useAlert(t('SCHEDULED_MESSAGES.ERRORS.DELETE_FAILED'));
}
};
watch(
() => props.conversationId,
newConversationId => {
@ -147,7 +219,19 @@ watch(
<ScheduledMessageSkeletonLoader v-if="isFetching" :rows="3" />
<div v-else class="flex flex-col max-h-[400px] overflow-y-auto">
<!-- Resolved conversation warning -->
<div
v-if="
isConversationResolved &&
(hasActiveMessages || activeRecurringMessages.length)
"
class="mx-4 mb-2 flex items-start gap-2 rounded-lg bg-n-amber-2 px-3 py-2 text-xs text-n-amber-11"
>
<i class="i-lucide-alert-triangle mt-0.5 shrink-0" />
<span>{{ t('SCHEDULED_MESSAGES.RECURRENCE.RESOLVED_WARNING') }}</span>
</div>
<div class="flex flex-col max-h-[400px] overflow-y-auto">
<!-- Draft Messages -->
<template v-if="draftMessages.length">
<ScheduledMessageItem
@ -164,6 +248,22 @@ watch(
/>
</template>
<!-- Active Recurring Messages -->
<template v-if="activeRecurringMessages.length">
<ScheduledMessageItem
v-for="rm in activeRecurringMessages"
:key="rm.id"
class="px-4 py-4"
:scheduled-message="rm"
:written-by="getWrittenBy(rm)"
allow-edit
allow-delete
collapsible
@edit="openEditRecurringModal"
@stop="stopRecurring"
/>
</template>
<!-- Pending Messages -->
<template v-if="pendingMessages.length">
<ScheduledMessageItem
@ -189,7 +289,7 @@ watch(
</p>
<!-- History Section -->
<template v-if="hasHistory">
<template v-if="hasHistory || inactiveRecurringMessages.length">
<div
class="flex items-center gap-2 px-4 pt-4 pb-2 border-t border-n-weak"
>
@ -197,6 +297,16 @@ watch(
{{ t('SCHEDULED_MESSAGES.PAST_MESSAGES_SECTION') }}
</span>
</div>
<ScheduledMessageItem
v-for="rm in inactiveRecurringMessages"
:key="rm.id"
class="px-4 py-4"
:scheduled-message="rm"
:written-by="getWrittenBy(rm)"
:allow-edit="false"
:allow-delete="false"
collapsible
/>
<ScheduledMessageItem
v-for="message in historyMessages"
:key="message.id"
@ -220,8 +330,8 @@ watch(
<woot-modal
v-model:show="showDeleteConfirm"
:on-close="closeDeleteConfirm"
size="small"
@close="closeDeleteConfirm"
>
<div class="flex w-full flex-col gap-4 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">

View File

@ -42,6 +42,7 @@ import macros from './modules/macros';
import notifications from './modules/notifications';
import portals from './modules/helpCenterPortals';
import reports from './modules/reports';
import recurringScheduledMessages from './modules/recurringScheduledMessages';
import scheduledMessages from './modules/scheduledMessages';
import sla from './modules/sla';
import slaReports from './modules/SLAReports';
@ -107,6 +108,7 @@ export default createStore({
notifications,
portals,
reports,
recurringScheduledMessages,
scheduledMessages,
sla,
slaReports,

View File

@ -467,6 +467,36 @@ const actions = {
dispatch('scheduledMessages/removeFromEvent', scheduledMessage);
},
handleRecurringScheduledMessageCreated(
{ dispatch },
recurringScheduledMessage
) {
dispatch(
'recurringScheduledMessages/upsertFromEvent',
recurringScheduledMessage
);
},
handleRecurringScheduledMessageUpdated(
{ dispatch },
recurringScheduledMessage
) {
dispatch(
'recurringScheduledMessages/upsertFromEvent',
recurringScheduledMessage
);
},
handleRecurringScheduledMessageDeleted(
{ dispatch },
recurringScheduledMessage
) {
dispatch(
'recurringScheduledMessages/removeFromEvent',
recurringScheduledMessage
);
},
setActiveInbox({ commit }, inboxId) {
commit(types.SET_ACTIVE_INBOX, inboxId);
},

View File

@ -0,0 +1,214 @@
import types from '../mutation-types';
import RecurringScheduledMessagesAPI from '../../api/recurringScheduledMessages';
export const state = {
records: {},
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
export const getters = {
getAllByConversation: _state => conversationId => {
return _state.records[Number(conversationId)] || [];
},
getUIFlags(_state) {
return _state.uiFlags;
},
};
export const actions = {
async get({ commit }, { conversationId }) {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isFetching: true,
});
try {
const normalizedConversationId = Number(conversationId);
const { data } = await RecurringScheduledMessagesAPI.get(
normalizedConversationId
);
commit(types.SET_RECURRING_SCHEDULED_MESSAGES, {
conversationId: normalizedConversationId,
data: data.payload,
});
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isFetching: false,
});
}
},
async create({ commit }, { conversationId, payload }) {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isCreating: true,
});
try {
const normalizedConversationId = Number(conversationId);
const { data } = await RecurringScheduledMessagesAPI.create(
normalizedConversationId,
payload
);
commit(types.ADD_RECURRING_SCHEDULED_MESSAGE, {
conversationId: normalizedConversationId,
data,
});
return data;
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isCreating: false,
});
}
},
async update(
{ commit },
{ conversationId, recurringScheduledMessageId, payload }
) {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isUpdating: true,
});
try {
const normalizedConversationId = Number(conversationId);
const { data } = await RecurringScheduledMessagesAPI.update(
normalizedConversationId,
recurringScheduledMessageId,
payload
);
commit(types.UPDATE_RECURRING_SCHEDULED_MESSAGE, {
conversationId: normalizedConversationId,
data,
});
return data;
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isUpdating: false,
});
}
},
async delete({ commit }, { conversationId, recurringScheduledMessageId }) {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isDeleting: true,
});
try {
const normalizedConversationId = Number(conversationId);
const response = await RecurringScheduledMessagesAPI.delete(
normalizedConversationId,
recurringScheduledMessageId
);
commit(types.UPDATE_RECURRING_SCHEDULED_MESSAGE, {
conversationId: normalizedConversationId,
data: response.data,
});
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG, {
isDeleting: false,
});
}
},
upsertFromEvent({ commit, state: localState }, recurringScheduledMessage) {
const conversationId = Number(recurringScheduledMessage.conversation_id);
const records = localState.records[conversationId] || [];
const exists = records.some(
record => record.id === recurringScheduledMessage.id
);
commit(
exists
? types.UPDATE_RECURRING_SCHEDULED_MESSAGE
: types.ADD_RECURRING_SCHEDULED_MESSAGE,
{ conversationId, data: recurringScheduledMessage }
);
},
removeFromEvent({ commit }, recurringScheduledMessage) {
commit(types.DELETE_RECURRING_SCHEDULED_MESSAGE, {
conversationId: Number(recurringScheduledMessage.conversation_id),
recurringScheduledMessageId: recurringScheduledMessage.id,
});
},
};
export const mutations = {
[types.SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.SET_RECURRING_SCHEDULED_MESSAGES]($state, { conversationId, data }) {
$state.records = {
...$state.records,
[Number(conversationId)]: data,
};
},
[types.ADD_RECURRING_SCHEDULED_MESSAGE]($state, { conversationId, data }) {
const normalizedConversationId = Number(conversationId);
const records = $state.records[normalizedConversationId] || [];
const existingIndex = records.findIndex(record => record.id === data.id);
if (existingIndex > -1) {
records[existingIndex] = data;
} else {
records.push(data);
}
$state.records = {
...$state.records,
[normalizedConversationId]: [...records],
};
},
[types.UPDATE_RECURRING_SCHEDULED_MESSAGE]($state, { conversationId, data }) {
const normalizedConversationId = Number(conversationId);
const records = $state.records[normalizedConversationId] || [];
const existingIndex = records.findIndex(record => record.id === data.id);
if (existingIndex > -1) {
records[existingIndex] = data;
} else {
records.push(data);
}
$state.records = {
...$state.records,
[normalizedConversationId]: [...records],
};
},
[types.DELETE_RECURRING_SCHEDULED_MESSAGE](
$state,
{ conversationId, recurringScheduledMessageId }
) {
const normalizedConversationId = Number(conversationId);
const records = $state.records[normalizedConversationId] || [];
$state.records = {
...$state.records,
[normalizedConversationId]: records.filter(
record => record.id !== recurringScheduledMessageId
),
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@ -36,6 +36,13 @@ export default {
UPDATE_SCHEDULED_MESSAGE: 'UPDATE_SCHEDULED_MESSAGE',
DELETE_SCHEDULED_MESSAGE: 'DELETE_SCHEDULED_MESSAGE',
SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG:
'SET_RECURRING_SCHEDULED_MESSAGES_UI_FLAG',
SET_RECURRING_SCHEDULED_MESSAGES: 'SET_RECURRING_SCHEDULED_MESSAGES',
ADD_RECURRING_SCHEDULED_MESSAGE: 'ADD_RECURRING_SCHEDULED_MESSAGE',
UPDATE_RECURRING_SCHEDULED_MESSAGE: 'UPDATE_RECURRING_SCHEDULED_MESSAGE',
DELETE_RECURRING_SCHEDULED_MESSAGE: 'DELETE_RECURRING_SCHEDULED_MESSAGE',
SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW',
CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW',
CLEAR_ALL_MESSAGES: 'CLEAR_ALL_MESSAGES',

View File

@ -14,6 +14,7 @@ class ScheduledMessages::SendScheduledMessageJob < ApplicationJob
if scheduled_message&.pending?
scheduled_message.update!(status: :failed)
dispatch_event(scheduled_message)
handle_recurrence_on_failure(scheduled_message)
end
ensure
Current.reset
@ -27,6 +28,7 @@ class ScheduledMessages::SendScheduledMessageJob < ApplicationJob
message = send_message(scheduled_message)
update_scheduled_message_status(scheduled_message, message)
handle_recurrence(scheduled_message)
end
def send_message(scheduled_message)
@ -66,4 +68,45 @@ class ScheduledMessages::SendScheduledMessageJob < ApplicationJob
def dispatch_event(scheduled_message)
Rails.configuration.dispatcher.dispatch(SCHEDULED_MESSAGE_UPDATED, Time.zone.now, scheduled_message: scheduled_message)
end
def handle_recurrence(scheduled_message)
return if scheduled_message.recurring_scheduled_message_id.blank?
recurring = scheduled_message.recurring_scheduled_message
return unless recurring&.active?
RecurringScheduledMessages::CreateNextOccurrenceService.new(
recurring_scheduled_message: recurring,
previous_scheduled_message: scheduled_message
).perform
end
def handle_recurrence_on_failure(scheduled_message)
return if scheduled_message.recurring_scheduled_message_id.blank?
recurring = scheduled_message.recurring_scheduled_message
return unless recurring&.active?
next_message = RecurringScheduledMessages::CreateNextOccurrenceService.new(
recurring_scheduled_message: recurring,
previous_scheduled_message: scheduled_message,
skip_increment: true
).perform
create_failure_activity_message(scheduled_message, next_message) if next_message
end
def create_failure_activity_message(scheduled_message, next_message)
I18n.with_locale(scheduled_message.account.locale) do
scheduled_message.conversation.messages.create!(
account: scheduled_message.account,
inbox: scheduled_message.inbox,
message_type: :activity,
content: I18n.t(
'conversations.activity.recurring_message_failed',
next_date: I18n.l(next_message.scheduled_at, format: :short)
)
)
end
end
end

View File

@ -78,6 +78,30 @@ class ActionCableListener < BaseListener # rubocop:disable Metrics/ClassLength
broadcast(account, tokens, SCHEDULED_MESSAGE_DELETED, scheduled_message.push_event_data)
end
def recurring_scheduled_message_created(event)
recurring = event.data[:recurring_scheduled_message]
account = recurring.account
tokens = user_tokens(account, recurring.conversation.inbox.members)
broadcast(account, tokens, RECURRING_SCHEDULED_MESSAGE_CREATED, recurring.push_event_data)
end
def recurring_scheduled_message_updated(event)
recurring = event.data[:recurring_scheduled_message]
account = recurring.account
tokens = user_tokens(account, recurring.conversation.inbox.members)
broadcast(account, tokens, RECURRING_SCHEDULED_MESSAGE_UPDATED, recurring.push_event_data)
end
def recurring_scheduled_message_deleted(event)
recurring = event.data[:recurring_scheduled_message]
account = recurring.account
tokens = user_tokens(account, recurring.conversation.inbox.members)
broadcast(account, tokens, RECURRING_SCHEDULED_MESSAGE_DELETED, recurring.push_event_data)
end
def first_reply_created(event)
message, account = extract_message_and_account(event)
conversation = message.conversation

View File

@ -124,6 +124,7 @@ class Account < ApplicationRecord
has_many :notifications, dependent: :destroy_async
has_many :portals, dependent: :destroy_async, class_name: '::Portal'
has_many :scheduled_messages, dependent: :destroy_async
has_many :recurring_scheduled_messages, dependent: :destroy_async
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
has_many :teams, dependent: :destroy_async
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'

View File

@ -124,6 +124,7 @@ class Conversation < ApplicationRecord
has_many :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async
has_many :scheduled_messages, dependent: :destroy
has_many :recurring_scheduled_messages, dependent: :destroy
before_save :ensure_snooze_until_reset
before_create :determine_conversation_status

View File

@ -69,6 +69,7 @@ class Inbox < ApplicationRecord
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
has_many :scheduled_messages, dependent: :destroy_async
has_many :recurring_scheduled_messages, dependent: :destroy_async
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy

View File

@ -0,0 +1,174 @@
# == Schema Information
#
# Table name: recurring_scheduled_messages
#
# id :bigint not null, primary key
# author_type :string not null
# content :text
# occurrences_sent :integer default(0), not null
# recurrence_rule :jsonb not null
# status :integer default("draft"), not null
# template_params :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# conversation_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# idx_recurring_sched_msgs_on_account_status (account_id,status)
# idx_recurring_sched_msgs_on_conversation_status (conversation_id,status)
# idx_recurring_sched_msgs_on_status (status)
# index_recurring_scheduled_messages_on_account_id (account_id)
# index_recurring_scheduled_messages_on_author (author_type,author_id)
# index_recurring_scheduled_messages_on_conversation_id (conversation_id)
# index_recurring_scheduled_messages_on_inbox_id (inbox_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (inbox_id => inboxes.id)
#
class RecurringScheduledMessage < ApplicationRecord
include Rails.application.routes.url_helpers
FREQUENCIES = %w[daily weekly monthly yearly].freeze
END_TYPES = %w[never on_date after_count].freeze
MONTHLY_TYPES = %w[day_of_month day_of_week].freeze
belongs_to :account
belongs_to :conversation
belongs_to :inbox
belongs_to :author, polymorphic: true
has_many :scheduled_messages, dependent: :destroy
has_one_attached :attachment
enum status: { draft: 0, active: 1, completed: 2, cancelled: 3 }
validates :author_type, inclusion: { in: ['User'] }
validates :content, presence: true, unless: :content_optional?
validate :validate_recurrence_rule
scope :for_conversation, ->(conversation_id) { where(conversation_id: conversation_id) }
def push_event_data
base_event_data.tap do |data|
data[:author] = author.push_event_data if author.present?
data[:attachment] = attachment_data if attachment.attached?
data[:pending_scheduled_message] = pending_scheduled_message_data
data[:scheduled_messages] = recent_scheduled_messages_data
end
end
def recurrence_description
RecurringScheduledMessages::RecurrenceDescriptionService.new(recurrence_rule, locale: account&.locale || :en).generate
end
def attachment_data
return unless attachment.attached?
{
id: attachment.id,
recurring_scheduled_message_id: id,
file_type: attachment.content_type,
account_id: account_id,
file_url: url_for(attachment),
blob_id: attachment.blob.signed_id,
filename: attachment.filename.to_s
}
end
private
def base_event_data
{
id: id, content: content, inbox_id: inbox_id,
conversation_id: conversation.display_id, account_id: account_id,
status: status, template_params: template_params,
recurrence_rule: recurrence_rule, recurrence_description: recurrence_description,
occurrences_sent: occurrences_sent, author_id: author_id, author_type: author_type,
created_at: created_at.to_i, updated_at: updated_at.to_i
}
end
def pending_scheduled_message_data
sm = scheduled_messages.where(status: :pending).order(scheduled_at: :asc).first
return unless sm
{ id: sm.id, scheduled_at: sm.scheduled_at&.to_i }
end
def recent_scheduled_messages_data
scheduled_messages.order(scheduled_at: :desc).limit(50).map do |sm|
{ id: sm.id, status: sm.status, scheduled_at: sm.scheduled_at&.to_i, message_id: sm.message_id }
end
end
def content_optional?
template_params.present? || attachment.attached?
end
def validate_recurrence_rule
if recurrence_rule.blank? || recurrence_rule == {}
errors.add(:recurrence_rule, 'must have a valid frequency') if active?
return
end
rule = recurrence_rule.with_indifferent_access
validate_frequency(rule)
validate_interval(rule)
validate_weekly_fields(rule) if rule[:frequency] == 'weekly'
validate_monthly_fields(rule) if rule[:frequency] == 'monthly'
validate_end_type(rule)
end
def validate_frequency(rule)
errors.add(:recurrence_rule, 'must have a valid frequency') unless rule[:frequency].in?(FREQUENCIES)
end
def validate_interval(rule)
interval = rule[:interval]
errors.add(:recurrence_rule, 'must have an interval >= 1') unless interval.is_a?(Integer) && interval >= 1
end
def validate_weekly_fields(rule)
week_days = rule[:week_days]
return errors.add(:recurrence_rule, 'must have week_days for weekly frequency') if week_days.blank? || !week_days.is_a?(Array)
errors.add(:recurrence_rule, 'week_days must contain values between 0-6') unless week_days.all? { |d| d.is_a?(Integer) && d.between?(0, 6) }
end
def validate_monthly_fields(rule)
errors.add(:recurrence_rule, 'must have a valid monthly_type') unless rule[:monthly_type].in?(MONTHLY_TYPES)
return unless rule[:monthly_type] == 'day_of_week'
errors.add(:recurrence_rule, 'must have monthly_week for day_of_week type') unless rule[:monthly_week].is_a?(Integer)
errors.add(:recurrence_rule, 'must have monthly_weekday (0-6) for day_of_week type') unless rule[:monthly_weekday].is_a?(Integer) &&
rule[:monthly_weekday].between?(0, 6)
end
def validate_end_type(rule)
end_type = rule[:end_type]
errors.add(:recurrence_rule, 'must have a valid end_type') unless end_type.in?(END_TYPES)
case end_type
when 'on_date'
validate_end_date(rule[:end_date])
when 'after_count'
end_count = rule[:end_count]
errors.add(:recurrence_rule, 'must have end_count >= 1 for after_count end_type') unless end_count.is_a?(Integer) && end_count >= 1
end
end
def validate_end_date(end_date)
return errors.add(:recurrence_rule, 'must have an end_date for on_date end_type') if end_date.blank?
Date.iso8601(end_date)
rescue ArgumentError
errors.add(:recurrence_rule, 'end_date must be a valid ISO8601 date (YYYY-MM-DD)')
end
end

View File

@ -2,19 +2,20 @@
#
# Table name: scheduled_messages
#
# id :bigint not null, primary key
# author_type :string
# content :text
# scheduled_at :datetime
# status :integer default("draft"), not null
# template_params :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint
# conversation_id :bigint not null
# inbox_id :bigint not null
# message_id :bigint
# id :bigint not null, primary key
# author_type :string
# content :text
# scheduled_at :datetime
# status :integer default("draft"), not null
# template_params :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint
# conversation_id :bigint not null
# inbox_id :bigint not null
# message_id :bigint
# recurring_scheduled_message_id :bigint
#
# Indexes
#
@ -28,6 +29,7 @@
# index_scheduled_messages_on_inbox_id (inbox_id)
# index_scheduled_messages_on_inbox_id_and_status (inbox_id,status)
# index_scheduled_messages_on_message_id (message_id)
# index_scheduled_messages_on_recurring_scheduled_message_id (recurring_scheduled_message_id)
# index_scheduled_messages_on_status_and_scheduled_at (status,scheduled_at)
#
# Foreign Keys
@ -36,6 +38,7 @@
# fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (inbox_id => inboxes.id)
# fk_rails_... (message_id => messages.id)
# fk_rails_... (recurring_scheduled_message_id => recurring_scheduled_messages.id)
#
class ScheduledMessage < ApplicationRecord
include Rails.application.routes.url_helpers
@ -45,6 +48,7 @@ class ScheduledMessage < ApplicationRecord
belongs_to :conversation
belongs_to :author, polymorphic: true, optional: true
belongs_to :message, optional: true
belongs_to :recurring_scheduled_message, optional: true
has_one_attached :attachment
@ -59,32 +63,34 @@ class ScheduledMessage < ApplicationRecord
validate :must_be_editable, on: :update
validate :scheduled_at_must_be_in_future, if: :should_validate_future_schedule?
scope :due_for_sending, -> { pending.where('scheduled_at <= ?', Time.current) }
scope :due_for_sending, lambda {
pending
.where('scheduled_at <= ?', Time.current)
.joins(:conversation)
.merge(Conversation.where(status: [:open, :pending]))
}
def due_for_sending?
scheduled_at.present? && scheduled_at <= Time.current
scheduled_at.present? && scheduled_at <= Time.current && conversation&.status&.in?(%w[open pending])
end
def push_event_data
data = {
id: id,
content: content,
inbox_id: inbox_id,
conversation_id: conversation.display_id,
account_id: account_id,
status: status,
scheduled_at: scheduled_at&.to_i,
template_params: template_params,
author_id: author_id,
author_type: author_type,
message_id: message_id,
created_at: created_at.to_i,
updated_at: updated_at.to_i
}
base_event_data.tap do |data|
data[:author] = author_event_data if author.present?
data[:attachment] = attachment_data if attachment.attached?
data[:recurring_scheduled_message_id] = recurring_scheduled_message_id if recurring_scheduled_message_id.present?
end
end
data[:author] = author_event_data if author.present?
data[:attachment] = attachment_data if attachment.attached?
data
def base_event_data
{
id: id, content: content, inbox_id: inbox_id,
conversation_id: conversation.display_id, account_id: account_id,
status: status, scheduled_at: scheduled_at&.to_i,
template_params: template_params, author_id: author_id,
author_type: author_type, message_id: message_id,
created_at: created_at.to_i, updated_at: updated_at.to_i
}
end
def attachment_data

View File

@ -102,6 +102,7 @@ class User < ApplicationRecord
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
has_many :scheduled_messages, as: :author, dependent: :nullify
has_many :recurring_scheduled_messages, as: :author, dependent: :nullify
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :nullify

View File

@ -0,0 +1,51 @@
class RecurringScheduledMessagePolicy < ApplicationPolicy
def index?
accessible?
end
def create?
accessible?
end
def update?
accessible?
end
def destroy?
accessible?
end
private
def accessible?
administrator? || agent_bot? || agent_can_view_conversation?
end
def agent_can_view_conversation?
inbox_access? || team_access?
end
def administrator?
account_user&.administrator?
end
def agent_bot?
user.is_a?(AgentBot)
end
def conversation
record.respond_to?(:conversation) ? record.conversation : record
end
def inbox_access?
user.inboxes.where(account_id: account&.id).exists?(id: conversation.inbox_id)
end
def team_access?
return false if conversation.team_id.blank?
user.teams.where(account_id: account&.id).exists?(id: conversation.team_id)
end
end
RecurringScheduledMessagePolicy.prepend_mod_with('RecurringScheduledMessagePolicy')

View File

@ -0,0 +1,97 @@
class RecurringScheduledMessages::CreateNextOccurrenceService
def initialize(recurring_scheduled_message:, previous_scheduled_message:, skip_increment: false)
@recurring = recurring_scheduled_message
@previous = previous_scheduled_message
@skip_increment = skip_increment
end
def perform
unless @skip_increment
@recurring.class.update_counters(@recurring.id, occurrences_sent: 1) # rubocop:disable Rails/SkipsModelValidations
@recurring.reload
end
if should_complete?
complete_series
return nil
end
create_next_scheduled_message
end
private
def should_complete?
rule = @recurring.recurrence_rule.with_indifferent_access
case rule[:end_type]
when 'after_count'
@recurring.occurrences_sent >= rule[:end_count]
when 'on_date'
next_date = calculate_next_date
end_date = begin
Date.iso8601(rule[:end_date])
rescue StandardError
nil
end
next_date.nil? || end_date.nil? || next_date.to_date > end_date
else
false
end
end
def complete_series
@recurring.update!(status: :completed)
create_completion_activity_message
dispatch_event(Events::Types::RECURRING_SCHEDULED_MESSAGE_UPDATED)
end
def create_next_scheduled_message
next_date = calculate_next_date
return nil if next_date.nil?
scheduled_message = @recurring.scheduled_messages.create!(
content: @recurring.content,
template_params: @recurring.template_params,
scheduled_at: next_date,
status: :pending,
account: @recurring.account,
conversation: @recurring.conversation,
inbox: @recurring.inbox,
author: @recurring.author
)
copy_attachment(scheduled_message) if @recurring.attachment.attached?
dispatch_event(Events::Types::RECURRING_SCHEDULED_MESSAGE_UPDATED)
scheduled_message
end
def calculate_next_date
RecurringScheduledMessages::RecurrenceCalculatorService.new(
recurrence_rule: @recurring.recurrence_rule,
last_date: @previous.scheduled_at
).next_date
end
def copy_attachment(scheduled_message)
scheduled_message.attachment.attach(@recurring.attachment.blob)
end
def create_completion_activity_message
I18n.with_locale(@recurring.account.locale) do
@recurring.conversation.messages.create!(
account: @recurring.account,
inbox: @recurring.inbox,
message_type: :activity,
content: I18n.t(
'conversations.activity.recurring_message_completed',
count: @recurring.occurrences_sent
)
)
end
end
def dispatch_event(event_name)
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, recurring_scheduled_message: @recurring)
end
end

View File

@ -0,0 +1,109 @@
class RecurringScheduledMessages::RecurrenceCalculatorService
def initialize(recurrence_rule:, last_date:)
@rule = recurrence_rule.with_indifferent_access
@last_date = last_date
@frequency = @rule[:frequency]
@interval = @rule[:interval] || 1
end
def next_date
date = calculate_next_date
return nil if date.nil?
preserve_time(date)
end
private
def calculate_next_date
case @frequency
when 'daily' then calculate_daily
when 'weekly' then calculate_weekly
when 'monthly' then calculate_monthly
when 'yearly' then calculate_yearly
end
end
def calculate_daily
@last_date + @interval.days
end
def calculate_weekly
return nil if @rule[:week_days].blank?
week_days = @rule[:week_days].sort
current_wday = @last_date.wday
next_day = week_days.find { |d| d > current_wday }
if next_day
@last_date + (next_day - current_wday).days
else
days_until_first = (7 - current_wday + week_days.first) + ((@interval - 1) * 7)
@last_date + days_until_first.days
end
end
def calculate_monthly
if @rule[:monthly_type] == 'day_of_week'
calculate_monthly_day_of_week
else
calculate_monthly_day_of_month
end
end
def calculate_monthly_day_of_month
target = @last_date.advance(months: @interval)
day = @rule[:month_day] || @last_date.day
last_day = Time.days_in_month(target.month, target.year)
target.change(day: [day, last_day].min)
end
def calculate_monthly_day_of_week
monthly_week = @rule[:monthly_week]
monthly_weekday = @rule[:monthly_weekday]
target_month = @last_date.advance(months: @interval)
find_nth_weekday_in_month(target_month.year, target_month.month, monthly_weekday, monthly_week)
end
def find_nth_weekday_in_month(year, month, weekday, week_number)
if week_number == -1
find_last_weekday_in_month(year, month, weekday)
else
first_day = Date.new(year, month, 1)
first_occurrence = first_day + ((weekday - first_day.wday + 7) % 7)
result = first_occurrence + ((week_number - 1) * 7)
# If nth occurrence doesn't exist in month, use last occurrence
if result.month == month
result.to_time(:utc)
else
find_last_weekday_in_month(year, month, weekday)
end
end
end
def find_last_weekday_in_month(year, month, weekday)
last_day = Date.new(year, month, -1)
offset = (last_day.wday - weekday + 7) % 7
(last_day - offset).to_time(:utc)
end
def calculate_yearly
year_month = @rule[:year_month] || @last_date.month
year_day = @rule[:year_day] || @last_date.day
target = @last_date.advance(years: @interval)
if year_month == 2 && year_day == 29
target.change(day: Date.leap?(target.year) ? 29 : 28)
else
last_day = Time.days_in_month(target.month, target.year)
target.change(day: [year_day, last_day].min)
end
end
def preserve_time(date)
date.change(hour: @last_date.hour, min: @last_date.min, sec: @last_date.sec)
end
end

View File

@ -0,0 +1,71 @@
class RecurringScheduledMessages::RecurrenceDescriptionService
def initialize(recurrence_rule, locale: :en)
@rule = recurrence_rule&.with_indifferent_access || {}
@locale = locale
end
def generate
return '' if @rule.blank? || @rule[:frequency].blank?
I18n.with_locale(@locale) do
parts = [frequency_description]
parts << end_description if @rule[:end_type] && @rule[:end_type] != 'never'
parts.compact.join(' · ')
end
end
private
def frequency_description
case @rule[:frequency]
when 'daily' then daily_description
when 'weekly' then weekly_description
when 'monthly' then monthly_description
when 'yearly' then yearly_description
end
end
def daily_description
interval = @rule[:interval] || 1
I18n.t('recurring_scheduled_messages.description.daily', count: interval)
end
def weekly_description
interval = @rule[:interval] || 1
days = (@rule[:week_days] || []).sort.map { |d| I18n.t('date.abbr_day_names')[d] }
prefix = I18n.t('recurring_scheduled_messages.description.weekly', count: interval)
days.any? ? I18n.t('recurring_scheduled_messages.description.weekly_on', prefix: prefix, days: days.join(', ')) : prefix
end
def monthly_description
interval = @rule[:interval] || 1
prefix = I18n.t('recurring_scheduled_messages.description.monthly', count: interval)
if @rule[:monthly_type] == 'day_of_week'
ordinal = I18n.t("recurring_scheduled_messages.description.ordinals.#{ordinal_key(@rule[:monthly_week])}")
weekday = I18n.t('date.day_names')[@rule[:monthly_weekday]] || ''
I18n.t('recurring_scheduled_messages.description.monthly_on_weekday', prefix: prefix, ordinal: ordinal, weekday: weekday)
else
prefix
end
end
def yearly_description
interval = @rule[:interval] || 1
I18n.t('recurring_scheduled_messages.description.yearly', count: interval)
end
def end_description
case @rule[:end_type]
when 'on_date'
I18n.t('recurring_scheduled_messages.description.until_date', date: @rule[:end_date])
when 'after_count'
count = @rule[:end_count]
I18n.t('recurring_scheduled_messages.description.after_count', count: count)
end
end
def ordinal_key(week_number)
{ 1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth', 5 => 'fifth', -1 => 'last' }[week_number] || 'first'
end
end

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/recurring_scheduled_message', recurring_scheduled_message: @recurring_scheduled_message

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/recurring_scheduled_message', recurring_scheduled_message: @recurring_scheduled_message

View File

@ -0,0 +1,5 @@
json.payload do
json.array! @recurring_scheduled_messages do |recurring_scheduled_message|
json.partial! 'api/v1/models/recurring_scheduled_message', recurring_scheduled_message: recurring_scheduled_message
end
end

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/recurring_scheduled_message', recurring_scheduled_message: @recurring_scheduled_message

View File

@ -0,0 +1,37 @@
json.id recurring_scheduled_message.id
json.content recurring_scheduled_message.content
json.inbox_id recurring_scheduled_message.inbox_id
json.conversation_id recurring_scheduled_message.conversation.display_id
json.account_id recurring_scheduled_message.account_id
json.status recurring_scheduled_message.status
json.template_params recurring_scheduled_message.template_params
json.recurrence_rule recurring_scheduled_message.recurrence_rule
json.recurrence_description recurring_scheduled_message.recurrence_description
json.occurrences_sent recurring_scheduled_message.occurrences_sent
json.author_id recurring_scheduled_message.author_id
json.author_type recurring_scheduled_message.author_type
json.created_at recurring_scheduled_message.created_at.to_i
json.updated_at recurring_scheduled_message.updated_at.to_i
if recurring_scheduled_message.author.is_a?(User)
json.author do
json.partial! 'api/v1/models/agent', formats: [:json], resource: recurring_scheduled_message.author
end
end
json.attachment recurring_scheduled_message.attachment_data if recurring_scheduled_message.attachment.attached?
pending_sm = recurring_scheduled_message.scheduled_messages.select { |sm| sm.status == 'pending' }.min_by(&:scheduled_at)
if pending_sm
json.pending_scheduled_message do
json.id pending_sm.id
json.scheduled_at pending_sm.scheduled_at&.to_i
end
end
json.scheduled_messages recurring_scheduled_message.scheduled_messages.sort_by { |sm| sm.scheduled_at || Time.zone.at(0) }.last(50).reverse do |sm|
json.id sm.id
json.status sm.status
json.scheduled_at sm.scheduled_at&.to_i
json.message_id sm.message_id
end

View File

@ -25,3 +25,8 @@ elsif scheduled_message.author.present?
end
json.attachment scheduled_message.attachment_data if scheduled_message.attachment.attached?
if scheduled_message.recurring_scheduled_message_id.present?
json.recurring_scheduled_message_id scheduled_message.recurring_scheduled_message_id
json.recurrence_rule scheduled_message.recurring_scheduled_message&.recurrence_rule
end

View File

@ -310,6 +310,10 @@ en:
not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation'
recurring_message_failed: 'Recurring scheduled message failed to send. Next occurrence scheduled for %{next_date}.'
recurring_message_completed: 'Recurring scheduled message series completed after %{count} occurrences.'
recurring_message_cancelled: 'Recurring scheduled message has been stopped by %{agent}.'
unknown_agent: 'Unknown'
auto_resolution_message: 'Resolving the conversation as it has been inactive for a while. Please start a new conversation if you need further assistance.'
templates:
greeting_message_body: '%{account_name} typically replies in a few hours.'
@ -510,3 +514,30 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
recurring_scheduled_messages:
description:
daily:
one: "Every day"
other: "Every %{count} days"
weekly:
one: "Every week"
other: "Every %{count} weeks"
weekly_on: "%{prefix} on %{days}"
monthly:
one: "Monthly"
other: "Every %{count} months"
monthly_on_weekday: "%{prefix} on the %{ordinal} %{weekday}"
yearly:
one: "Every year"
other: "Every %{count} years"
until_date: "until %{date}"
after_count:
one: "%{count} occurrence"
other: "%{count} occurrences"
ordinals:
first: "first"
second: "second"
third: "third"
fourth: "fourth"
fifth: "fifth"
last: "last"

View File

@ -284,6 +284,10 @@ pt_BR:
not_sent_due_to_messaging_window: 'Auto-resolver mensagem não enviada devido a restrições de envio de mensagens'
muted: '%{user_name} silenciou a conversa'
unmuted: '%{user_name} reativou a conversa'
recurring_message_failed: 'Mensagem agendada recorrente falhou ao enviar. Próxima ocorrência agendada para %{next_date}.'
recurring_message_completed: 'Série de mensagem agendada recorrente concluída após %{count} ocorrências.'
recurring_message_cancelled: 'Recorrência de mensagem agendada foi interrompida por %{agent}.'
unknown_agent: 'Desconhecido'
auto_resolution_message: 'Resolvendo a conversa dado que está inativa por um tempo. Por favor, inicie uma nova conversa se precisar de mais ajuda.'
templates:
greeting_message_body: '%{account_name} normalmente responde em algumas horas.'
@ -481,3 +485,30 @@ pt_BR:
subject: 'Termine de configurar %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Domínio personalizado não está configurado'
recurring_scheduled_messages:
description:
daily:
one: "Todos os dias"
other: "A cada %{count} dias"
weekly:
one: "Semanal"
other: "A cada %{count} semanas"
weekly_on: "%{prefix}: %{days}"
monthly:
one: "Mensal"
other: "A cada %{count} meses"
monthly_on_weekday: "%{prefix} no(a) %{ordinal} %{weekday}"
yearly:
one: "Anual"
other: "A cada %{count} anos"
until_date: "até %{date}"
after_count:
one: "%{count} ocorrência"
other: "%{count} ocorrências"
ordinals:
first: "primeiro(a)"
second: "segundo(a)"
third: "terceiro(a)"
fourth: "quarto(a)"
fifth: "quinto(a)"
last: "último(a)"

View File

@ -138,6 +138,7 @@ Rails.application.routes.draw do
resources :attachments, only: [:update]
end
resources :scheduled_messages, only: [:index, :create, :update, :destroy]
resources :recurring_scheduled_messages, only: [:index, :create, :update, :destroy]
resources :assignments, only: [:create]
resources :labels, only: [:create, :index]
resource :participants, only: [:show, :create, :update, :destroy]

View File

@ -0,0 +1,22 @@
class CreateRecurringScheduledMessages < ActiveRecord::Migration[7.0]
def change
create_table :recurring_scheduled_messages do |t|
t.text :content
t.jsonb :template_params, default: {}
t.jsonb :recurrence_rule, null: false, default: {}
t.integer :status, default: 0, null: false
t.integer :occurrences_sent, default: 0, null: false
t.references :account, null: false, foreign_key: true
t.references :conversation, null: false, foreign_key: true
t.references :inbox, null: false, foreign_key: true
t.references :author, null: false, polymorphic: true
t.timestamps
end
add_index :recurring_scheduled_messages, [:conversation_id, :status], name: 'idx_recurring_sched_msgs_on_conversation_status'
add_index :recurring_scheduled_messages, [:account_id, :status], name: 'idx_recurring_sched_msgs_on_account_status'
add_index :recurring_scheduled_messages, [:status], name: 'idx_recurring_sched_msgs_on_status'
end
end

View File

@ -0,0 +1,6 @@
class AddRecurringScheduledMessageIdToScheduledMessages < ActiveRecord::Migration[7.0]
def change
add_reference :scheduled_messages, :recurring_scheduled_message,
null: true, foreign_key: true, index: true
end
end

View File

@ -1128,6 +1128,28 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_18_180001) do
t.index ["user_id"], name: "index_portals_members_on_user_id"
end
create_table "recurring_scheduled_messages", force: :cascade do |t|
t.text "content"
t.jsonb "template_params", default: {}
t.jsonb "recurrence_rule", default: {}, null: false
t.integer "status", default: 0, null: false
t.integer "occurrences_sent", default: 0, null: false
t.bigint "account_id", null: false
t.bigint "conversation_id", null: false
t.bigint "inbox_id", null: false
t.string "author_type", null: false
t.bigint "author_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "status"], name: "idx_recurring_sched_msgs_on_account_status"
t.index ["account_id"], name: "index_recurring_scheduled_messages_on_account_id"
t.index ["author_type", "author_id"], name: "index_recurring_scheduled_messages_on_author"
t.index ["conversation_id", "status"], name: "idx_recurring_sched_msgs_on_conversation_status"
t.index ["conversation_id"], name: "index_recurring_scheduled_messages_on_conversation_id"
t.index ["inbox_id"], name: "index_recurring_scheduled_messages_on_inbox_id"
t.index ["status"], name: "idx_recurring_sched_msgs_on_status"
end
create_table "related_categories", force: :cascade do |t|
t.bigint "category_id"
t.bigint "related_category_id"
@ -1172,6 +1194,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_18_180001) do
t.bigint "message_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "recurring_scheduled_message_id"
t.index ["account_id", "status"], name: "index_scheduled_messages_on_account_id_and_status"
t.index ["account_id"], name: "index_scheduled_messages_on_account_id"
t.index ["author_type", "author_id", "status"], name: "idx_on_author_type_author_id_status_6997d67ef6"
@ -1182,6 +1205,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_18_180001) do
t.index ["inbox_id", "status"], name: "index_scheduled_messages_on_inbox_id_and_status"
t.index ["inbox_id"], name: "index_scheduled_messages_on_inbox_id"
t.index ["message_id"], name: "index_scheduled_messages_on_message_id"
t.index ["recurring_scheduled_message_id"], name: "index_scheduled_messages_on_recurring_scheduled_message_id"
t.index ["status", "scheduled_at"], name: "index_scheduled_messages_on_status_and_scheduled_at"
end
@ -1335,10 +1359,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_18_180001) do
add_foreign_key "group_members", "contacts"
add_foreign_key "group_members", "contacts", column: "group_contact_id"
add_foreign_key "inboxes", "portals"
add_foreign_key "recurring_scheduled_messages", "accounts"
add_foreign_key "recurring_scheduled_messages", "conversations"
add_foreign_key "recurring_scheduled_messages", "inboxes"
add_foreign_key "scheduled_messages", "accounts"
add_foreign_key "scheduled_messages", "conversations"
add_foreign_key "scheduled_messages", "inboxes"
add_foreign_key "scheduled_messages", "messages"
add_foreign_key "scheduled_messages", "recurring_scheduled_messages"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").
after(:insert).

View File

@ -46,6 +46,11 @@ module Events::Types
SCHEDULED_MESSAGE_UPDATED = 'scheduled_message.updated'
SCHEDULED_MESSAGE_DELETED = 'scheduled_message.deleted'
# recurring scheduled message events
RECURRING_SCHEDULED_MESSAGE_CREATED = 'recurring_scheduled_message.created'
RECURRING_SCHEDULED_MESSAGE_UPDATED = 'recurring_scheduled_message.updated'
RECURRING_SCHEDULED_MESSAGE_DELETED = 'recurring_scheduled_message.deleted'
# contact events
CONTACT_CREATED = 'contact.created'
CONTACT_UPDATED = 'contact.updated'

View File

@ -0,0 +1,18 @@
FactoryBot.define do
factory :recurring_scheduled_message do
conversation
account { conversation.account }
inbox { conversation.inbox }
association :author, factory: :user
content { 'Recurring scheduled message content' }
recurrence_rule do
{
frequency: 'weekly',
interval: 1,
week_days: [1],
end_type: 'never'
}
end
status { :active }
end
end