From e620c1a0e431d10cd21cb1d2d351793a53568e61 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 14 Feb 2026 11:13:09 -0800 Subject: [PATCH 1/7] Add automated reminder for queue items without ticket (#10175) Refactored RemindDraftEventProposalsJob into a general SendRemindersJob that sends multiple types of reminders. Added new functionality to remind users who have items in their signup queue but no ticket (only for conventions where tickets are required). Changes: - Renamed RemindDraftEventProposalsJob to SendRemindersJob - Created RemindQueueWithoutTicket service to find users needing reminders - Added queue_no_ticket_reminded_at timestamp to user_con_profiles table - Added signup_queue/no_ticket_reminder notification type to config - Created SignupQueue::NoTicketReminderNotifier for email delivery - Added SignupRankedChoiceUserConProfileEvaluator dynamic destination - Updated RunNotificationsService to use new job name - Fixed pre-existing rubocop issue in notifier DSL The service finds users who: - Have pending items in their signup queue - Don't have a ticket - Are in a convention where tickets are required - Haven't been reminded in the last week Reminders are sent automatically when the SendRemindersJob runs (daily via RunNotificationsService). Co-Authored-By: Claude Sonnet 4.5 --- ...proposals_job.rb => send_reminders_job.rb} | 3 +- app/models/user_con_profile.rb | 1 + app/notifiers/notifier/dsl.rb | 3 + .../notifier/dynamic_destinations.rb | 13 ++ .../no_ticket_reminder_notifier.rb | 29 ++++ app/services/remind_queue_without_ticket.rb | 29 ++++ app/services/run_notifications_service.rb | 2 +- .../signup_queue/no_ticket_reminder.liquid | 47 +++++++ config/notifications.json | 11 ++ ...ticket_reminded_at_to_user_con_profiles.rb | 5 + ...ignup_queue_no_ticket_reminder_template.rb | 25 ++++ db/structure.sql | 5 +- test/factories/user_con_profiles.rb | 1 + test/models/user_con_profile_test.rb | 9 +- .../remind_queue_without_ticket_test.rb | 132 ++++++++++++++++++ 15 files changed, 308 insertions(+), 7 deletions(-) rename app/jobs/{remind_draft_event_proposals_job.rb => send_reminders_job.rb} (54%) create mode 100644 app/notifiers/signup_queue/no_ticket_reminder_notifier.rb create mode 100644 app/services/remind_queue_without_ticket.rb create mode 100644 cms_content_sets/base/notification_templates/signup_queue/no_ticket_reminder.liquid create mode 100644 db/migrate/20260214190735_add_queue_no_ticket_reminded_at_to_user_con_profiles.rb create mode 100644 db/migrate/20260214191626_load_signup_queue_no_ticket_reminder_template.rb create mode 100644 test/services/remind_queue_without_ticket_test.rb diff --git a/app/jobs/remind_draft_event_proposals_job.rb b/app/jobs/send_reminders_job.rb similarity index 54% rename from app/jobs/remind_draft_event_proposals_job.rb rename to app/jobs/send_reminders_job.rb index 5fcf1b671c3..6e740d36179 100644 --- a/app/jobs/remind_draft_event_proposals_job.rb +++ b/app/jobs/send_reminders_job.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -class RemindDraftEventProposalsJob < ApplicationJob +class SendRemindersJob < ApplicationJob def perform RemindDraftEventProposals.new.call! + RemindQueueWithoutTicket.new.call! end end diff --git a/app/models/user_con_profile.rb b/app/models/user_con_profile.rb index e222d07a627..bf7b2f98e49 100644 --- a/app/models/user_con_profile.rb +++ b/app/models/user_con_profile.rb @@ -26,6 +26,7 @@ # needs_update :boolean default(FALSE), not null # nickname :string # preferred_contact :string +# queue_no_ticket_reminded_at :datetime # ranked_choice_fallback_action :text default("waitlist"), not null # ranked_choice_ordering_boost :integer # receive_whos_free_emails :boolean default(TRUE), not null diff --git a/app/notifiers/notifier/dsl.rb b/app/notifiers/notifier/dsl.rb index 2ee3dd2a56b..b51a94a9872 100644 --- a/app/notifiers/notifier/dsl.rb +++ b/app/notifiers/notifier/dsl.rb @@ -5,6 +5,7 @@ module Notifier::Dsl event_proposal_owner: Notifier::DynamicDestinations::EventProposalOwnerEvaluator, event_team_members: Notifier::DynamicDestinations::EventTeamMembersEvaluator, order_user_con_profile: Notifier::DynamicDestinations::OrderUserConProfileEvaluator, + signup_ranked_choice_user_con_profile: Notifier::DynamicDestinations::SignupRankedChoiceUserConProfileEvaluator, signup_request_user_con_profile: Notifier::DynamicDestinations::SignupRequestUserConProfileEvaluator, signup_user_con_profile: Notifier::DynamicDestinations::SignupUserConProfileEvaluator, ticket_user_con_profile: Notifier::DynamicDestinations::TicketUserConProfileEvaluator, @@ -63,8 +64,10 @@ def condition_evaluators self.class.allowed_conditions.index_with { |condition| self.class.evaluator_for_condition(condition, self) } end + # rubocop:disable Naming/PredicateMethod def evaluate_condition(condition_type, condition_value) @condition_evaluators ||= condition_evaluators @condition_evaluators.fetch(condition_type.to_sym).matches?(condition_value) end + # rubocop:enable Naming/PredicateMethod end diff --git a/app/notifiers/notifier/dynamic_destinations.rb b/app/notifiers/notifier/dynamic_destinations.rb index de373e057af..3dbb7d7be93 100644 --- a/app/notifiers/notifier/dynamic_destinations.rb +++ b/app/notifiers/notifier/dynamic_destinations.rb @@ -123,4 +123,17 @@ def user_con_profiles user_activity_alert.notification_destinations.flat_map { |destination| destination.user_con_profiles(notifier) } end end + + class SignupRankedChoiceUserConProfileEvaluator < Evaluator + attr_reader :user_con_profile + + def initialize(notifier:, user_con_profile:) + super(notifier:) + @user_con_profile = user_con_profile + end + + def user_con_profiles + [user_con_profile] + end + end end diff --git a/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb b/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb new file mode 100644 index 00000000000..1145fdda85f --- /dev/null +++ b/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +class SignupQueue::NoTicketReminderNotifier < Notifier + attr_reader :user_con_profile + + dynamic_destination :signup_ranked_choice_user_con_profile do + { user_con_profile: } + end + + def initialize(user_con_profile:) + @user_con_profile = user_con_profile + super(convention: user_con_profile.convention, event_key: "signup_queue/no_ticket_reminder") + end + + def initializer_options + { user_con_profile: } + end + + def liquid_assigns + super.merge( + "user_con_profile" => user_con_profile, + "ticket_name" => convention.ticket_name, + "queue_items" => user_con_profile.signup_ranked_choices.where(state: "pending") + ) + end + + def self.build_default_destinations(notification_template:) + [notification_template.notification_destinations.new(dynamic_destination: :signup_ranked_choice_user_con_profile)] + end +end diff --git a/app/services/remind_queue_without_ticket.rb b/app/services/remind_queue_without_ticket.rb new file mode 100644 index 00000000000..cf72eff26eb --- /dev/null +++ b/app/services/remind_queue_without_ticket.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +class RemindQueueWithoutTicket < CivilService::Service + private + + def inner_call + users_to_remind.each do |user_con_profile| + SignupQueue::NoTicketReminderNotifier.new(user_con_profile: user_con_profile).deliver_later + user_con_profile.update_columns(queue_no_ticket_reminded_at: Time.zone.now) + end + + success + end + + def users_to_remind + @users_to_remind ||= + UserConProfile + .joins(:convention, :signup_ranked_choices) + .joins("LEFT JOIN tickets ON tickets.user_con_profile_id = user_con_profiles.id") + .where(tickets: { id: nil }) + .where(conventions: { ticket_mode: %w[required_for_signup ticket_per_event] }) + .where("conventions.starts_at > ?", Time.zone.now) + .where( + "user_con_profiles.queue_no_ticket_reminded_at IS NULL OR user_con_profiles.queue_no_ticket_reminded_at < ?", + 1.week.ago + ) + .distinct + .includes(:convention, :signup_ranked_choices) + end +end diff --git a/app/services/run_notifications_service.rb b/app/services/run_notifications_service.rb index a08c2533006..a2bcf154fa1 100644 --- a/app/services/run_notifications_service.rb +++ b/app/services/run_notifications_service.rb @@ -2,7 +2,7 @@ class RunNotificationsService < CivilService::Service private def inner_call - [NotifyEventProposalChangesJob, NotifyEventChangesJob, RemindDraftEventProposalsJob].each(&:perform_later) + [NotifyEventProposalChangesJob, NotifyEventChangesJob, SendRemindersJob].each(&:perform_later) success end end diff --git a/cms_content_sets/base/notification_templates/signup_queue/no_ticket_reminder.liquid b/cms_content_sets/base/notification_templates/signup_queue/no_ticket_reminder.liquid new file mode 100644 index 00000000000..b54bacefcc4 --- /dev/null +++ b/cms_content_sets/base/notification_templates/signup_queue/no_ticket_reminder.liquid @@ -0,0 +1,47 @@ +--- +subject: | + [{{ convention.name }}] Don't forget to get your {{ ticket_name }}! +body_text: |- + Hi {{ user_con_profile.name_without_nickname }}, + + You have {{ queue_items.size }} event{% if queue_items.size != 1 %}s{% endif %} in your signup queue for {{ convention.name }}, but you don't have a {{ ticket_name }} yet. To be eligible for signup when your queue processes, you'll need to purchase a {{ ticket_name }}. + + Your queue includes: + {%- for item in queue_items limit: 10 %} + - {{ item.target_run.event.title }}{% if item.target_run.title_suffix %} ({{ item.target_run.title_suffix }}){% endif %} + {%- endfor %} + + {% if queue_items.size > 10 %}...and {{ queue_items.size | minus: 10 }} more{% endif %} + + Get your {{ ticket_name }} now: {{ convention.url | append: '/ticket/new' | absolute_url }} + + We hope to see you at {{ convention.name }}! +--- +

Don't forget to get your {{ ticket_name }}!

+ +

Hi {{ user_con_profile.name_without_nickname }},

+ +

+ You have {{ queue_items.size }} event{% if queue_items.size != 1 %}s{% endif %} + in your signup queue for {{ convention.name }}, but you don't have a {{ ticket_name }} yet. + To be eligible for signup when your queue processes, you'll need to purchase a {{ ticket_name }}. +

+ +

Your queue includes:

+ + +{% if queue_items.size > 10 %} +

...and {{ queue_items.size | minus: 10 }} more

+{% endif %} + +

+ + Get your {{ ticket_name }} now + +

+ +

We hope to see you at {{ convention.name }}!

diff --git a/config/notifications.json b/config/notifications.json index cc958fd892f..7dad682fce7 100644 --- a/config/notifications.json +++ b/config/notifications.json @@ -70,6 +70,17 @@ } ] }, + { + "key": "signup_queue", + "name": "Signup queue", + "events": [ + { + "key": "no_ticket_reminder", + "name": "Reminder: queue items without ticket", + "destination_description": "Attendee with queue items" + } + ] + }, { "key": "signup_requests", "name": "Event signup requests", diff --git a/db/migrate/20260214190735_add_queue_no_ticket_reminded_at_to_user_con_profiles.rb b/db/migrate/20260214190735_add_queue_no_ticket_reminded_at_to_user_con_profiles.rb new file mode 100644 index 00000000000..b7c6702ed77 --- /dev/null +++ b/db/migrate/20260214190735_add_queue_no_ticket_reminded_at_to_user_con_profiles.rb @@ -0,0 +1,5 @@ +class AddQueueNoTicketRemindedAtToUserConProfiles < ActiveRecord::Migration[8.1] + def change + add_column :user_con_profiles, :queue_no_ticket_reminded_at, :datetime + end +end diff --git a/db/migrate/20260214191626_load_signup_queue_no_ticket_reminder_template.rb b/db/migrate/20260214191626_load_signup_queue_no_ticket_reminder_template.rb new file mode 100644 index 00000000000..53cf8c05a75 --- /dev/null +++ b/db/migrate/20260214191626_load_signup_queue_no_ticket_reminder_template.rb @@ -0,0 +1,25 @@ +class LoadSignupQueueNoTicketReminderTemplate < ActiveRecord::Migration[7.2] + def up + Convention.find_each do |convention| + say "Loading signup_queue/no_ticket_reminder notification template for #{convention.name}" + adapter = CmsContentStorageAdapters::NotificationTemplates.new(convention, CmsContentSet.new(name: "base")) + + item = adapter.all_items_from_disk.find { |i| i.identifier == "signup_queue/no_ticket_reminder" } + next unless item + + attrs = adapter.read_item_attrs(item) + template = convention.notification_templates.find_or_initialize_by(event_key: "signup_queue/no_ticket_reminder") + template.assign_attributes(**attrs) + template.save! + + # Build default destinations if this is a new template + if template.notification_destinations.empty? + SignupQueue::NoTicketReminderNotifier.build_default_destinations(notification_template: template).each(&:save!) + end + end + end + + def down + NotificationTemplate.where(event_key: "signup_queue/no_ticket_reminder").find_each(&:destroy!) + end +end diff --git a/db/structure.sql b/db/structure.sql index 376692c73d0..aa1a5be2942 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2955,7 +2955,8 @@ CREATE TABLE public.user_con_profiles ( allow_sms boolean DEFAULT true NOT NULL, lottery_number integer NOT NULL, ranked_choice_ordering_boost integer, - ranked_choice_fallback_action text DEFAULT 'waitlist'::text NOT NULL + ranked_choice_fallback_action text DEFAULT 'waitlist'::text NOT NULL, + queue_no_ticket_reminded_at timestamp(6) without time zone ); @@ -6135,6 +6136,8 @@ ALTER TABLE ONLY public.cms_files_pages SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260214191626'), +('20260214190735'), ('20251210230514'), ('20251109200750'), ('20251001173716'), diff --git a/test/factories/user_con_profiles.rb b/test/factories/user_con_profiles.rb index 9f58b0ca46c..70189a90db9 100644 --- a/test/factories/user_con_profiles.rb +++ b/test/factories/user_con_profiles.rb @@ -25,6 +25,7 @@ # needs_update :boolean default(FALSE), not null # nickname :string # preferred_contact :string +# queue_no_ticket_reminded_at :datetime # ranked_choice_fallback_action :text default("waitlist"), not null # ranked_choice_ordering_boost :integer # receive_whos_free_emails :boolean default(TRUE), not null diff --git a/test/models/user_con_profile_test.rb b/test/models/user_con_profile_test.rb index 4da15007424..8c07ace1c08 100644 --- a/test/models/user_con_profile_test.rb +++ b/test/models/user_con_profile_test.rb @@ -25,6 +25,7 @@ # needs_update :boolean default(FALSE), not null # nickname :string # preferred_contact :string +# queue_no_ticket_reminded_at :datetime # ranked_choice_fallback_action :text default("waitlist"), not null # ranked_choice_ordering_boost :integer # receive_whos_free_emails :boolean default(TRUE), not null @@ -47,11 +48,11 @@ # fk_rails_... (user_id => users.id) # # rubocop:enable Layout/LineLength, Lint/RedundantCopDisableDirective -require 'test_helper' +require "test_helper" class UserConProfileTest < ActiveSupport::TestCase - describe 'is_team_member' do - it 'finds a user who is a team member for an event' do + describe "is_team_member" do + it "finds a user who is a team member for an event" do team_member = create(:team_member) assert UserConProfile.is_team_member.to_a.include?(team_member.user_con_profile) end @@ -61,7 +62,7 @@ class UserConProfileTest < ActiveSupport::TestCase refute UserConProfile.is_team_member.to_a.include?(user_con_profile) end - it 'scopes correctly by convention' do + it "scopes correctly by convention" do team_member = create(:team_member) other_convention = create(:convention) diff --git a/test/services/remind_queue_without_ticket_test.rb b/test/services/remind_queue_without_ticket_test.rb new file mode 100644 index 00000000000..132933f1db2 --- /dev/null +++ b/test/services/remind_queue_without_ticket_test.rb @@ -0,0 +1,132 @@ +require "test_helper" + +class RemindQueueWithoutTicketTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + let(:convention) { create(:convention, ticket_mode: "required_for_signup", starts_at: 1.week.from_now) } + let(:user_con_profile) { create(:user_con_profile, convention: convention) } + let(:event) { create(:event, convention: convention) } + let(:event_run) { create(:run, event: event) } + + before do + # Load CMS content including notification templates + ClearCmsContentService.new(convention: convention).call! + LoadCmsContentSetService.new(convention: convention, content_set_name: "standard").call! + end + + describe "when user has queue items but no ticket" do + before do + create(:signup_ranked_choice, user_con_profile: user_con_profile, target_run: event_run, state: "pending") + end + + it "sends a reminder" do + assert_difference "ActionMailer::Base.deliveries.size", 1 do + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } + end + end + + it "updates queue_no_ticket_reminded_at timestamp" do + RemindQueueWithoutTicket.new.call! + user_con_profile.reload + assert_not_nil user_con_profile.queue_no_ticket_reminded_at + end + end + + describe "when user has a ticket" do + before do + ticket_type = create(:free_ticket_type, convention: convention) + create(:ticket, user_con_profile: user_con_profile, ticket_type: ticket_type) + create(:signup_ranked_choice, user_con_profile: user_con_profile, target_run: event_run, state: "pending") + end + + it "does not send a reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! + end + end + end + + describe "when user has no queue items" do + it "does not send a reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! + end + end + end + + describe "when user was recently reminded" do + before do + user_con_profile.update!(queue_no_ticket_reminded_at: 3.days.ago) + create(:signup_ranked_choice, user_con_profile: user_con_profile, target_run: event_run, state: "pending") + end + + it "does not send a duplicate reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! + end + end + end + + describe "when user was reminded over a week ago" do + before do + user_con_profile.update!(queue_no_ticket_reminded_at: 8.days.ago) + create(:signup_ranked_choice, user_con_profile: user_con_profile, target_run: event_run, state: "pending") + end + + it "sends another reminder" do + assert_difference "ActionMailer::Base.deliveries.size", 1 do + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } + end + end + end + + describe "when convention is badgeless" do + let(:badgeless_convention) { create(:convention, ticket_mode: "disabled", starts_at: 1.week.from_now) } + let(:badgeless_user) { create(:user_con_profile, convention: badgeless_convention) } + let(:badgeless_event) { create(:event, convention: badgeless_convention) } + let(:badgeless_run) { create(:run, event: badgeless_event) } + + before do + create(:signup_ranked_choice, user_con_profile: badgeless_user, target_run: badgeless_run, state: "pending") + end + + it "does not send a reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! + end + end + end + + describe "when convention has already started" do + let(:started_convention) { create(:convention, ticket_mode: "required_for_signup", starts_at: 1.day.ago) } + let(:started_user) { create(:user_con_profile, convention: started_convention) } + let(:started_event) { create(:event, convention: started_convention) } + let(:started_run) { create(:run, event: started_event) } + + before { create(:signup_ranked_choice, user_con_profile: started_user, target_run: started_run, state: "pending") } + + it "does not send a reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! + end + end + end + + describe "when queue items are waitlisted" do + before do + signup = create(:signup, user_con_profile: user_con_profile, run: event_run, state: "waitlisted") + create( + :signup_ranked_choice, + user_con_profile: user_con_profile, + target_run: event_run, + state: "waitlisted", + result_signup: signup + ) + end + + it "does not send a reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! + end + end + end +end From 86623897ea0232807406adfd19e3b318fc6fb2a5 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 14 Feb 2026 11:40:06 -0800 Subject: [PATCH 2/7] Add user_con_profile parameter support to NotifierPreviewFactory The signup_queue/no_ticket_reminder notifier requires a user_con_profile parameter, which needs to be handled by the preview factory for integration tests. Co-Authored-By: Claude Sonnet 4.5 --- app/notifiers/notifier_preview_factory.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/notifiers/notifier_preview_factory.rb b/app/notifiers/notifier_preview_factory.rb index c448f4cafc0..fa4a99dd724 100644 --- a/app/notifiers/notifier_preview_factory.rb +++ b/app/notifiers/notifier_preview_factory.rb @@ -26,7 +26,7 @@ def parameters # This is super not worth refactoring def synthesize_parameter_value(parameter_name) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength case parameter_name - when :alert_user_con_profile + when :alert_user_con_profile, :user_con_profile UserConProfile.new(convention: convention) when :changes [] @@ -64,7 +64,7 @@ def synthesize_parameter_value(parameter_name) # rubocop:disable Metrics/AbcSize # This is super not worth refactoring def find_parameter_value(parameter_name) # rubocop:disable Metrics case parameter_name - when :alert_user_con_profile + when :alert_user_con_profile, :user_con_profile convention.user_con_profiles.first when :changes [ From 71281656c4905afaba81ca9b0c98807eafe62776 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 14 Feb 2026 11:44:32 -0800 Subject: [PATCH 3/7] Refactor SignupRankedChoiceUserConProfileEvaluator to follow pattern The evaluator now takes a signup_ranked_choice parameter and extracts the user_con_profile from it, consistent with other evaluators like SignupUserConProfileEvaluator and OrderUserConProfileEvaluator. Updated: - SignupRankedChoiceUserConProfileEvaluator to accept signup_ranked_choice - SignupQueue::NoTicketReminderNotifier to accept signup_ranked_choice - RemindQueueWithoutTicket service to pass first pending signup_ranked_choice - NotifierPreviewFactory to handle signup_ranked_choice parameter Co-Authored-By: Claude Sonnet 4.5 --- app/notifiers/notifier/dynamic_destinations.rb | 8 ++++---- app/notifiers/notifier_preview_factory.rb | 7 +++++++ .../signup_queue/no_ticket_reminder_notifier.rb | 11 ++++++----- app/services/remind_queue_without_ticket.rb | 7 ++++++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/notifiers/notifier/dynamic_destinations.rb b/app/notifiers/notifier/dynamic_destinations.rb index 3dbb7d7be93..27b9a71b0b1 100644 --- a/app/notifiers/notifier/dynamic_destinations.rb +++ b/app/notifiers/notifier/dynamic_destinations.rb @@ -125,15 +125,15 @@ def user_con_profiles end class SignupRankedChoiceUserConProfileEvaluator < Evaluator - attr_reader :user_con_profile + attr_reader :signup_ranked_choice - def initialize(notifier:, user_con_profile:) + def initialize(notifier:, signup_ranked_choice:) super(notifier:) - @user_con_profile = user_con_profile + @signup_ranked_choice = signup_ranked_choice end def user_con_profiles - [user_con_profile] + [signup_ranked_choice.user_con_profile] end end end diff --git a/app/notifiers/notifier_preview_factory.rb b/app/notifiers/notifier_preview_factory.rb index fa4a99dd724..deac0348def 100644 --- a/app/notifiers/notifier_preview_factory.rb +++ b/app/notifiers/notifier_preview_factory.rb @@ -50,6 +50,11 @@ def synthesize_parameter_value(parameter_name) # rubocop:disable Metrics/AbcSize Order.new(user_con_profile: UserConProfile.new(convention: convention)) when :signup Signup.new(run: Run.new(event: Event.new(convention: convention, title: "Event Title"))) + when :signup_ranked_choice + SignupRankedChoice.new( + user_con_profile: UserConProfile.new(convention: convention), + target_run: Run.new(event: Event.new(convention: convention, title: "Event Title")) + ) when :signup_request SignupRequest.new(target_run: Run.new(event: Event.new(convention: convention, title: "Event Title"))) when :ticket @@ -89,6 +94,8 @@ def find_parameter_value(parameter_name) # rubocop:disable Metrics "refund-abc123" when :signup convention.signups.first + when :signup_ranked_choice + SignupRankedChoice.where(user_con_profile: convention.user_con_profiles.select(:id)).first when :signup_request convention.signup_requests.first when :ticket diff --git a/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb b/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb index 1145fdda85f..5d709ff74b8 100644 --- a/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb +++ b/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb @@ -1,18 +1,19 @@ # frozen_string_literal: true class SignupQueue::NoTicketReminderNotifier < Notifier - attr_reader :user_con_profile + attr_reader :signup_ranked_choice + delegate :user_con_profile, to: :signup_ranked_choice dynamic_destination :signup_ranked_choice_user_con_profile do - { user_con_profile: } + { signup_ranked_choice: } end - def initialize(user_con_profile:) - @user_con_profile = user_con_profile + def initialize(signup_ranked_choice:) + @signup_ranked_choice = signup_ranked_choice super(convention: user_con_profile.convention, event_key: "signup_queue/no_ticket_reminder") end def initializer_options - { user_con_profile: } + { signup_ranked_choice: } end def liquid_assigns diff --git a/app/services/remind_queue_without_ticket.rb b/app/services/remind_queue_without_ticket.rb index cf72eff26eb..c356e3ef90b 100644 --- a/app/services/remind_queue_without_ticket.rb +++ b/app/services/remind_queue_without_ticket.rb @@ -4,7 +4,11 @@ class RemindQueueWithoutTicket < CivilService::Service def inner_call users_to_remind.each do |user_con_profile| - SignupQueue::NoTicketReminderNotifier.new(user_con_profile: user_con_profile).deliver_later + # Get the first pending signup_ranked_choice for this user to pass to the notifier + signup_ranked_choice = user_con_profile.signup_ranked_choices.find { |src| src.state == "pending" } + next unless signup_ranked_choice + + SignupQueue::NoTicketReminderNotifier.new(signup_ranked_choice: signup_ranked_choice).deliver_later user_con_profile.update_columns(queue_no_ticket_reminded_at: Time.zone.now) end @@ -17,6 +21,7 @@ def users_to_remind .joins(:convention, :signup_ranked_choices) .joins("LEFT JOIN tickets ON tickets.user_con_profile_id = user_con_profiles.id") .where(tickets: { id: nil }) + .where(signup_ranked_choices: { state: "pending" }) .where(conventions: { ticket_mode: %w[required_for_signup ticket_per_event] }) .where("conventions.starts_at > ?", Time.zone.now) .where( From 1f555472d77a68696a05b2fab309e35d0d54761b Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 14 Feb 2026 11:47:16 -0800 Subject: [PATCH 4/7] Simplify RemindQueueWithoutTicket to query SignupRankedChoices directly Instead of finding UserConProfiles and then searching for their signup_ranked_choices, the service now queries SignupRankedChoices directly and uses DISTINCT ON to get one per user. This is more efficient and aligns better with the notifier's interface. Co-Authored-By: Claude Sonnet 4.5 --- app/services/remind_queue_without_ticket.rb | 23 +++++++++------------ 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/app/services/remind_queue_without_ticket.rb b/app/services/remind_queue_without_ticket.rb index c356e3ef90b..5fe95de911d 100644 --- a/app/services/remind_queue_without_ticket.rb +++ b/app/services/remind_queue_without_ticket.rb @@ -3,32 +3,29 @@ class RemindQueueWithoutTicket < CivilService::Service private def inner_call - users_to_remind.each do |user_con_profile| - # Get the first pending signup_ranked_choice for this user to pass to the notifier - signup_ranked_choice = user_con_profile.signup_ranked_choices.find { |src| src.state == "pending" } - next unless signup_ranked_choice - + signup_ranked_choices_to_remind.each do |signup_ranked_choice| SignupQueue::NoTicketReminderNotifier.new(signup_ranked_choice: signup_ranked_choice).deliver_later - user_con_profile.update_columns(queue_no_ticket_reminded_at: Time.zone.now) + signup_ranked_choice.user_con_profile.update_columns(queue_no_ticket_reminded_at: Time.zone.now) end success end - def users_to_remind - @users_to_remind ||= - UserConProfile - .joins(:convention, :signup_ranked_choices) + def signup_ranked_choices_to_remind + @signup_ranked_choices_to_remind ||= + SignupRankedChoice + .joins(:user_con_profile) + .joins(user_con_profile: :convention) .joins("LEFT JOIN tickets ON tickets.user_con_profile_id = user_con_profiles.id") + .where(state: "pending") .where(tickets: { id: nil }) - .where(signup_ranked_choices: { state: "pending" }) .where(conventions: { ticket_mode: %w[required_for_signup ticket_per_event] }) .where("conventions.starts_at > ?", Time.zone.now) .where( "user_con_profiles.queue_no_ticket_reminded_at IS NULL OR user_con_profiles.queue_no_ticket_reminded_at < ?", 1.week.ago ) - .distinct - .includes(:convention, :signup_ranked_choices) + .select("DISTINCT ON (user_con_profiles.id) signup_ranked_choices.*") + .includes(user_con_profile: :convention) end end From 3371c8b76618ad4c0d74bb24706d96788340aa52 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 14 Feb 2026 11:52:44 -0800 Subject: [PATCH 5/7] Change reminder timing to one week before first automated signup round Updated RemindQueueWithoutTicket to: - Send reminders only once (no repeat reminders) - Send exactly one week before the first signup round with automated signups - Only apply to conventions using ranked_choice signup automation - Use BETWEEN clause to match signup rounds in the reminder window Updated tests to: - Create signup rounds with automation_action and ranked_choice_order - Verify reminder is not sent when already reminded - Add test for when signup round is not in reminder window - Update convention to use signup_automation_mode: 'ranked_choice' Co-Authored-By: Claude Sonnet 4.5 --- app/services/remind_queue_without_ticket.rb | 22 ++++++-- .../remind_queue_without_ticket_test.rb | 53 +++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/app/services/remind_queue_without_ticket.rb b/app/services/remind_queue_without_ticket.rb index 5fe95de911d..b41efff4282 100644 --- a/app/services/remind_queue_without_ticket.rb +++ b/app/services/remind_queue_without_ticket.rb @@ -12,20 +12,34 @@ def inner_call end def signup_ranked_choices_to_remind + reminder_window_start = 1.week.from_now.beginning_of_day + reminder_window_end = 1.week.from_now.end_of_day + @signup_ranked_choices_to_remind ||= SignupRankedChoice .joins(:user_con_profile) .joins(user_con_profile: :convention) .joins("LEFT JOIN tickets ON tickets.user_con_profile_id = user_con_profiles.id") + .joins(sanitize_sql_array([<<~SQL.squish, reminder_window_start, reminder_window_end])) + INNER JOIN signup_rounds ON signup_rounds.convention_id = conventions.id + AND signup_rounds.automation_action = 'execute_ranked_choice' + AND signup_rounds.executed_at IS NULL + AND signup_rounds.start BETWEEN ? AND ? + SQL .where(state: "pending") .where(tickets: { id: nil }) - .where(conventions: { ticket_mode: %w[required_for_signup ticket_per_event] }) - .where("conventions.starts_at > ?", Time.zone.now) .where( - "user_con_profiles.queue_no_ticket_reminded_at IS NULL OR user_con_profiles.queue_no_ticket_reminded_at < ?", - 1.week.ago + conventions: { + ticket_mode: %w[required_for_signup ticket_per_event], + signup_automation_mode: "ranked_choice" + } ) + .where(user_con_profiles: { queue_no_ticket_reminded_at: nil }) .select("DISTINCT ON (user_con_profiles.id) signup_ranked_choices.*") .includes(user_con_profile: :convention) end + + def sanitize_sql_array(array) + SignupRankedChoice.sanitize_sql_array(array) + end end diff --git a/test/services/remind_queue_without_ticket_test.rb b/test/services/remind_queue_without_ticket_test.rb index 132933f1db2..ea658ab479c 100644 --- a/test/services/remind_queue_without_ticket_test.rb +++ b/test/services/remind_queue_without_ticket_test.rb @@ -2,15 +2,33 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase include ActiveJob::TestHelper - let(:convention) { create(:convention, ticket_mode: "required_for_signup", starts_at: 1.week.from_now) } + let(:convention) do + create( + :convention, + ticket_mode: "required_for_signup", + signup_automation_mode: "ranked_choice", + starts_at: 2.weeks.from_now + ) + end let(:user_con_profile) { create(:user_con_profile, convention: convention) } let(:event) { create(:event, convention: convention) } let(:event_run) { create(:run, event: event) } + let(:signup_round) do + create( + :signup_round, + convention: convention, + automation_action: "execute_ranked_choice", + ranked_choice_order: "asc", + start: 1.week.from_now + ) + end before do # Load CMS content including notification templates ClearCmsContentService.new(convention: convention).call! LoadCmsContentSetService.new(convention: convention, content_set_name: "standard").call! + # Ensure signup round exists + signup_round end describe "when user has queue items but no ticket" do @@ -66,15 +84,15 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase end end - describe "when user was reminded over a week ago" do + describe "when user was already reminded" do before do user_con_profile.update!(queue_no_ticket_reminded_at: 8.days.ago) create(:signup_ranked_choice, user_con_profile: user_con_profile, target_run: event_run, state: "pending") end - it "sends another reminder" do - assert_difference "ActionMailer::Base.deliveries.size", 1 do - perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } + it "does not send another reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! end end end @@ -129,4 +147,29 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase end end end + + describe "when signup round is not one week away" do + let(:far_signup_round) do + create( + :signup_round, + convention: convention, + automation_action: "execute_ranked_choice", + ranked_choice_order: "asc", + start: 2.weeks.from_now + ) + end + + before do + # Override the default signup round with one that's 2 weeks away + SignupRound.where(id: signup_round.id).delete_all + far_signup_round + create(:signup_ranked_choice, user_con_profile: user_con_profile, target_run: event_run, state: "pending") + end + + it "does not send a reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" do + RemindQueueWithoutTicket.new.call! + end + end + end end From 42ca1dc5231ff214ef905c127b97032a1c3ca9dc Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 14 Feb 2026 11:58:15 -0800 Subject: [PATCH 6/7] Simplify parameter binding by using .where instead of sanitize_sql_array Moved the signup_rounds filtering from .joins to .where clauses, which provides cleaner native parameter binding with ? placeholders. The join now only establishes the relationship, while all filtering happens in .where clauses. Co-Authored-By: Claude Sonnet 4.5 --- app/services/remind_queue_without_ticket.rb | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/services/remind_queue_without_ticket.rb b/app/services/remind_queue_without_ticket.rb index b41efff4282..cf14eecae86 100644 --- a/app/services/remind_queue_without_ticket.rb +++ b/app/services/remind_queue_without_ticket.rb @@ -11,6 +11,7 @@ def inner_call success end + # rubocop:disable Metrics/MethodLength def signup_ranked_choices_to_remind reminder_window_start = 1.week.from_now.beginning_of_day reminder_window_end = 1.week.from_now.end_of_day @@ -20,14 +21,11 @@ def signup_ranked_choices_to_remind .joins(:user_con_profile) .joins(user_con_profile: :convention) .joins("LEFT JOIN tickets ON tickets.user_con_profile_id = user_con_profiles.id") - .joins(sanitize_sql_array([<<~SQL.squish, reminder_window_start, reminder_window_end])) - INNER JOIN signup_rounds ON signup_rounds.convention_id = conventions.id - AND signup_rounds.automation_action = 'execute_ranked_choice' - AND signup_rounds.executed_at IS NULL - AND signup_rounds.start BETWEEN ? AND ? - SQL + .joins("INNER JOIN signup_rounds ON signup_rounds.convention_id = conventions.id") .where(state: "pending") .where(tickets: { id: nil }) + .where(signup_rounds: { automation_action: "execute_ranked_choice", executed_at: nil }) + .where("signup_rounds.start BETWEEN ? AND ?", reminder_window_start, reminder_window_end) .where( conventions: { ticket_mode: %w[required_for_signup ticket_per_event], @@ -38,8 +36,5 @@ def signup_ranked_choices_to_remind .select("DISTINCT ON (user_con_profiles.id) signup_ranked_choices.*") .includes(user_con_profile: :convention) end - - def sanitize_sql_array(array) - SignupRankedChoice.sanitize_sql_array(array) - end + # rubocop:enable Metrics/MethodLength end From 306f016a9e01d894d37090e92b193c4fc9566a8f Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sun, 15 Feb 2026 12:51:39 -0800 Subject: [PATCH 7/7] Add perform_enqueued_jobs to all test assertions Previously, only tests asserting that emails were sent used perform_enqueued_jobs. Tests asserting no emails were sent didn't actually process the job queue, so they could accidentally pass even if emails would have been sent. Now all tests consistently use perform_enqueued_jobs to ensure the job queue is processed and assertions are properly validated. Co-Authored-By: Claude Sonnet 4.5 --- .../services/remind_queue_without_ticket_test.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/services/remind_queue_without_ticket_test.rb b/test/services/remind_queue_without_ticket_test.rb index ea658ab479c..508dc7c892f 100644 --- a/test/services/remind_queue_without_ticket_test.rb +++ b/test/services/remind_queue_without_ticket_test.rb @@ -58,7 +58,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase it "does not send a reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end @@ -66,7 +66,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase describe "when user has no queue items" do it "does not send a reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end @@ -79,7 +79,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase it "does not send a duplicate reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end @@ -92,7 +92,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase it "does not send another reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end @@ -109,7 +109,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase it "does not send a reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end @@ -124,7 +124,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase it "does not send a reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end @@ -143,7 +143,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase it "does not send a reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end @@ -168,7 +168,7 @@ class RemindQueueWithoutTicketTest < ActiveSupport::TestCase it "does not send a reminder" do assert_no_difference "ActionMailer::Base.deliveries.size" do - RemindQueueWithoutTicket.new.call! + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } end end end