0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/plugins/automation/lib/discourse_automation/triggers/recurring.rb
Kris de8f59edf5
FIX: Automation Plugin - Monthly recurring triggers shifting dates and skipping months (BYDAY to BYMONTHDAY) (#40747)
Cleaning up what @Lillinator was working on over here:
https://github.com/discourse/discourse/pull/39917

Description:

Context / Bug:
Currently, when an admin sets a monthly recurring automation, the script
attempts to calculate the "Nth Weekday" (e.g., "The 3rd Tuesday") using
BYDAY instead of the actual calendar date. This causes two major issues
for administrators:

1. Shifting Dates: Users expect a monthly report to run on the exact
same calendar date (e.g., the 15th). Instead, the date shifts back and
forth depending on when the "3rd Tuesday" or "2nd Friday" falls in the
following month.
2. Silent Failures: If an admin sets a start date on the 30th or 31st of
a month (e.g., the 5th Tuesday), the automation generates a rule like
BYDAY=5TU. Months that only have 4 Tuesdays will completely fail to find
a valid execution date and silently skip the entire month.

Changes:

* Replaced the BYDAY string calculation with
BYMONTHDAY=#{start_date.day}. Monthly schedules will now reliably
trigger on the exact calendar date matching user expectations.
* Removed the now-unused count while-loop that was calculating the
weekday occurrence.
* Minor cleanup: Swapped Time.now for Time.zone.now in the RRule bounds
check for standard Rails timezone consistency.

Test Updates:

* Updated two existing assertions in recurring_spec.rb. The original
tests were explicitly (and incorrectly) expecting the buggy Nth-weekday
drift. I corrected the assertion dates to expect the proper exact
calendar day.

Files Touched:

*
plugins/discourse-automation/lib/discourse_automation/triggers/recurring.rb
*
plugins/discourse-automation/spec/lib/discourse_automation/triggers/recurring_spec.rb
2026-06-16 11:46:43 -04:00

156 lines
5.6 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAutomation
module Triggers
module Recurring
RECURRENCE_CHOICES = [
{ id: "minute", name: "discourse_automation.triggerables.recurring.frequencies.minute" },
{ id: "hour", name: "discourse_automation.triggerables.recurring.frequencies.hour" },
{ id: "day", name: "discourse_automation.triggerables.recurring.frequencies.day" },
{ id: "weekday", name: "discourse_automation.triggerables.recurring.frequencies.weekday" },
{ id: "week", name: "discourse_automation.triggerables.recurring.frequencies.week" },
{ id: "month", name: "discourse_automation.triggerables.recurring.frequencies.month" },
{ id: "year", name: "discourse_automation.triggerables.recurring.frequencies.year" },
]
def self.setup_pending_automation(automation, fields, previous_fields)
start_date = fields.dig("start_date", "value")
interval = fields.dig("recurrence", "value", "interval")
frequency = fields.dig("recurrence", "value", "frequency")
# this case is not possible in practice but better be safe
if !start_date || !interval || !frequency
automation.pending_automations.destroy_all
return
end
previous_start_date = previous_fields&.dig("start_date", "value")
previous_interval = previous_fields&.dig("recurrence", "value", "interval")
previous_frequency = previous_fields&.dig("recurrence", "value", "frequency")
if previous_start_date != start_date || previous_interval != interval ||
previous_frequency != frequency
automation.pending_automations.destroy_all
elsif automation.pending_automations.present?
log_debugging_info(
id: automation.id,
start_date:,
interval:,
frequency:,
previous_start_date:,
previous_interval:,
previous_frequency:,
now: Time.zone.now,
)
return
end
start_date = Time.parse(start_date)
now = Time.zone.now
interval = interval.to_i
if start_date > now
automation.pending_automations.create!(execute_at: start_date)
return
end
byday = start_date.strftime("%A").upcase[0, 2]
interval_end = interval + 1
next_trigger_date =
case frequency
when "minute"
(now + interval.minute).beginning_of_minute
when "hour"
(now + interval.hour).beginning_of_hour
when "day"
RRule::Rule
.new("FREQ=DAILY;INTERVAL=#{interval}", dtstart: start_date)
.between(now, now + interval_end.days)
.find { |date| date > now }
when "weekday"
max_weekends = (interval_end.to_f / 5).ceil
RRule::Rule
.new("FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", dtstart: start_date)
.between(now.end_of_day, now + max_weekends.weeks)
.drop(interval - 1)
.find { |date| date > now }
when "week"
RRule::Rule
.new("FREQ=WEEKLY;INTERVAL=#{interval};BYDAY=#{byday}", dtstart: start_date)
.between(now.end_of_week, now + interval_end.weeks)
.find { |date| date > now }
when "month"
months_elapsed = (now.year - start_date.year) * 12 + (now.month - start_date.month)
steps = (months_elapsed.to_f / interval).ceil * interval
next_date = start_date + steps.months
next_date = start_date + (steps += interval).months while next_date <= now
next_date
when "year"
RRule::Rule
.new("FREQ=YEARLY;INTERVAL=#{interval}", dtstart: start_date)
.between(now, now + interval_end.years)
.find { |date| date > now }
end
if next_trigger_date
automation.pending_automations.create!(execute_at: next_trigger_date)
else
log_debugging_info(
id: automation.id,
start_date:,
interval:,
frequency:,
previous_start_date:,
previous_interval:,
previous_frequency:,
byday:,
interval_end:,
next_trigger_date:,
now:,
)
nil
end
end
def self.log_debugging_info(context)
return if !SiteSetting.discourse_automation_enable_recurring_debug
str = "scheduling recurring automation debug: "
str += context.map { |k, v| "#{k}=#{v.inspect}" }.join(", ")
DiscourseAutomation::Logger.warn(str)
end
end
end
end
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::RECURRING) do
field :recurrence,
component: :period,
extra: {
content: DiscourseAutomation::Triggers::Recurring::RECURRENCE_CHOICES,
},
required: true,
validator: ->(value) do
if value && !value["interval"].to_i.positive?
I18n.t("discourse_automation.triggerables.recurring.invalid_interval")
end
end
field :start_date, component: :date_time, required: true
on_update do |automation, fields, previous_fields|
DiscourseAutomation::Triggers::Recurring.setup_pending_automation(
automation,
fields,
previous_fields,
)
end
on_call do |automation, fields, previous_fields|
DiscourseAutomation::Triggers::Recurring.setup_pending_automation(
automation,
fields,
previous_fields,
)
end
enable_manual_trigger
end