diff --git a/Gemfile b/Gemfile
index c6df12eda0..6b3a172fd3 100644
--- a/Gemfile
+++ b/Gemfile
@@ -94,6 +94,8 @@ gem "geocoder"
gem 'httparty'
# Generate .ics calendars for use with Google Calendar
gem 'icalendar', require: false
+# Offers functionality for date reocccurances
+gem "ice_cube"
# JSON Web Token encoding / decoding (e.g. for links in e-mails)
gem "jwt"
# Use Newrelic for logs and APM
diff --git a/Gemfile.lock b/Gemfile.lock
index 5e1a9fd586..b2cab74685 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -747,6 +747,7 @@ DEPENDENCIES
guard-rspec
httparty
icalendar
+ ice_cube
image_processing
importmap-rails (~> 2.0)
jbuilder
diff --git a/app/assets/stylesheets/custom.scss b/app/assets/stylesheets/custom.scss
index ad5a229332..44a3f648d3 100644
--- a/app/assets/stylesheets/custom.scss
+++ b/app/assets/stylesheets/custom.scss
@@ -98,3 +98,15 @@
margin-top: 40px;
}
}
+
+#week-day-fields, #date-fields {
+ display: none;
+}
+
+#toggle-to-week-day:checked ~ #week-day-fields {
+ display: block;
+}
+
+#toggle-to-date:checked ~ #date-fields {
+ display: block
+}
\ No newline at end of file
diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb
index 11313338e7..a3592f0c6d 100644
--- a/app/controllers/admin/organizations_controller.rb
+++ b/app/controllers/admin/organizations_controller.rb
@@ -2,6 +2,7 @@
class Admin::OrganizationsController < AdminController
def edit
@organization = Organization.find(params[:id])
+ @organization.get_values_from_reminder_schedule
end
def update
@@ -31,6 +32,7 @@ def index
def new
@organization = Organization.new
+ @organization.get_values_from_reminder_schedule
account_request = params[:token] && AccountRequest.get_by_identity_token(params[:token])
@user = User.new
@@ -46,7 +48,6 @@ def new
def create
@organization = Organization.new(organization_params)
-
if @organization.save
Organization.seed_items(@organization)
@user = UserInviteService.invite(name: user_params[:name],
@@ -82,7 +83,8 @@ def destroy
def organization_params
params.require(:organization)
- .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id, :reminder_day, :deadline_day,
+ .permit(:name, :short_name, :street, :city, :state, :zipcode, :email, :url, :logo, :intake_location, :default_email_text, :account_request_id,
+ :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, :deadline_day,
users_attributes: %i(name email organization_admin), account_request_attributes: %i(ndbn_member_id id))
end
diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb
index 9a07bdfafd..cec0c5783d 100644
--- a/app/controllers/organizations_controller.rb
+++ b/app/controllers/organizations_controller.rb
@@ -10,11 +10,11 @@ def show
def edit
@organization = current_organization
+ @organization.get_values_from_reminder_schedule
end
def update
@organization = current_organization
-
if OrganizationUpdateService.update(@organization, organization_params)
redirect_to organization_path, notice: "Updated your organization!"
else
@@ -92,13 +92,14 @@ def organization_params
:name, :short_name, :street, :city, :state,
:zipcode, :email, :url, :logo, :intake_location,
:default_storage_location, :default_email_text,
- :invitation_text, :reminder_day, :deadline_day,
+ :invitation_text, :reminder_schedule, :deadline_day,
:repackage_essentials, :distribute_monthly,
:ndbn_member_id, :enable_child_based_requests,
:enable_individual_requests, :enable_quantity_based_requests,
:ytd_on_distribution_printout, :one_step_partner_invite,
:hide_value_columns_on_receipt, :hide_package_column_on_receipt,
- :signature_for_distribution_pdf,
+ :signature_for_distribution_pdf, :by_month_or_week, :day_of_month, :day_of_week,
+ :every_nth_day,
partner_form_fields: [],
request_unit_names: []
)
diff --git a/app/controllers/partner_groups_controller.rb b/app/controllers/partner_groups_controller.rb
index b6ae82dc00..0ca0a04baf 100644
--- a/app/controllers/partner_groups_controller.rb
+++ b/app/controllers/partner_groups_controller.rb
@@ -18,6 +18,8 @@ def create
end
def edit
+ @partner_group = current_organization.partner_groups.find(params[:id])
+ @partner_group.get_values_from_reminder_schedule
@item_categories = current_organization.item_categories
end
@@ -49,6 +51,7 @@ def set_partner_group
end
def partner_group_params
- params.require(:partner_group).permit(:name, :send_reminders, :deadline_day, :reminder_day, item_category_ids: [])
+ params.require(:partner_group).permit(:name, :send_reminders, :reminder_schedule,
+ :deadline_day, :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day, item_category_ids: [])
end
end
diff --git a/app/javascript/utils/deadline_day_pickers.js b/app/javascript/utils/deadline_day_pickers.js
index d538c4a76a..1ab5082ec2 100644
--- a/app/javascript/utils/deadline_day_pickers.js
+++ b/app/javascript/utils/deadline_day_pickers.js
@@ -6,6 +6,7 @@ $(document).ready(function () {
const deadline_selector = '.deadline-day-pickers__deadline-day';
const reminder_container_selector = '.deadline-day-pickers__reminder-container';
const deadline_container_selector = '.deadline-day-pickers__deadline-container';
+ const day_of_week_toggle_selector = '#toggle-to-week-day';
const reminder_text_selector = '.deadline-day-pickers__reminder-day-text';
const deadline_text_selector = '.deadline-day-pickers__deadline-day-text';
@@ -18,6 +19,7 @@ $(document).ready(function () {
const $deadline = $container.find(deadline_selector);
const $reminder_text = $container.find(reminder_text_selector);
const $deadline_text = $container.find(deadline_text_selector);
+ const $day_of_week_toggle = $container.find(day_of_week_toggle_selector)[0];
const reminder_day = parseInt($reminder.val());
const deadline_day = parseInt($deadline.val());
@@ -29,17 +31,20 @@ $(document).ready(function () {
if (reminder_day) {
$(container).find(reminder_container_selector).find(server_validation_selector).remove();
- if (reminder_day === deadline_day) {
+ if (reminder_day === deadline_day && !$day_of_week_toggle.checked) {
$reminder_text.removeClass('text-muted').addClass('text-danger');
$reminder_text.text('Reminder day cannot be the same as deadline day.');
- } else {
+ }
+ else if ($day_of_week_toggle.checked){
+ $reminder_text.text('');
+ }
+ else {
$reminder_text.removeClass('text-danger').addClass('text-muted');
-
- const next_reminder_month = (current_day >= reminder_day) ? next_month : current_month;
- $reminder_text.text(`Your next reminder will be sent on ${reminder_day} ${next_reminder_month}.`);
+ const next_reminder_month = (current_day >= reminder_day) ? next_month : current_month;
+ $reminder_text.text(`Your next reminder will be sent on ${reminder_day} ${next_reminder_month}.`);
+ }
}
- }
if (deadline_day) {
$(container).find(deadline_container_selector).find(server_validation_selector).remove();
diff --git a/app/models/concerns/deadlinable.rb b/app/models/concerns/deadlinable.rb
index dfb35c6d64..5d1529b01a 100644
--- a/app/models/concerns/deadlinable.rb
+++ b/app/models/concerns/deadlinable.rb
@@ -1,15 +1,97 @@
module Deadlinable
extend ActiveSupport::Concern
-
MIN_DAY_OF_MONTH = 1
MAX_DAY_OF_MONTH = 28
+ EVERY_NTH_COLLECTION = [["First", 1], ["Second", 2], ["Third", 3], ["Fourth", 4], ["Last", -1]].freeze
+ DAY_OF_WEEK_COLLECTION = [["Sunday", 0], ["Monday", 1], ["Tuesday", 2], ["Wednesday", 3], ["Thursday", 4], ["Friday", 5], ["Saturday", 6]].freeze
included do
+ attr_accessor :by_month_or_week, :day_of_month, :day_of_week, :every_nth_day
validates :deadline_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH,
greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true}
- validates :reminder_day, numericality: {only_integer: true, less_than_or_equal_to: MAX_DAY_OF_MONTH,
- greater_than_or_equal_to: MIN_DAY_OF_MONTH, allow_nil: true}
+ validate :reminder_on_deadline_day?, if: -> { day_of_month.present? }
+ validate :reminder_is_within_range?, if: -> { day_of_month.present? }
+ validates :by_month_or_week, inclusion: {in: %w[day_of_month day_of_week]}, if: -> { by_month_or_week.present? }
+ validates :day_of_week, if: -> { day_of_week.present? }, inclusion: {in: %w[0 1 2 3 4 5 6]}
+ validates :every_nth_day, if: -> { every_nth_day.present? }, inclusion: {in: %w[1 2 3 4 -1]}
+ end
+
+ def convert_to_reminder_schedule(day)
+ schedule = IceCube::Schedule.new
+ schedule.add_recurrence_rule IceCube::Rule.monthly.day_of_month(day)
+ schedule.to_ical
+ end
+
+ def show_description(ical)
+ schedule = IceCube::Schedule.from_ical(ical)
+ schedule.recurrence_rules.first.to_s
+ end
+
+ def from_ical(ical)
+ return if ical.blank?
+ schedule = IceCube::Schedule.from_ical(ical)
+ rule = schedule.recurrence_rules.first.instance_values
+ day_of_month = rule["validations"][:day_of_month]&.first&.value
+
+ results = {}
+ results[:by_month_or_week] = day_of_month ? "day_of_month" : "day_of_week"
+ results[:day_of_month] = day_of_month
+ results[:day_of_week] = rule["validations"][:day_of_week]&.first&.day
+ results[:every_nth_day] = rule["validations"][:day_of_week]&.first&.occ
+ results
+ rescue
+ nil
+ end
+
+ def get_values_from_reminder_schedule
+ return if reminder_schedule.blank?
+ results = from_ical(reminder_schedule)
+ return if results.nil?
+ self.by_month_or_week = results[:by_month_or_week]
+ self.day_of_month = results[:day_of_month]
+ self.day_of_week = results[:day_of_week]
+ self.every_nth_day = results[:every_nth_day]
+ end
+
+ private
+
+ def reminder_on_deadline_day?
+ if by_month_or_week == "day_of_month" && day_of_month.to_i == deadline_day
+ errors.add(:day_of_month, "Reminder must not be the same as deadline date")
+ end
+ end
+
+ def reminder_is_within_range?
+ # IceCube converts negative or zero days to valid days (e.g. -1 becomes the last day of the month, 0 becomes 1)
+ # The minimum check should no longer be necessary, but keeping it in case IceCube changes
+ if by_month_or_week == "day_of_month" && day_of_month.to_i < MIN_DAY_OF_MONTH || day_of_month.to_i > MAX_DAY_OF_MONTH
+ errors.add(:day_of_month, "Reminder day must be between #{MIN_DAY_OF_MONTH} and #{MAX_DAY_OF_MONTH}")
+ end
+ end
+
+ def should_update_reminder_schedule
+ if reminder_schedule.blank?
+ return by_month_or_week.present?
+ end
+ sched = from_ical(reminder_schedule)
+ by_month_or_week != sched[:by_month_or_week].presence.to_s ||
+ day_of_month != sched[:day_of_month].presence.to_s ||
+ day_of_week != sched[:day_of_week].presence.to_s ||
+ every_nth_day != sched[:every_nth_day].presence.to_s
+ end
- validates :reminder_day, numericality: {other_than: :deadline_day}, if: :deadline_day?
+ def create_schedule
+ schedule = IceCube::Schedule.new(Time.zone.now.to_date)
+ return nil if by_month_or_week.blank?
+ if by_month_or_week == "day_of_month"
+ return nil if day_of_month.blank?
+ schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_month(day_of_month.to_i))
+ else
+ return nil if day_of_week.blank? || every_nth_day.blank?
+ schedule.add_recurrence_rule(IceCube::Rule.monthly(1).day_of_week(day_of_week.to_i => [every_nth_day.to_i]))
+ end
+ schedule.to_ical
+ rescue
+ nil
end
end
diff --git a/app/models/organization.rb b/app/models/organization.rb
index d8f46221a6..56b4686937 100644
--- a/app/models/organization.rb
+++ b/app/models/organization.rb
@@ -20,7 +20,7 @@
# name :string
# one_step_partner_invite :boolean default(FALSE), not null
# partner_form_fields :text default([]), is an Array
-# reminder_day :integer
+# reminder_schedule :string
# repackage_essentials :boolean default(FALSE), not null
# short_name :string
# signature_for_distribution_pdf :boolean default(FALSE)
@@ -110,6 +110,12 @@ def upcoming
end
end
+ before_save do
+ if should_update_reminder_schedule
+ self.reminder_schedule = create_schedule
+ end
+ end
+
after_create do
account_request&.update!(status: "admin_approved")
end
diff --git a/app/models/partner_group.rb b/app/models/partner_group.rb
index e7ac55c8fd..acd8563ed6 100644
--- a/app/models/partner_group.rb
+++ b/app/models/partner_group.rb
@@ -2,14 +2,14 @@
#
# Table name: partner_groups
#
-# id :bigint not null, primary key
-# deadline_day :integer
-# name :string
-# reminder_day :integer
-# send_reminders :boolean default(FALSE), not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# organization_id :bigint
+# id :bigint not null, primary key
+# deadline_day :integer
+# name :string
+# reminder_schedule :string
+# send_reminders :boolean default(FALSE), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# organization_id :bigint
#
class PartnerGroup < ApplicationRecord
has_paper_trail
@@ -19,7 +19,11 @@ class PartnerGroup < ApplicationRecord
has_many :partners, dependent: :nullify
has_and_belongs_to_many :item_categories
+ before_save do
+ self.reminder_schedule = create_schedule
+ end
+
validates :organization, presence: true
validates :name, presence: true, uniqueness: { scope: :organization }
- validates :deadline_day, :reminder_day, presence: true, if: :send_reminders?
+ validates :deadline_day, presence: true, if: :send_reminders?
end
diff --git a/app/services/partners/fetch_partners_to_remind_now_service.rb b/app/services/partners/fetch_partners_to_remind_now_service.rb
index 0f112b76d5..c6e792fd9c 100644
--- a/app/services/partners/fetch_partners_to_remind_now_service.rb
+++ b/app/services/partners/fetch_partners_to_remind_now_service.rb
@@ -1,22 +1,32 @@
module Partners
class FetchPartnersToRemindNowService
def fetch
- current_day = Time.current.day
+ current_day = Time.current
deactivated_status = ::Partner.statuses[:deactivated]
partners_with_group_reminders = ::Partner.left_joins(:partner_group)
- .where(partner_groups: {reminder_day: current_day})
+ .where.not(partner_groups: {reminder_schedule: nil})
.where.not(partner_groups: {deadline_day: nil})
.where.not(status: deactivated_status)
+ # where partner groups have reminder schedule match
+ filtered_partner_groups = partners_with_group_reminders.select do |partner|
+ sched = IceCube::Schedule.from_ical partner.partner_group.reminder_schedule
+ sched.occurs_on?(current_day)
+ end
+
partners_with_only_organization_reminders = ::Partner.left_joins(:partner_group, :organization)
- .where(partner_groups: {reminder_day: nil})
+ .where(partner_groups: {reminder_schedule: nil})
.where(send_reminders: true)
- .where(organizations: {reminder_day: current_day})
.where.not(organizations: {deadline_day: nil})
+ .where.not(organizations: {reminder_schedule: nil})
.where.not(status: deactivated_status)
- (partners_with_group_reminders + partners_with_only_organization_reminders).flatten.uniq
+ filtered_organizations = partners_with_only_organization_reminders.select do |partner|
+ sched = IceCube::Schedule.from_ical partner.organization.reminder_schedule
+ sched.occurs_on?(current_day)
+ end
+ (filtered_partner_groups + filtered_organizations).flatten.uniq
end
end
end
diff --git a/app/views/admin/organizations/new.html.erb b/app/views/admin/organizations/new.html.erb
index 62fd4906a8..cb574e4eb2 100644
--- a/app/views/admin/organizations/new.html.erb
+++ b/app/views/admin/organizations/new.html.erb
@@ -48,8 +48,7 @@
<%= f.input :city %>
<%= f.input :state, collection: us_states, class: "form-control", placeholder: "state" %>
<%= f.input :zipcode %>
- <%= f.input :reminder_day, class: "form-control", placeholder: "Reminder day" %>
- <%= f.input :deadline_day, class: "form-control", placeholder: "Deadline day" %>
+ <%= render 'shared/deadline_day_fields', f: f %>
<%= f.simple_fields_for :account_request do |account_request| %>
<%= account_request.input :ndbn_member, label: 'NDBN Membership', wrapper: :input_group do %>
<%= account_request.association :ndbn_member, label_method: :full_name, value_method: :id, label: false %>
diff --git a/app/views/organizations/_details.html.erb b/app/views/organizations/_details.html.erb
index 90dc479f6f..ead5de83b7 100644
--- a/app/views/organizations/_details.html.erb
+++ b/app/views/organizations/_details.html.erb
@@ -63,10 +63,10 @@
diff --git a/app/views/partners/_partner_groups_table.html.erb b/app/views/partners/_partner_groups_table.html.erb
index 94778851c3..458e606e17 100644
--- a/app/views/partners/_partner_groups_table.html.erb
+++ b/app/views/partners/_partner_groups_table.html.erb
@@ -45,8 +45,10 @@
<% if pg.send_reminders %>
- Reminder emails are sent on the <%= pg.reminder_day.ordinalize %> of every month.
-
+ <% if pg.reminder_schedule.present? %>
+ Reminder emails are sent <%= pg.show_description(pg.reminder_schedule) %>.
+
+ <% end %>
Deadlines are the <%= pg.deadline_day.ordinalize %> of every month.
<% else %>
No
diff --git a/app/views/shared/_deadline_day_fields.html.erb b/app/views/shared/_deadline_day_fields.html.erb
index cf9387075f..6760f8efc5 100644
--- a/app/views/shared/_deadline_day_fields.html.erb
+++ b/app/views/shared/_deadline_day_fields.html.erb
@@ -1,27 +1,54 @@
<%= tag.div(class: 'deadline-day-pickers',
data: {
- min: Deadlinable::MIN_DAY_OF_MONTH,
- max: Deadlinable::MAX_DAY_OF_MONTH,
current_day: Date.current.mday,
current_month: Date::MONTHNAMES[Date.current.month],
next_month: Date::MONTHNAMES[Date.current.next_month.month]
}) do %>
-
- <%= f.input :reminder_day, wrapper: :input_group, wrapper_html: { class: 'mb-0' },
- label: 'Reminder day (Day of month an e-mail reminder is sent to partners to submit requests)' do %>
+
+ <%= f.label :by_month_or_week, 'Send reminders on a specific day of the month (e.g. "the 5th") or a day of the week (eg "the first Tuesday")?' %>
+
+ <%= f.radio_button :by_month_or_week, 'day_of_month', label: 'Day of Month', id: 'toggle-to-date' %>
+ <%= f.label :by_month_or_week, 'Day of Month' %>
+
+ <%= f.radio_button :by_month_or_week, 'day_of_week', label: 'Day of the Week', id: 'toggle-to-week-day' %>
+ <%= f.label :by_month_or_week, 'Day of the Week' %>
+
+
+ <%= f.input :day_of_month, as: :integer, wrapper: :input_group,
+ label: 'Reminder date' do %>
- <%= f.number_field :reminder_day,
- class: "deadline-day-pickers__reminder-day form-control",
- placeholder: "Reminder day" %>
- <% end %>
-
-
+ <%= f.number_field :day_of_month,
+ min: Deadlinable::MIN_DAY_OF_MONTH,
+ max: Deadlinable::MAX_DAY_OF_MONTH,
+ class: "deadline-day-pickers__reminder-day form-control",
+ placeholder: "Reminder day" %>
+ <% end %>
+
+
+
+
+ <%= f.input :every_nth_day, wrapper: :input_group, wrapper_html: { class: 'mb-3'},
+ label: 'Reminder day of the week' do %>
+ <%= f.input :every_nth_day, collection: Deadlinable::EVERY_NTH_COLLECTION,
+ class: "deadline-day-pickers__reminder-day form-control",
+ label: false %>
+
+ <%= f.input :day_of_week, collection: Deadlinable::DAY_OF_WEEK_COLLECTION,
+ class: "deadline-day-pickers__reminder-day form-control",
+ label: false,
+ show_blank: true,
+ default: 1,
+ :input_html => {:style=> 'width: 200px'} %>
+
+ <% end %>
- <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { class: 'mb-0' },
+ <%= f.input :deadline_day, wrapper: :input_group, wrapper_html: { class: 'mb-0', min: 0, max: 28 },
label: 'Deadline day (Final day of month to submit requests)' do %>
<%= f.number_field :deadline_day,
+ min: Deadlinable::MIN_DAY_OF_MONTH,
+ max: Deadlinable::MAX_DAY_OF_MONTH,
class: "deadline-day-pickers__deadline-day form-control",
placeholder: "Deadline day" %>
<% end %>
diff --git a/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb b/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb
new file mode 100644
index 0000000000..4da938f248
--- /dev/null
+++ b/db/migrate/20240715155823_add_reminder_schedule_to_organizations.rb
@@ -0,0 +1,6 @@
+class AddReminderScheduleToOrganizations < ActiveRecord::Migration[7.1]
+ def change
+ add_column :organizations, :reminder_schedule, :string
+ add_column :partner_groups, :reminder_schedule, :string
+ end
+end
diff --git a/db/migrate/20240715162837_seed_reminder_schedule_data.rb b/db/migrate/20240715162837_seed_reminder_schedule_data.rb
new file mode 100644
index 0000000000..5fdc96bbe7
--- /dev/null
+++ b/db/migrate/20240715162837_seed_reminder_schedule_data.rb
@@ -0,0 +1,17 @@
+class SeedReminderScheduleData < ActiveRecord::Migration[7.1]
+ def change
+ for o in Organization.all
+ if o.reminder_day.present?
+ reminder_schedule = o.convert_to_reminder_schedule(o.reminder_day)
+ o.update(reminder_schedule: reminder_schedule)
+ end
+ end
+
+ for pg in PartnerGroup.all
+ if pg.reminder_day.present?
+ reminder_schedule = pg.convert_to_reminder_schedule(pg.reminder_day)
+ pg.update(reminder_schedule: reminder_schedule)
+ end
+ end
+ end
+end
diff --git a/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb b/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb
new file mode 100644
index 0000000000..df697e0c90
--- /dev/null
+++ b/db/migrate/20240715163348_remove_reminder_day_from_organizations.rb
@@ -0,0 +1,6 @@
+class RemoveReminderDayFromOrganizations < ActiveRecord::Migration[7.1]
+ def change
+ safety_assured { remove_column :organizations, :reminder_day, :integer }
+ safety_assured { remove_column :partner_groups, :reminder_day, :integer }
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 91483f028f..0ec4df8583 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -476,7 +476,6 @@
t.string "zipcode"
t.float "latitude"
t.float "longitude"
- t.integer "reminder_day"
t.integer "deadline_day"
t.text "invitation_text"
t.integer "default_storage_location"
@@ -493,6 +492,7 @@
t.boolean "hide_value_columns_on_receipt", default: false
t.boolean "hide_package_column_on_receipt", default: false
t.boolean "signature_for_distribution_pdf", default: false
+ t.string "reminder_schedule"
t.index ["latitude", "longitude"], name: "index_organizations_on_latitude_and_longitude"
t.index ["short_name"], name: "index_organizations_on_short_name"
end
@@ -510,12 +510,11 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "send_reminders", default: false, null: false
- t.integer "reminder_day"
t.integer "deadline_day"
+ t.string "reminder_schedule"
t.index ["name", "organization_id"], name: "index_partner_groups_on_name_and_organization_id", unique: true
t.index ["organization_id"], name: "index_partner_groups_on_organization_id"
t.check_constraint "deadline_day <= 28", name: "deadline_day_of_month_check"
- t.check_constraint "reminder_day <= 28", name: "reminder_day_of_month_check"
end
create_table "partner_profiles", force: :cascade do |t|
diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb
index 30ef97daa7..447117ef5f 100644
--- a/spec/factories/organizations.rb
+++ b/spec/factories/organizations.rb
@@ -20,7 +20,7 @@
# name :string
# one_step_partner_invite :boolean default(FALSE), not null
# partner_form_fields :text default([]), is an Array
-# reminder_day :integer
+# reminder_schedule :string
# repackage_essentials :boolean default(FALSE), not null
# short_name :string
# signature_for_distribution_pdf :boolean default(FALSE)
@@ -41,6 +41,9 @@
skip_items { false }
end
+ recurrence_schedule = IceCube::Schedule.new
+ recurrence_schedule.add_recurrence_rule IceCube::Rule.monthly(1).day_of_month(10)
+ recurrence_schedule_ical = recurrence_schedule.to_ical
sequence(:name) { |n| "Essentials Bank #{n}" } # 037000863427
sequence(:short_name) { |n| "db_#{n}" } # 037000863427
sequence(:email) { |n| "email#{n}@example.com" } # 037000863427
@@ -49,13 +52,13 @@
city { 'Front Royal' }
state { 'VA' }
zipcode { '22630' }
- reminder_day { 10 }
+ reminder_schedule { recurrence_schedule_ical }
deadline_day { 20 }
logo { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/logo.jpg"), "image/jpeg") }
trait :without_deadlines do
- reminder_day { nil }
+ reminder_schedule { nil }
deadline_day { nil }
end
diff --git a/spec/factories/partner_groups.rb b/spec/factories/partner_groups.rb
index b3723feda1..99a74042fa 100644
--- a/spec/factories/partner_groups.rb
+++ b/spec/factories/partner_groups.rb
@@ -2,25 +2,29 @@
#
# Table name: partner_groups
#
-# id :bigint not null, primary key
-# deadline_day :integer
-# name :string
-# reminder_day :integer
-# send_reminders :boolean default(FALSE), not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# organization_id :bigint
+# id :bigint not null, primary key
+# deadline_day :integer
+# name :string
+# reminder_schedule :string
+# send_reminders :boolean default(FALSE), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# organization_id :bigint
#
FactoryBot.define do
+ recurrence_schedule = IceCube::Schedule.new
+ recurrence_schedule.add_recurrence_rule IceCube::Rule.monthly(1).day_of_month(10)
+ recurrence_schedule_ical = recurrence_schedule.to_ical
+
factory :partner_group do
sequence(:name) { |n| "Group #{n}" }
organization { Organization.try(:first) || create(:organization) }
- reminder_day { 14 }
+ reminder_schedule { recurrence_schedule_ical }
deadline_day { 28 }
trait :without_deadlines do
- reminder_day { nil }
+ reminder_schedule { nil }
deadline_day { nil }
end
end
diff --git a/spec/mailers/reminder_deadline_mailer_spec.rb b/spec/mailers/reminder_deadline_mailer_spec.rb
index fde33fa564..a18cb9717a 100644
--- a/spec/mailers/reminder_deadline_mailer_spec.rb
+++ b/spec/mailers/reminder_deadline_mailer_spec.rb
@@ -4,9 +4,8 @@
describe 'notify deadline' do
let(:today) { Date.new(2022, 1, 10) }
let(:partner) { create(:partner, organization: organization) }
-
before(:each) do
- organization.update!(reminder_day: today.day, deadline_day: 1)
+ organization.update!(deadline_day: 1)
end
subject { described_class.notify_deadline(partner) }
diff --git a/spec/models/concerns/deadlinable_spec.rb b/spec/models/concerns/deadlinable_spec.rb
index 4034b5bc5f..4d56993c2f 100644
--- a/spec/models/concerns/deadlinable_spec.rb
+++ b/spec/models/concerns/deadlinable_spec.rb
@@ -8,7 +8,7 @@ def self.name
include ActiveModel::Model
include Deadlinable
- attr_accessor :deadline_day, :reminder_day
+ attr_accessor :deadline_day, :reminder_schedule
def deadline_day?
!!deadline_day
@@ -17,6 +17,12 @@ def deadline_day?
end
subject(:dummy) { dummy_class.new }
+ let(:current_day) { Time.current }
+ let(:schedule) { IceCube::Schedule.new(current_day) }
+
+ before do
+ dummy.deadline_day = 7
+ end
describe "validations" do
it do
@@ -27,20 +33,41 @@ def deadline_day?
.allow_nil
end
- it do
- is_expected.to validate_numericality_of(:reminder_day)
- .only_integer
- .is_greater_than_or_equal_to(1)
- .is_less_than_or_equal_to(28)
- .allow_nil
+ it "validates the by_month_or_week field inclusion" do
+ is_expected.to validate_inclusion_of(:by_month_or_week).in_array(%w[day_of_month day_of_week])
+ end
+
+ it "validates the day of week field inclusion" do
+ dummy.day_of_week = "0"
+ expect(dummy).to be_valid
+ dummy.day_of_week = "A"
+ expect(dummy).not_to be_valid
+ end
+
+ it "validates the by_month_or_week field inclusion" do
+ dummy.every_nth_day = "1"
+ expect(dummy).to be_valid
+ dummy.every_nth_day = "B"
+ expect(dummy).not_to be_valid
+ end
+
+ it "validates that the reminder schedule's date fall within the range" do
+ dummy.by_month_or_week = "day_of_month"
+ dummy.day_of_month = 29
+
+ expect(dummy).not_to be_valid
+ expect(dummy.errors.added?(:day_of_month, "Reminder day must be between 1 and 28")).to be_truthy
+
+ dummy.day_of_month = -1
+ expect(dummy).not_to be_valid
end
it "validates that reminder day is not the same as deadline day" do
- dummy.deadline_day = 7
- dummy.reminder_day = 7
+ dummy.by_month_or_week = "day_of_month"
+ dummy.day_of_month = dummy.deadline_day
expect(dummy).not_to be_valid
- expect(dummy.errors.added?(:reminder_day, "must be other than 7")).to be_truthy
+ expect(dummy.errors.added?(:day_of_month, "Reminder must not be the same as deadline date")).to be_truthy
end
end
end
diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb
index 89438098d9..289ab8ebc9 100644
--- a/spec/models/organization_spec.rb
+++ b/spec/models/organization_spec.rb
@@ -20,7 +20,7 @@
# name :string
# one_step_partner_invite :boolean default(FALSE), not null
# partner_form_fields :text default([]), is an Array
-# reminder_day :integer
+# reminder_schedule :string
# repackage_essentials :boolean default(FALSE), not null
# short_name :string
# signature_for_distribution_pdf :boolean default(FALSE)
@@ -447,13 +447,12 @@
end
end
- describe 'reminder_day' do
- it "can only contain numbers 1-28" do
- expect(build(:organization, reminder_day: 28)).to be_valid
- expect(build(:organization, reminder_day: 1)).to be_valid
- expect(build(:organization, reminder_day: 0)).to_not be_valid
- expect(build(:organization, reminder_day: -5)).to_not be_valid
- expect(build(:organization, reminder_day: 29)).to_not be_valid
+ describe 'reminder_schedule' do
+ it "cannot exceed 28 if by_month_or_week is day_of_month" do
+ expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 28)).to be_valid
+ expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 29)).to_not be_valid
+ expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: 0)).to_not be_valid
+ expect(build(:organization, by_month_or_week: 'day_of_month', day_of_month: -5)).to_not be_valid
end
end
describe 'deadline_day' do
diff --git a/spec/models/partner_group_spec.rb b/spec/models/partner_group_spec.rb
index 10030476ce..493e3b5520 100644
--- a/spec/models/partner_group_spec.rb
+++ b/spec/models/partner_group_spec.rb
@@ -2,14 +2,14 @@
#
# Table name: partner_groups
#
-# id :bigint not null, primary key
-# deadline_day :integer
-# name :string
-# reminder_day :integer
-# send_reminders :boolean default(FALSE), not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# organization_id :bigint
+# id :bigint not null, primary key
+# deadline_day :integer
+# name :string
+# reminder_schedule :string
+# send_reminders :boolean default(FALSE), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# organization_id :bigint
#
RSpec.describe PartnerGroup, type: :model do
describe 'associations' do
@@ -28,15 +28,16 @@
end
end
- describe 'deadline_day <= 28' do
+ describe 'deadline_day > 28' do
it 'raises error if unmet' do
expect { partner_group.update_column(:deadline_day, 29) }.to raise_error(ActiveRecord::StatementInvalid)
end
end
- describe 'reminder_day <= 28' do
+ describe 'reminder_schedule day > 28 and <=0' do
it 'raises error if unmet' do
- expect { partner_group.update_column(:reminder_day, 29) }.to raise_error(ActiveRecord::StatementInvalid)
+ expect { partner_group.update!(by_month_or_week: 'day_of_month', day_of_month: 29) }.to raise_error(ActiveRecord::RecordInvalid)
+ expect { partner_group.update!(by_month_or_week: 'day_of_month', day_of_month: -5) }.to raise_error(ActiveRecord::RecordInvalid)
end
end
end
@@ -54,8 +55,8 @@
expect(build(:partner, name: "Foo", organization: build(:organization))).to be_valid
end
- describe "deadline_day && reminder_day must be defined if send_reminders=true" do
- let(:partner_group) { build(:partner_group, send_reminders: true, deadline_day: nil, reminder_day: nil) }
+ describe "deadline_day && reminder_schedule must be defined if send_reminders=true" do
+ let(:partner_group) { build(:partner_group, send_reminders: true, deadline_day: nil, reminder_schedule: nil) }
it "should not be valid" do
expect(partner_group).not_to be_valid
diff --git a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb
index b4ca3d8eff..9ff880698f 100644
--- a/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb
+++ b/spec/services/partners/fetch_partners_to_remind_now_service_spec.rb
@@ -7,18 +7,30 @@
context "when there is a partner" do
let!(:partner) { create(:partner) }
-
context "that has an organization with a global reminder & deadline" do
context "that is for today" do
before do
- partner.organization.update(reminder_day: current_day)
- partner.organization.update(deadline_day: current_day + 2)
+ partner.organization.update(by_month_or_week: "day_of_month", day_of_month: current_day, deadline_day: current_day + 2)
end
it "should include that partner" do
+ schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule
+ expect(schedule.occurs_on?(Time.current)).to be_truthy
expect(subject).to include(partner)
end
+ context "as matched by day of the week" do
+ before do
+ partner.organization.update(by_month_or_week: "day_of_week",
+ day_of_week: 2, every_nth_day: 2, deadline_day: current_day + 2)
+ end
+ it "should include that partner" do
+ schedule = IceCube::Schedule.from_ical partner.organization.reminder_schedule
+ expect(schedule.occurs_on?(Time.current)).to be_truthy
+ expect(subject).to include(partner)
+ end
+ end
+
context "but the partner is deactivated" do
before do
partner.deactivated!
@@ -42,8 +54,8 @@
context "that is not for today" do
before do
- partner.organization.update(reminder_day: current_day - 1)
- partner.organization.update(deadline_day: current_day + 2)
+ partner.organization.update(by_month_or_week: "day_of_month",
+ day_of_month: current_day - 1, deadline_day: current_day + 2)
end
it "should NOT include that partner" do
@@ -53,11 +65,12 @@
context "AND a partner group that does have them defined" do
before do
- partner_group = create(:partner_group, reminder_day: current_day, deadline_day: current_day + 2)
+ partner_group = create(:partner_group, by_month_or_week: "day_of_month",
+ day_of_month: current_day, deadline_day: current_day + 2)
partner_group.partners << partner
- partner.organization.update(reminder_day: current_day - 1)
- partner.organization.update(deadline_day: current_day + 2)
+ partner.organization.update(by_month_or_week: "day_of_month",
+ day_of_month: current_day - 1, deadline_day: current_day + 2)
end
it "should remind based on the partner group instead of the organization level reminder" do
@@ -78,13 +91,14 @@
context "that does NOT have a organization with a global reminder & deadline" do
before do
- partner.organization.update(reminder_day: nil, deadline_day: nil)
+ partner.organization.update(reminder_schedule: nil, deadline_day: nil)
end
context "and is a part of a partner group that does have them defined" do
context "that is for today" do
before do
- partner_group = create(:partner_group, reminder_day: current_day, deadline_day: current_day + 2)
+ partner_group = create(:partner_group, by_month_or_week: "day_of_month",
+ day_of_month: current_day, deadline_day: current_day + 2)
partner_group.partners << partner
end
@@ -105,7 +119,8 @@
context "that is not for today" do
before do
- partner_group = create(:partner_group, reminder_day: current_day - 1, deadline_day: current_day + 2)
+ partner_group = create(:partner_group, by_month_or_week: "day_of_month",
+ day_of_month: current_day - 1, deadline_day: current_day + 2)
partner_group.partners << partner
end
diff --git a/spec/system/admin/organizations_system_spec.rb b/spec/system/admin/organizations_system_spec.rb
index aa94b8a792..0e8b8d170f 100644
--- a/spec/system/admin/organizations_system_spec.rb
+++ b/spec/system/admin/organizations_system_spec.rb
@@ -116,6 +116,9 @@
fill_in "organization_user_name", with: admin_user_params[:name]
fill_in "organization_user_email", with: admin_user_params[:email]
+ choose 'toggle-to-date'
+ fill_in "organization_day_of_month", with: 1
+
click_on "Save"
end
@@ -124,7 +127,6 @@
within("tr.#{org_params[:short_name]}") do
first(:link, "View").click
end
-
expect(page).to have_content(org_params[:name])
expect(page).to have_content("Remount")
expect(page).to have_content("Front Royal")
diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb
index b54b9fc42b..2c1cfd8582 100644
--- a/spec/system/partner_system_spec.rb
+++ b/spec/system/partner_system_spec.rb
@@ -496,6 +496,12 @@
# Click on the second item category
find("input#partner_group_item_category_ids_#{item_category_2.id}").click
+ # Opt in to sending deadline reminders
+ check 'Yes'
+
+ choose 'toggle-to-date'
+ fill_in "partner_group_day_of_month", with: 1
+ fill_in "partner_group_deadline_day", with: 25
find_button('Add Partner Group').click
assert page.has_content? 'Group Name', wait: page_content_wait
@@ -531,6 +537,24 @@
refute page.has_content? item_category_1.name
assert page.has_content? item_category_2.name
end
+
+ it 'should be able to edit a custom reminder schedule' do
+ visit partners_path
+
+ click_on 'Groups'
+ assert page.has_content? existing_partner_group.name, wait: page_content_wait
+
+ click_on 'Edit'
+ # Opt in to sending deadline reminders
+ check 'Yes'
+ choose 'toggle-to-week-day', wait: page_content_wait
+ select "Second", from: "partner_group_every_nth_day"
+ select "Thursday", from: "partner_group_day_of_week"
+ fill_in "partner_group_deadline_day", with: 24
+
+ find_button('Update Partner Group').click
+ assert page.has_content? 'Monthly on the 2nd Thursday', wait: page_content_wait
+ end
end
end
end
|