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

-

Reminder day

+

Reminder Schedule

<%= fa_icon "calendar" %> - <%= @organization.reminder_day.blank? ? 'Not defined' : "The #{@organization.reminder_day.ordinalize} of each month" %> + <%= @organization.reminder_schedule.blank? ? 'Not defined' : @organization.show_description(@organization.reminder_schedule) %>

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