@@ -197,6 +297,16 @@ watch(
{{ t('SCHEDULED_MESSAGES.PAST_MESSAGES_SECTION') }}
+
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index af17fca8d..f26a658e4 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -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,
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index 0aed3c6c3..56e5dd45a 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -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);
},
diff --git a/app/javascript/dashboard/store/modules/recurringScheduledMessages.js b/app/javascript/dashboard/store/modules/recurringScheduledMessages.js
new file mode 100644
index 000000000..842de357b
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/recurringScheduledMessages.js
@@ -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,
+};
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 605c1fdab..82c211a86 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -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',
diff --git a/app/jobs/scheduled_messages/send_scheduled_message_job.rb b/app/jobs/scheduled_messages/send_scheduled_message_job.rb
index e1874779c..ec7ececa0 100644
--- a/app/jobs/scheduled_messages/send_scheduled_message_job.rb
+++ b/app/jobs/scheduled_messages/send_scheduled_message_job.rb
@@ -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
diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb
index 153768660..2513d7b08 100644
--- a/app/listeners/action_cable_listener.rb
+++ b/app/listeners/action_cable_listener.rb
@@ -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
diff --git a/app/models/account.rb b/app/models/account.rb
index 57454f2ca..47ce86b82 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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'
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index ddcd9d459..0f9f2efed 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -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
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index c751becf5..18513a162 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -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
diff --git a/app/models/recurring_scheduled_message.rb b/app/models/recurring_scheduled_message.rb
new file mode 100644
index 000000000..5b9166542
--- /dev/null
+++ b/app/models/recurring_scheduled_message.rb
@@ -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
diff --git a/app/models/scheduled_message.rb b/app/models/scheduled_message.rb
index 1e9a6c559..35525bfe1 100644
--- a/app/models/scheduled_message.rb
+++ b/app/models/scheduled_message.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index afea4b4c5..44b4257d6 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
diff --git a/app/policies/recurring_scheduled_message_policy.rb b/app/policies/recurring_scheduled_message_policy.rb
new file mode 100644
index 000000000..05b36d16f
--- /dev/null
+++ b/app/policies/recurring_scheduled_message_policy.rb
@@ -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')
diff --git a/app/services/recurring_scheduled_messages/create_next_occurrence_service.rb b/app/services/recurring_scheduled_messages/create_next_occurrence_service.rb
new file mode 100644
index 000000000..d4ad640c4
--- /dev/null
+++ b/app/services/recurring_scheduled_messages/create_next_occurrence_service.rb
@@ -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
diff --git a/app/services/recurring_scheduled_messages/recurrence_calculator_service.rb b/app/services/recurring_scheduled_messages/recurrence_calculator_service.rb
new file mode 100644
index 000000000..b0d2c32b6
--- /dev/null
+++ b/app/services/recurring_scheduled_messages/recurrence_calculator_service.rb
@@ -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
diff --git a/app/services/recurring_scheduled_messages/recurrence_description_service.rb b/app/services/recurring_scheduled_messages/recurrence_description_service.rb
new file mode 100644
index 000000000..2c28592a6
--- /dev/null
+++ b/app/services/recurring_scheduled_messages/recurrence_description_service.rb
@@ -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
diff --git a/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/create.json.jbuilder b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/create.json.jbuilder
new file mode 100644
index 000000000..5dcbc55ed
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/create.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/recurring_scheduled_message', recurring_scheduled_message: @recurring_scheduled_message
diff --git a/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/destroy.json.jbuilder b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/destroy.json.jbuilder
new file mode 100644
index 000000000..5dcbc55ed
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/destroy.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/recurring_scheduled_message', recurring_scheduled_message: @recurring_scheduled_message
diff --git a/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/index.json.jbuilder b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/index.json.jbuilder
new file mode 100644
index 000000000..d88afab55
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/index.json.jbuilder
@@ -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
diff --git a/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/update.json.jbuilder b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/update.json.jbuilder
new file mode 100644
index 000000000..5dcbc55ed
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/recurring_scheduled_messages/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/recurring_scheduled_message', recurring_scheduled_message: @recurring_scheduled_message
diff --git a/app/views/api/v1/models/_recurring_scheduled_message.json.jbuilder b/app/views/api/v1/models/_recurring_scheduled_message.json.jbuilder
new file mode 100644
index 000000000..3cfead3d3
--- /dev/null
+++ b/app/views/api/v1/models/_recurring_scheduled_message.json.jbuilder
@@ -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
diff --git a/app/views/api/v1/models/_scheduled_message.json.jbuilder b/app/views/api/v1/models/_scheduled_message.json.jbuilder
index 693c4fbfc..392840257 100644
--- a/app/views/api/v1/models/_scheduled_message.json.jbuilder
+++ b/app/views/api/v1/models/_scheduled_message.json.jbuilder
@@ -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
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b9be6329d..d78d1a76c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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"
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index 329efd25d..a7675b035 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -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)"
diff --git a/config/routes.rb b/config/routes.rb
index 5c525dd43..9d29516a0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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]
diff --git a/db/migrate/20260318180000_create_recurring_scheduled_messages.rb b/db/migrate/20260318180000_create_recurring_scheduled_messages.rb
new file mode 100644
index 000000000..e674145dc
--- /dev/null
+++ b/db/migrate/20260318180000_create_recurring_scheduled_messages.rb
@@ -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
diff --git a/db/migrate/20260318180001_add_recurring_scheduled_message_id_to_scheduled_messages.rb b/db/migrate/20260318180001_add_recurring_scheduled_message_id_to_scheduled_messages.rb
new file mode 100644
index 000000000..3a6383c51
--- /dev/null
+++ b/db/migrate/20260318180001_add_recurring_scheduled_message_id_to_scheduled_messages.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index afcfad2ef..d40e9e3eb 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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).
diff --git a/lib/events/types.rb b/lib/events/types.rb
index c4ff4748d..fa7758403 100644
--- a/lib/events/types.rb
+++ b/lib/events/types.rb
@@ -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'
diff --git a/spec/factories/recurring_scheduled_messages.rb b/spec/factories/recurring_scheduled_messages.rb
new file mode 100644
index 000000000..fd5ef8b30
--- /dev/null
+++ b/spec/factories/recurring_scheduled_messages.rb
@@ -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