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..27b9a71b0b1 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 :signup_ranked_choice + + def initialize(notifier:, signup_ranked_choice:) + super(notifier:) + @signup_ranked_choice = signup_ranked_choice + end + + def user_con_profiles + [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 c448f4cafc0..deac0348def 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 [] @@ -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 @@ -64,7 +69,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 [ @@ -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 new file mode 100644 index 00000000000..5d709ff74b8 --- /dev/null +++ b/app/notifiers/signup_queue/no_ticket_reminder_notifier.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +class SignupQueue::NoTicketReminderNotifier < Notifier + attr_reader :signup_ranked_choice + delegate :user_con_profile, to: :signup_ranked_choice + + dynamic_destination :signup_ranked_choice_user_con_profile do + { signup_ranked_choice: } + end + + 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 + { signup_ranked_choice: } + 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..cf14eecae86 --- /dev/null +++ b/app/services/remind_queue_without_ticket.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +class RemindQueueWithoutTicket < CivilService::Service + private + + def inner_call + signup_ranked_choices_to_remind.each do |signup_ranked_choice| + SignupQueue::NoTicketReminderNotifier.new(signup_ranked_choice: signup_ranked_choice).deliver_later + signup_ranked_choice.user_con_profile.update_columns(queue_no_ticket_reminded_at: Time.zone.now) + end + + 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 + + @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("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], + 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 + # rubocop:enable Metrics/MethodLength +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..508dc7c892f --- /dev/null +++ b/test/services/remind_queue_without_ticket_test.rb @@ -0,0 +1,175 @@ +require "test_helper" + +class RemindQueueWithoutTicketTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + 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 + 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 + perform_enqueued_jobs { 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 + perform_enqueued_jobs { 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 + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } + end + end + end + + 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 "does not send another reminder" do + assert_no_difference "ActionMailer::Base.deliveries.size" 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 + perform_enqueued_jobs { 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 + perform_enqueued_jobs { 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 + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } + 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 + perform_enqueued_jobs { RemindQueueWithoutTicket.new.call! } + end + end + end +end