From dcb7cbeaf88e252c1d828fef8f90423a32aca695 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 10:34:53 -0400 Subject: [PATCH 1/4] Notify admins when a registrant submits a duplicate registration Co-Authored-By: Claude Opus 5.5 (1M context) From 42940e3ad7d64f6b76fc06a8a81433197f8e14a3 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 10:43:27 -0400 Subject: [PATCH 2/4] Email admins a DUPLICATE FYI when a registrant resubmits Resubmitting against an active registration silently merged the answers (including a new scholarship request) with no email to anyone, so staff missed scholarship applications that arrived as second submissions. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/jobs/notification_mailer_job.rb | 1 + app/mailers/notification_mailer.rb | 14 ++++ app/models/notification.rb | 2 + .../public_registration.rb | 17 +++++ .../_event_registration_fyi_details.html.erb | 62 ++++++++++++++++++ .../_event_registration_fyi_details.text.erb | 32 +++++++++ ...ent_registration_confirmation_fyi.html.erb | 65 +------------------ ...ent_registration_confirmation_fyi.text.erb | 35 +--------- .../event_registration_duplicate_fyi.html.erb | 24 +++++++ .../event_registration_duplicate_fyi.text.erb | 20 ++++++ config/features.yml | 13 ++++ spec/mailers/notification_mailer_spec.rb | 34 ++++++++++ .../public_registration_spec.rb | 40 ++++++++++-- .../previews/notification_mailer_preview.rb | 21 ++++++ 14 files changed, 278 insertions(+), 102 deletions(-) create mode 100644 app/views/notification_mailer/_event_registration_fyi_details.html.erb create mode 100644 app/views/notification_mailer/_event_registration_fyi_details.text.erb create mode 100644 app/views/notification_mailer/event_registration_duplicate_fyi.html.erb create mode 100644 app/views/notification_mailer/event_registration_duplicate_fyi.text.erb diff --git a/app/jobs/notification_mailer_job.rb b/app/jobs/notification_mailer_job.rb index 12aa63862f..a06f81acf2 100644 --- a/app/jobs/notification_mailer_job.rb +++ b/app/jobs/notification_mailer_job.rb @@ -20,6 +20,7 @@ def perform(notification_id, persist_delivered_email: true) "reset_password_fyi" => ->(n) { NotificationMailer.reset_password_fyi(n) }, "event_registration_confirmation" => ->(n) { EventMailer.event_registration_confirmation(n.noticeable) }, "event_registration_confirmation_fyi" => ->(n) { NotificationMailer.event_registration_confirmation_fyi(n) }, + "event_registration_duplicate_fyi" => ->(n) { NotificationMailer.event_registration_duplicate_fyi(n) }, "event_registration_cancelled" => ->(n) { EventMailer.event_registration_cancelled(n.noticeable) }, "event_registration_cancelled_fyi" => ->(n) { NotificationMailer.event_registration_cancelled_fyi(n) }, "event_registration_reminder" => ->(n) { EventMailer.event_registration_reminder(n.noticeable, custom_message: n.custom_message, custom_subject: n.custom_subject, hide_event_card: n.hide_event_card, hide_ticket_button: n.hide_ticket_button) }, diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index aa31d30700..8bbaf240a0 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -18,6 +18,20 @@ def event_registration_confirmation_fyi(notification) ) end + def event_registration_duplicate_fyi(notification) + @event_registration = notification.noticeable + @event = @event_registration.event.decorate + @person = @event_registration.registrant + @resubmitted_at = notification.created_at + @scholarship_note = notification.custom_message.presence + @notification_type = "Duplicate event registration" + + scholarship_suffix = " (scholarship now requested)" if @scholarship_note + mail( + subject: "#{FYI_PREFIX} DUPLICATE #{@event_registration.registration_subject_noun} by #{@person.full_name} to #{@event.title}#{scholarship_suffix}" + ) + end + # Takes the FormSubmission directly (not a Notification record, unlike the others). # `updated:` distinguishes an edit to an existing submission from a first one. def callout_form_submitted_fyi(form_submission, updated: false) diff --git a/app/models/notification.rb b/app/models/notification.rb index 173a0c56bc..61b28a8e2c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -27,6 +27,7 @@ class Notification < ApplicationRecord event_registration_confirmation event_registration_confirmation_fyi + event_registration_duplicate_fyi event_registration_cancelled event_registration_cancelled_fyi event_registration_reminder @@ -102,6 +103,7 @@ class Notification < ApplicationRecord [ "Admin FYI (all)", "[FYI]" ], [ "Admin FYI: event registration confirmed", "[FYI] New event registration" ], [ "Admin FYI: event scholarship registration confirmed", "[FYI] New event scholarship registration" ], + [ "Admin FYI: duplicate event registration", "[FYI] DUPLICATE event" ], [ "Admin FYI: event registration cancelled", "[FYI] Event registration cancelled" ], [ "Admin FYI: event scholarship registration cancelled", "[FYI] Event scholarship registration cancelled" ], [ "Admin FYI: bulk payment", "[FYI] New bulk payment" ], diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 96070da29c..dcac69e4b0 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -36,6 +36,8 @@ class PublicRegistration # and specs agree. SOMEONE_ELSE_WILL_PAY_IDENTIFIER = "someone_else_will_pay".freeze + SCHOLARSHIP_ADDED_NOTE = "This submission requested a scholarship; the original registration did not.".freeze + def self.call(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, scholarship_form: nil, scholarship_params: {}, continuing_education_form: nil, continuing_education_params: {}) @@ -92,6 +94,7 @@ def call ) if existing + scholarship_added = @scholarship_requested && !existing.scholarship_requested? existing.update!(scholarship_requested: true) if @scholarship_requested create_ce_registration(existing, person) existing.update!(w9_requested: true) if w9_requested? @@ -103,6 +106,8 @@ def call if existing.status == "cancelled" existing.update!(status: "registered") send_notifications(existing) + else + send_duplicate_notification(existing, scholarship_added:) end organization_link = connect_organization(existing, organization) submission = create_form_submission(person) @@ -401,5 +406,17 @@ def send_notifications(event_registration) notification_type: 0 ) end + + # custom_message carries what the resubmission changed, for the mailer to call out. + def send_duplicate_notification(event_registration, scholarship_added:) + NotificationServices::CreateNotification.call( + noticeable: event_registration, + kind: :event_registration_duplicate_fyi, + recipient_role: :admin, + recipient_email: Organization.programs_email, + custom_message: (SCHOLARSHIP_ADDED_NOTE if scholarship_added), + notification_type: 1 + ) + end end end diff --git a/app/views/notification_mailer/_event_registration_fyi_details.html.erb b/app/views/notification_mailer/_event_registration_fyi_details.html.erb new file mode 100644 index 0000000000..003fb907e5 --- /dev/null +++ b/app/views/notification_mailer/_event_registration_fyi_details.html.erb @@ -0,0 +1,62 @@ +
+ <% if @event.respond_to?(:pre_title) && @event.pre_title.present? %> +

+ <%= @event.pre_title %> +

+ <% end %> + +

+ <%= @event.title %> +

+ +

+ <%= @event.times(display_day: true, display_date: true) %> +

+ + <% if @event.labelled_cost.present? %> +

+ <%= @event.labelled_cost %> +

+ <% end %> + + <% if @event.location.present? %> +

+ <%= @event.location.name %> +

+ <% end %> + + <%# Virtual events show only the platform label (e.g. "Zoom") — the join link, + meeting ID, and passcode are withheld and live on the ticket pages. %> + <% if event_platform_label(@event).present? %> +

+ <%= event_platform_label(@event) %> +

+ <% end %> +
+ +

+ <%= link_to "View ticket", + registration_ticket_url(@event_registration.slug), + style: "display:inline-block; + padding:10px 16px; + background:#2563eb; + color:#ffffff; + text-decoration:none; + border-radius:6px; + font-weight:bold;" if @event_registration.present? %> + + + View profile + + + + + View event + +

diff --git a/app/views/notification_mailer/_event_registration_fyi_details.text.erb b/app/views/notification_mailer/_event_registration_fyi_details.text.erb new file mode 100644 index 0000000000..204f43274f --- /dev/null +++ b/app/views/notification_mailer/_event_registration_fyi_details.text.erb @@ -0,0 +1,32 @@ +Event +<%= @event.title %> + +Event date +<%= @event.times(display_day: true, display_date: true) %> + +<% if @event.location.present? %> + Location + <%= @event.location.name %> +<% end %> + +<% if event_platform_label(@event).present? %> + Videoconference + <%= event_platform_label(@event) %> +<% end %> + +<% if @event.labelled_cost.present? %> + Cost + <%= @event.labelled_cost %> +<% end %> + +------------------------------------------------------------ + +View ticket: +<%= registration_ticket_url(@event_registration.slug) if @event_registration.present? %> + +View profile: +<%= person_url(@person) %> + +View event: +<%= event_url(@event, reg: @event_registration.slug) %> + diff --git a/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb b/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb index 30c2863d9d..d972844b7f 100644 --- a/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb +++ b/app/views/notification_mailer/event_registration_confirmation_fyi.html.erb @@ -1,5 +1,3 @@ -<% profile_url = person_url(@person) %> -

New Event Registration

@@ -16,65 +14,4 @@ .strftime("%B %-d, %Y at %-l:%M %p %Z") %>

-
- <% if @event.respond_to?(:pre_title) && @event.pre_title.present? %> -

- <%= @event.pre_title %> -

- <% end %> - -

- <%= @event.title %> -

- -

- <%= @event.times(display_day: true, display_date: true) %> -

- - <% if @event.labelled_cost.present? %> -

- <%= @event.labelled_cost %> -

- <% end %> - - <% if @event.location.present? %> -

- <%= @event.location.name %> -

- <% end %> - - <%# Virtual events show only the platform label (e.g. "Zoom") — the join link, - meeting ID, and passcode are withheld and live on the ticket pages. %> - <% if event_platform_label(@event).present? %> -

- <%= event_platform_label(@event) %> -

- <% end %> -
- -

- <%= link_to "View ticket", - registration_ticket_url(@event_registration.slug), - style: "display:inline-block; - padding:10px 16px; - background:#2563eb; - color:#ffffff; - text-decoration:none; - border-radius:6px; - font-weight:bold;" if @event_registration.present? %> - - - View profile - - - - - View event - -

+<%= render "event_registration_fyi_details" %> diff --git a/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb b/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb index 14e6d15077..cbcf264ca8 100644 --- a/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb +++ b/app/views/notification_mailer/event_registration_confirmation_fyi.text.erb @@ -1,5 +1,3 @@ -<% profile_url = person_url(@person) %> - New Event Registration ========================== @@ -12,35 +10,4 @@ Registered on ------------------------------------------------------------ -Event -<%= @event.title %> - -Event date -<%= @event.times(display_day: true, display_date: true) %> - -<% if @event.location.present? %> - Location - <%= @event.location.name %> -<% end %> - -<% if event_platform_label(@event).present? %> - Videoconference - <%= event_platform_label(@event) %> -<% end %> - -<% if @event.labelled_cost.present? %> - Cost - <%= @event.labelled_cost %> -<% end %> - ------------------------------------------------------------- - -View ticket: -<%= registration_ticket_url(@event_registration.slug) if @event_registration.present? %> - -View profile: -<%= profile_url %> - -View event: -<%= event_url(@event, reg: @event_registration.slug) %> - +<%= render "event_registration_fyi_details" %> diff --git a/app/views/notification_mailer/event_registration_duplicate_fyi.html.erb b/app/views/notification_mailer/event_registration_duplicate_fyi.html.erb new file mode 100644 index 0000000000..3ed5c82f21 --- /dev/null +++ b/app/views/notification_mailer/event_registration_duplicate_fyi.html.erb @@ -0,0 +1,24 @@ +

+ DUPLICATE <%= @event_registration.registration_subject_noun %> +

+ +

+ <%= @person.full_name %> + (<%= @person.preferred_email %>) +

+ +

+ Resubmitted on + <%= @resubmitted_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %>. + Originally registered on + <%= @event_registration.created_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %>. + The answers were added to the existing registration. +

+ +<% if @scholarship_note %> +

+ <%= @scholarship_note %> +

+<% end %> + +<%= render "event_registration_fyi_details" %> diff --git a/app/views/notification_mailer/event_registration_duplicate_fyi.text.erb b/app/views/notification_mailer/event_registration_duplicate_fyi.text.erb new file mode 100644 index 0000000000..02fc41b2fb --- /dev/null +++ b/app/views/notification_mailer/event_registration_duplicate_fyi.text.erb @@ -0,0 +1,20 @@ +DUPLICATE <%= @event_registration.registration_subject_noun %> +========================== + +<%= @person.full_name %> (<%= @person.preferred_email %>) + +Resubmitted on +<%= @resubmitted_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %> + +Originally registered on +<%= @event_registration.created_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %> + +The answers were added to the existing registration. +<% if @scholarship_note %> + +*** <%= @scholarship_note %> *** +<% end %> + +------------------------------------------------------------ + +<%= render "event_registration_fyi_details" %> diff --git a/config/features.yml b/config/features.yml index de88f1905d..4cc148a362 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4510,3 +4510,16 @@ pro_tips: - "Logged in, your name and reply email fill in for you under \"From\", so you only write a subject and message." - "Links that pre-fill a subject (like an affiliation change request) drop you straight into the message box." + +- name: "Admins are emailed when someone resubmits a registration" + area: registration + display_status: admin_facing + released_on: 2026-10-14 + pr_number: 2684 + summary: >- + When someone who is already registered for an event submits the registration + form again, programs gets a "[FYI] DUPLICATE" email. If the new submission + asks for a scholarship the original didn't, the email says so. + pro_tips: + - "The new answers are added to the existing registration, so the person still has just one registration." + - "Filter the Communications page by \"Admin FYI: duplicate event registration\" to see these emails." diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 1a1cc0a079..cfb5dae6ad 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -86,6 +86,40 @@ end end + describe "#event_registration_duplicate_fyi" do + let(:event_registration) { create(:event_registration, scholarship_requested: false) } + let(:notification) do + create(:notification, kind: "event_registration_duplicate_fyi", noticeable: event_registration) + end + let(:mail) { described_class.event_registration_duplicate_fyi(notification) } + + it "flags the subject and body as a duplicate" do + expect(mail.subject).to include("[FYI] DUPLICATE event registration by") + expect(mail.html_part.body.decoded).to include("DUPLICATE event registration") + expect(mail.text_part.body.decoded).to include("DUPLICATE event registration") + end + + it "leaves out the scholarship note when the resubmission didn't add one" do + expect(mail.subject).not_to include("scholarship") + expect(mail.html_part.body.decoded).not_to include(EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) + end + + context "when the resubmission newly requested a scholarship" do + let(:event_registration) { create(:event_registration, scholarship_requested: true) } + let(:notification) do + create(:notification, kind: "event_registration_duplicate_fyi", noticeable: event_registration, + custom_message: EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) + end + + it "calls out the scholarship request in the subject and body" do + expect(mail.subject).to include("DUPLICATE event scholarship registration by") + expect(mail.subject).to end_with("(scholarship now requested)") + expect(mail.html_part.body.decoded).to include(EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) + expect(mail.text_part.body.decoded).to include(EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) + end + end + end + describe "#report_submitted_fyi" do let(:notification) { create(:notification, kind: :report_submitted_fyi) } diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 89a54e7c13..283a68e5fa 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -1070,18 +1070,50 @@ def register_with_additional_forms(selections) form_params: base_form_params(first_name: "Jane", last_name: "Doe", email: "jane@example.com") ) end + end - it "does not send notifications for an already-active registration" do - cancelled_registration.update!(status: "registered") - - expect(NotificationServices::CreateNotification).not_to receive(:call) + describe "duplicate submission for an active registration" do + let(:person) { create(:person, first_name: "Jane", last_name: "Doe", email: "jane@example.com") } + let!(:active_registration) do + create(:event_registration, event: event, registrant: person, scholarship_requested: false) + end + def resubmit(scholarship_requested: false) described_class.call( event: event, registration_form: form, + scholarship_requested: scholarship_requested, form_params: base_form_params(first_name: "Jane", last_name: "Doe", email: "jane@example.com") ) end + + it "sends admins a duplicate FYI and no registrant confirmation" do + expect(NotificationServices::CreateNotification).to receive(:call).once.with( + hash_including(kind: :event_registration_duplicate_fyi, recipient_role: :admin, + noticeable: active_registration, custom_message: nil) + ) + + resubmit + end + + it "notes a newly requested scholarship on the duplicate FYI" do + expect(NotificationServices::CreateNotification).to receive(:call).once.with( + hash_including(kind: :event_registration_duplicate_fyi, + custom_message: described_class::SCHOLARSHIP_ADDED_NOTE) + ) + + resubmit(scholarship_requested: true) + end + + it "leaves the note off when the registration already requested a scholarship" do + active_registration.update!(scholarship_requested: true) + + expect(NotificationServices::CreateNotification).to receive(:call).once.with( + hash_including(kind: :event_registration_duplicate_fyi, custom_message: nil) + ) + + resubmit(scholarship_requested: true) + end end describe "saving an \"Other\" answer with custom text" do diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index 63f2b53139..07e60c5883 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -19,6 +19,27 @@ def event_registration_confirmation_fyi NotificationMailer.event_registration_confirmation_fyi(notification) end + def event_registration_duplicate_fyi + event_registration = + EventRegistration.first || + EventRegistration.create!( + event: Event.first || raise("Need an Event"), + registrant: User.first || raise("Need a User") + ) + + notification = find_valid_notification("event_registration_duplicate_fyi") || + Notification.create!( + noticeable: event_registration, + notification_type: 1, + kind: "event_registration_duplicate_fyi", + recipient_role: "admin", + recipient_email: programs_email, + custom_message: EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE + ) + + NotificationMailer.event_registration_duplicate_fyi(notification) + end + def callout_form_submitted_fyi submission = FormSubmission.order(:id).last || FormSubmission.create!( From 581fea9336377586ab793373bee137d64b7f0484 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 10:57:19 -0400 Subject: [PATCH 3/4] Send the registrant a DUPLICATE confirmation and list every resubmission change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrants who resubmitted heard nothing back either, and admins only saw a scholarship change — CE, W-9, invoice, payment method, and who's paying changes were merged silently. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 3 +- app/jobs/notification_mailer_job.rb | 1 + app/mailers/event_mailer.rb | 18 ++++- app/mailers/notification_mailer.rb | 6 +- app/models/notification.rb | 2 + .../public_registration.rb | 22 ++++-- .../resubmission_changes.rb | 56 ++++++++++++++ .../event_registration_confirmation.html.erb | 11 ++- .../event_registration_confirmation.text.erb | 8 +- .../event_registration_duplicate_fyi.html.erb | 6 +- .../event_registration_duplicate_fyi.text.erb | 4 +- .../shared/_resubmission_changes.html.erb | 10 +++ .../shared/_resubmission_changes.text.erb | 6 ++ config/features.yml | 13 ++-- spec/mailers/event_mailer_spec.rb | 26 +++++++ spec/mailers/notification_mailer_spec.rb | 32 +++++--- .../public_registration_spec.rb | 47 +++++++++--- .../resubmission_changes_spec.rb | 74 +++++++++++++++++++ test/mailers/previews/event_mailer_preview.rb | 9 +++ .../previews/notification_mailer_preview.rb | 2 +- 20 files changed, 304 insertions(+), 52 deletions(-) create mode 100644 app/services/event_registration_services/resubmission_changes.rb create mode 100644 app/views/shared/_resubmission_changes.html.erb create mode 100644 app/views/shared/_resubmission_changes.text.erb create mode 100644 spec/services/event_registration_services/resubmission_changes_spec.rb diff --git a/AGENTS.md b/AGENTS.md index e194adbac3..3a909fa36b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,8 @@ action, or `authorize! :workshop, to: :summary?`). - `EventRegistrationServices::CalloutFormSubmission` — Records a registrant's answers to one of the forms a registration-ticket callout delivers inline (`call(registration:, callout:, form:, form_params:, clarity_params:)`, returns the service instance with `#submission`/`#profile_changes`). Find-or-creates one `FormSubmission` per `(registrant, form, event)` carrying the form's own role — a re-submit edits the answers in place via `FormSubmission#persist_answer`. That a callout collected it is recorded separately in `FormSubmission#metadata` (`collected_via: "callout"`, `collected_via_callout_id`) via `#record_callout_collection!` — the flag (`#collected_via_callout?`), not the role, tells a callout-collected submission apart. There is no survey-role flag — the extra side effects self-gate on the form's own fields, so they no-op on a form that doesn't ask those questions: a choice field with linked `FormFieldResource`s (`FormField#per_resource?`) fans out to one answer per resource (nil `form_field`, `FormField#per_resource_question` snapshotted in `question_name_when_answered` — subtitle-led, so two fan-out questions sharing a prompt can't collide on one answer row); fields carrying the `anonymous_contributions` / `display_name_preference` identifiers write through to the `Person` via `PersonServices::SyncSharingPreferences` (the same write-through the registration form runs), exposing `#profile_changes` so the controller Ahoy-tracks real edits; and submitting any form on the **`scholarship`** callout stamps a recipient's `post_survey_completed_at`. Drives `Events::CalloutsController#callout`/`#submit_callout` (the person-first `registration/:slug/forms/:callout_id` page and its `.../:form_id` submit) - `SurveyFormSeeder` — Creates the four standalone survey template forms from the form-builder presets (the Day 1 / Day 2 evaluations, the overall post-event survey, and the scholarship recipients survey), idempotent on form name and returning the names created. `#link_fanout_resources` then links each fan-out question to its training-topic `Resource`s by title (skipping any that aren't present). Shared by `db/seeds.rb` and the `data:seed_survey_forms` task so prod can create them without the full seed - `CloseProgramFormSeeder` — Creates the standalone "Close Program" agreement form (role `close_program`, sections `person_identifier` + `close_program`) from the form-builder presets, idempotent on form name, returning the name created (or nil). Built as an unpublished template for staff to slug + publish. Shared by `db/seeds.rb` and the `data:seed_close_program_form` task -- `EventRegistrationServices::PublicRegistration` — Public registration handling. An affirmative `communication_consent` answer captures a News (mailing-list) `TopicSubscription` via `NewsSubscriptionCapture`, sourced to the event +- `EventRegistrationServices::PublicRegistration` — Public registration handling. An affirmative `communication_consent` answer captures a News (mailing-list) `TopicSubscription` via `NewsSubscriptionCapture`, sourced to the event. A resubmission matched to an active registration sends the registrant and admins DUPLICATE emails (`event_registration_duplicate` / `event_registration_duplicate_fyi`) +- `EventRegistrationServices::ResubmissionChanges` — Snapshots a registration's resubmittable state (scholarship, CE, W-9, invoice, payment method, who's paying) before and after a resubmission and turns the difference into plain-language notes; the notes ride in the duplicate notifications' `custom_message` (one per line) and drive the "(scholarship now requested)" / "(registration changed)" subject suffix - `NewsSubscriptionCapture` — Successor to the retired person-level mailing-list consent flag: given a `person` + `source`, idempotently creates an active **News** `TopicSubscription` (skips when one is already active, no-op when the News topic isn't seeded). Called from `PublicRegistration` (source = event) and `PublicFormSubmission` (source = form) when the `communication_consent` question is answered - `OrganizationServices::CaptureFromSubmission` — The shared org-capture for **both** the event registration (`PublicRegistration`) and standalone (`PublicFormSubmission`) flows: resolves the submitted organization by name (`FormField::ORGANIZATION_NAME_FIELD_IDENTIFIER`, case-insensitive per the column collation; never creates one — an unmatched name is left for an admin), syncs its website/type (`SyncProfile`, latest-wins) and work address (`UpsertAddress`, which adds another work address rather than rewriting a different one). `AffiliationServices::CreateFromRegistration` then mints the person's job affiliation from the typed position, plus the standing Facilitator one when `facilitator_training:` — which only an event supplies (`@event.facilitator_training`), so a standalone form gets the job affiliation alone, the same as registering for a non-training event. **Agreement-role submissions never come through here** — they run `LinkSubmittedOrganization`, which owns scenario end-dating and the new-job start date. Returns the org + the autofill changes, which the event flow records on its registration↔org link - `PersonServices::CaptureFromSubmission` — The shared person-capture for **both** the event registration (`PublicRegistration`) and standalone (`PublicFormSubmission`) flows, run for a newly-created **and** an existing person, so the person smart fields on the Smart form settings page behave identically across both forms. Fills identity extras (pronouns, name `pronunciation`, `nickname` → first name / legal first name, primary email type, secondary email + type), the profile (`racial_ethnic_identity` + `SyncSharingPreferences`), the mailing address, the phone contact method, and sector/age tags (mirrored onto any organizations passed in). A non-blank answer overwrites what's on file (newest submission wins); a blank never clobbers. Sector and age tags both run through `SubmissionTagging` and follow one rule on the **person**: submitting both a primary and additional set **overwrites** the person's tags with the latest (`replace: true`), submitting only a primary **reassigns** the primary and leaves the rest (additive); the org mirror always stays additive (orgs aggregate their members' tags). Self-gates on each field identifier, so a form that doesn't ask a question no-ops for it diff --git a/app/jobs/notification_mailer_job.rb b/app/jobs/notification_mailer_job.rb index a06f81acf2..9e26c9ecb5 100644 --- a/app/jobs/notification_mailer_job.rb +++ b/app/jobs/notification_mailer_job.rb @@ -20,6 +20,7 @@ def perform(notification_id, persist_delivered_email: true) "reset_password_fyi" => ->(n) { NotificationMailer.reset_password_fyi(n) }, "event_registration_confirmation" => ->(n) { EventMailer.event_registration_confirmation(n.noticeable) }, "event_registration_confirmation_fyi" => ->(n) { NotificationMailer.event_registration_confirmation_fyi(n) }, + "event_registration_duplicate" => ->(n) { EventMailer.event_registration_confirmation(n.noticeable, duplicate: true, changes_message: n.custom_message) }, "event_registration_duplicate_fyi" => ->(n) { NotificationMailer.event_registration_duplicate_fyi(n) }, "event_registration_cancelled" => ->(n) { EventMailer.event_registration_cancelled(n.noticeable) }, "event_registration_cancelled_fyi" => ->(n) { NotificationMailer.event_registration_cancelled_fyi(n) }, diff --git a/app/mailers/event_mailer.rb b/app/mailers/event_mailer.rb index 33a2c91027..f55006dfd3 100644 --- a/app/mailers/event_mailer.rb +++ b/app/mailers/event_mailer.rb @@ -1,8 +1,13 @@ class EventMailer < ApplicationMailer - def event_registration_confirmation(event_registration) + # `duplicate:` marks a resubmission merged into an existing registration; its + # `changes_message` lists what that resubmission changed (see ResubmissionChanges). + def event_registration_confirmation(event_registration, duplicate: false, changes_message: nil) @event_registration = event_registration @event = event_registration.event.decorate @person = event_registration.registrant + @duplicate = duplicate + changes = EventRegistrationServices::ResubmissionChanges.from_message(changes_message) + @change_notes = changes.notes @notification_type = "Event registration confirmation" @@ -14,7 +19,7 @@ def event_registration_confirmation(event_registration) to: @person.preferred_email, from: self.class.sender(Organization.no_reply_email), reply_to: Organization.programs_email, - subject: "AWBW Portal: #{@event_registration.registration_subject_noun.capitalize} received for #{@event.title}" + subject: confirmation_subject(changes) ) end @@ -125,4 +130,13 @@ def event_registration_cancelled(event_registration) subject: "AWBW Portal: #{@event_registration.registration_subject_noun.capitalize} cancelled for #{@event.title}" ) end + + private + + def confirmation_subject(changes) + noun = @event_registration.registration_subject_noun + return "AWBW Portal: #{noun.capitalize} received for #{@event.title}" unless @duplicate + + "AWBW Portal: DUPLICATE #{noun} received for #{@event.title}#{changes.subject_suffix}" + end end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 8bbaf240a0..427702ae72 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -23,12 +23,12 @@ def event_registration_duplicate_fyi(notification) @event = @event_registration.event.decorate @person = @event_registration.registrant @resubmitted_at = notification.created_at - @scholarship_note = notification.custom_message.presence + changes = EventRegistrationServices::ResubmissionChanges.from_message(notification.custom_message) + @change_notes = changes.notes @notification_type = "Duplicate event registration" - scholarship_suffix = " (scholarship now requested)" if @scholarship_note mail( - subject: "#{FYI_PREFIX} DUPLICATE #{@event_registration.registration_subject_noun} by #{@person.full_name} to #{@event.title}#{scholarship_suffix}" + subject: "#{FYI_PREFIX} DUPLICATE #{@event_registration.registration_subject_noun} by #{@person.full_name} to #{@event.title}#{changes.subject_suffix}" ) end diff --git a/app/models/notification.rb b/app/models/notification.rb index 61b28a8e2c..b65b56a5ce 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -27,6 +27,7 @@ class Notification < ApplicationRecord event_registration_confirmation event_registration_confirmation_fyi + event_registration_duplicate event_registration_duplicate_fyi event_registration_cancelled event_registration_cancelled_fyi @@ -120,6 +121,7 @@ class Notification < ApplicationRecord [ "Event scholarship registration cancelled", "Event scholarship registration cancelled" ], [ "Event registration received", "Event registration received" ], [ "Event scholarship registration received", "Event scholarship registration received" ], + [ "Duplicate event registration received", "AWBW Portal: DUPLICATE event" ], [ "Bulk payment: confirmation", "Bulk payment received" ], [ "Idea: confirmation (all)", "has been received" ], [ "Story: promotion confirmation", "is now a story" ], diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index dcac69e4b0..3e37fd15cc 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -36,8 +36,6 @@ class PublicRegistration # and specs agree. SOMEONE_ELSE_WILL_PAY_IDENTIFIER = "someone_else_will_pay".freeze - SCHOLARSHIP_ADDED_NOTE = "This submission requested a scholarship; the original registration did not.".freeze - def self.call(event:, registration_form:, form_params:, scholarship_requested: false, person: nil, scholarship_form: nil, scholarship_params: {}, continuing_education_form: nil, continuing_education_params: {}) @@ -94,7 +92,7 @@ def call ) if existing - scholarship_added = @scholarship_requested && !existing.scholarship_requested? + before_resubmission = ResubmissionChanges.snapshot(existing) existing.update!(scholarship_requested: true) if @scholarship_requested create_ce_registration(existing, person) existing.update!(w9_requested: true) if w9_requested? @@ -107,7 +105,8 @@ def call existing.update!(status: "registered") send_notifications(existing) else - send_duplicate_notification(existing, scholarship_added:) + changes = ResubmissionChanges.between(before_resubmission, ResubmissionChanges.snapshot(existing)) + send_duplicate_notifications(existing, changes) end organization_link = connect_organization(existing, organization) submission = create_form_submission(person) @@ -407,14 +406,23 @@ def send_notifications(event_registration) ) end - # custom_message carries what the resubmission changed, for the mailer to call out. - def send_duplicate_notification(event_registration, scholarship_added:) + def send_duplicate_notifications(event_registration, changes) + NotificationServices::CreateNotification.call( + noticeable: event_registration, + person: event_registration.registrant, + kind: :event_registration_duplicate, + recipient_role: :person, + recipient_email: event_registration.registrant.preferred_email, + custom_message: changes.to_message, + notification_type: 1 + ) + NotificationServices::CreateNotification.call( noticeable: event_registration, kind: :event_registration_duplicate_fyi, recipient_role: :admin, recipient_email: Organization.programs_email, - custom_message: (SCHOLARSHIP_ADDED_NOTE if scholarship_added), + custom_message: changes.to_message, notification_type: 1 ) end diff --git a/app/services/event_registration_services/resubmission_changes.rb b/app/services/event_registration_services/resubmission_changes.rb new file mode 100644 index 0000000000..d7db107c13 --- /dev/null +++ b/app/services/event_registration_services/resubmission_changes.rb @@ -0,0 +1,56 @@ +module EventRegistrationServices + # Plain-language notes on what a resubmission changed on an existing registration. + # They travel in the duplicate notifications' custom_message, one note per line. + class ResubmissionChanges + SCHOLARSHIP_REQUESTED = "Scholarship now requested".freeze + + NEWLY_REQUESTED_NOTES = { + scholarship_requested: SCHOLARSHIP_REQUESTED, + ce_requested: "Continuing education credit now requested", + w9_requested: "W-9 now requested", + invoice_requested: "Invoice now requested" + }.freeze + + attr_reader :notes + + def self.snapshot(registration) + { + scholarship_requested: registration.scholarship_requested?, + ce_requested: registration.continuing_education_registrations.exists?, + w9_requested: registration.w9_requested?, + invoice_requested: registration.invoice_requested?, + expected_payment_method: registration.expected_payment_method.presence, + someone_else_will_pay: registration.someone_else_will_pay? + } + end + + def self.between(before, after) + notes = NEWLY_REQUESTED_NOTES.filter_map { |key, note| note if after[key] && !before[key] } + if before[:expected_payment_method] != after[:expected_payment_method] + notes << "Payment method changed from #{before[:expected_payment_method] || "none"} to #{after[:expected_payment_method] || "none"}" + end + if before[:someone_else_will_pay] != after[:someone_else_will_pay] + notes << (after[:someone_else_will_pay] ? "Someone else will now pay" : "Someone else is no longer paying") + end + new(notes) + end + + def self.from_message(message) + new(message.to_s.split("\n").map(&:strip).compact_blank) + end + + def initialize(notes) + @notes = notes + end + + def to_message + @notes.join("\n").presence + end + + def subject_suffix + return " (scholarship now requested)" if @notes.include?(SCHOLARSHIP_REQUESTED) + + " (registration changed)" if @notes.any? + end + end +end diff --git a/app/views/event_mailer/event_registration_confirmation.html.erb b/app/views/event_mailer/event_registration_confirmation.html.erb index 26ca4dec49..4a8faecbe7 100644 --- a/app/views/event_mailer/event_registration_confirmation.html.erb +++ b/app/views/event_mailer/event_registration_confirmation.html.erb @@ -1,10 +1,19 @@ -

Event registration received

+

<%= @duplicate ? "DUPLICATE event registration received" : "Event registration received" %>

Hello <%= @person.first_name %>,

+ <% if @duplicate %> +

+ We received another registration from you for this event. Your new answers + were added to your existing registration, so you're still registered just once. +

+ + <%= render "shared/resubmission_changes", notes: @change_notes %> + <% end %> +

This message confirms your registration for the following <%= @organization_name %> event:

diff --git a/app/views/event_mailer/event_registration_confirmation.text.erb b/app/views/event_mailer/event_registration_confirmation.text.erb index 64c52b87ea..f0f8a8c9e5 100644 --- a/app/views/event_mailer/event_registration_confirmation.text.erb +++ b/app/views/event_mailer/event_registration_confirmation.text.erb @@ -1,6 +1,12 @@ -Event registration received +<%= @duplicate ? "DUPLICATE event registration received" : "Event registration received" %> Hello <%= @person.first_name %>, +<% if @duplicate %> + +We received another registration from you for this event. Your new answers were added to your existing registration, so you're still registered just once. + +<%= render "shared/resubmission_changes", notes: @change_notes %> +<% end %> This message confirms your registration for the following event: diff --git a/app/views/notification_mailer/event_registration_duplicate_fyi.html.erb b/app/views/notification_mailer/event_registration_duplicate_fyi.html.erb index 3ed5c82f21..5aca0bbec9 100644 --- a/app/views/notification_mailer/event_registration_duplicate_fyi.html.erb +++ b/app/views/notification_mailer/event_registration_duplicate_fyi.html.erb @@ -15,10 +15,6 @@ The answers were added to the existing registration.

-<% if @scholarship_note %> -

- <%= @scholarship_note %> -

-<% end %> +<%= render "shared/resubmission_changes", notes: @change_notes %> <%= render "event_registration_fyi_details" %> diff --git a/app/views/notification_mailer/event_registration_duplicate_fyi.text.erb b/app/views/notification_mailer/event_registration_duplicate_fyi.text.erb index 02fc41b2fb..de582811ef 100644 --- a/app/views/notification_mailer/event_registration_duplicate_fyi.text.erb +++ b/app/views/notification_mailer/event_registration_duplicate_fyi.text.erb @@ -10,10 +10,8 @@ Originally registered on <%= @event_registration.created_at.in_time_zone("Pacific Time (US & Canada)").strftime("%B %-d, %Y at %-l:%M %p %Z") %> The answers were added to the existing registration. -<% if @scholarship_note %> -*** <%= @scholarship_note %> *** -<% end %> +<%= render "shared/resubmission_changes", notes: @change_notes %> ------------------------------------------------------------ diff --git a/app/views/shared/_resubmission_changes.html.erb b/app/views/shared/_resubmission_changes.html.erb new file mode 100644 index 0000000000..ede7cce2cf --- /dev/null +++ b/app/views/shared/_resubmission_changes.html.erb @@ -0,0 +1,10 @@ +<% if notes.any? %> +
+

This submission changed the registration:

+
    + <% notes.each do |note| %> +
  • <%= note %>
  • + <% end %> +
+
+<% end %> diff --git a/app/views/shared/_resubmission_changes.text.erb b/app/views/shared/_resubmission_changes.text.erb new file mode 100644 index 0000000000..29e8938774 --- /dev/null +++ b/app/views/shared/_resubmission_changes.text.erb @@ -0,0 +1,6 @@ +<% if notes.any? %> +This submission changed the registration: +<% notes.each do |note| %> +* <%= note %> +<% end %> +<% end %> diff --git a/config/features.yml b/config/features.yml index 4cc148a362..25a26dafd9 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4511,15 +4511,18 @@ - "Logged in, your name and reply email fill in for you under \"From\", so you only write a subject and message." - "Links that pre-fill a subject (like an affiliation change request) drop you straight into the message box." -- name: "Admins are emailed when someone resubmits a registration" +- name: "Duplicate registration emails" area: registration - display_status: admin_facing + display_status: user_facing released_on: 2026-10-14 pr_number: 2684 summary: >- When someone who is already registered for an event submits the registration - form again, programs gets a "[FYI] DUPLICATE" email. If the new submission - asks for a scholarship the original didn't, the email says so. + form again, they get a "DUPLICATE" confirmation email and programs gets a + "[FYI] DUPLICATE" email. Both list anything the new submission changed, such + as a new scholarship request. pro_tips: - "The new answers are added to the existing registration, so the person still has just one registration." - - "Filter the Communications page by \"Admin FYI: duplicate event registration\" to see these emails." + - "Changes that get called out: a new scholarship, CE credit, W-9, or invoice request, a different payment method, and a change in whether someone else is paying." + - "If the resubmission adds a scholarship request, the subject ends with \"(scholarship now requested)\"." + - "Filter the Communications page by \"Admin FYI: duplicate event registration\" or \"Duplicate event registration received\" to find these emails." diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb index 6812c6d0ed..df47fc89da 100644 --- a/spec/mailers/event_mailer_spec.rb +++ b/spec/mailers/event_mailer_spec.rb @@ -17,6 +17,32 @@ expect(mail.subject).to include(event_registration.event.title) end + context "for a duplicate submission" do + let(:event_registration) { create(:event_registration, scholarship_requested: true) } + let(:mail) do + described_class.event_registration_confirmation(event_registration, duplicate: true, + changes_message: "Scholarship now requested") + end + + it "flags the subject and body as a duplicate and lists what changed" do + expect(mail.subject).to eq("AWBW Portal: DUPLICATE event scholarship registration received for #{event_registration.event.title} (scholarship now requested)") + [ mail.html_part, mail.text_part ].each do |part| + expect(part.body.decoded).to include("DUPLICATE event registration received") + expect(part.body.decoded).to include("still registered just once") + expect(part.body.decoded).to include("Scholarship now requested") + end + end + + it "still sends to the registrant" do + expect(mail.to).to eq([ event_registration.registrant.preferred_email ]) + end + end + + it "doesn't mention a duplicate on a first registration" do + expect(mail.subject).not_to include("DUPLICATE") + expect(mail.html_part.body.decoded).not_to include("DUPLICATE") + end + context "when a scholarship was not requested" do let(:event_registration) { create(:event_registration, scholarship_requested: false) } diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index cfb5dae6ad..60ece87050 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -88,8 +88,10 @@ describe "#event_registration_duplicate_fyi" do let(:event_registration) { create(:event_registration, scholarship_requested: false) } + let(:custom_message) { nil } let(:notification) do - create(:notification, kind: "event_registration_duplicate_fyi", noticeable: event_registration) + create(:notification, kind: "event_registration_duplicate_fyi", noticeable: event_registration, + custom_message: custom_message) end let(:mail) { described_class.event_registration_duplicate_fyi(notification) } @@ -99,23 +101,31 @@ expect(mail.text_part.body.decoded).to include("DUPLICATE event registration") end - it "leaves out the scholarship note when the resubmission didn't add one" do - expect(mail.subject).not_to include("scholarship") - expect(mail.html_part.body.decoded).not_to include(EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) + it "leaves out the changes box when the resubmission changed nothing" do + expect(mail.subject).not_to include("(") + expect(mail.html_part.body.decoded).not_to include("This submission changed the registration") end context "when the resubmission newly requested a scholarship" do let(:event_registration) { create(:event_registration, scholarship_requested: true) } - let(:notification) do - create(:notification, kind: "event_registration_duplicate_fyi", noticeable: event_registration, - custom_message: EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) - end + let(:custom_message) { "Scholarship now requested\nW-9 now requested" } - it "calls out the scholarship request in the subject and body" do + it "calls out the scholarship in the subject and lists every change in the body" do expect(mail.subject).to include("DUPLICATE event scholarship registration by") expect(mail.subject).to end_with("(scholarship now requested)") - expect(mail.html_part.body.decoded).to include(EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) - expect(mail.text_part.body.decoded).to include(EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE) + [ mail.html_part, mail.text_part ].each do |part| + expect(part.body.decoded).to include("This submission changed the registration") + expect(part.body.decoded).to include("Scholarship now requested") + expect(part.body.decoded).to include("W-9 now requested") + end + end + end + + context "when the resubmission changed something other than the scholarship" do + let(:custom_message) { "Invoice now requested" } + + it "flags the subject generically" do + expect(mail.subject).to end_with("(registration changed)") end end end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 283a68e5fa..2507df1626 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -1087,33 +1087,56 @@ def resubmit(scholarship_requested: false) ) end - it "sends admins a duplicate FYI and no registrant confirmation" do - expect(NotificationServices::CreateNotification).to receive(:call).once.with( + def expect_duplicate_notifications(custom_message:) + expect(NotificationServices::CreateNotification).to receive(:call).with( + hash_including(kind: :event_registration_duplicate, recipient_role: :person, + recipient_email: "jane@example.com", noticeable: active_registration, + custom_message: custom_message) + ) + expect(NotificationServices::CreateNotification).to receive(:call).with( hash_including(kind: :event_registration_duplicate_fyi, recipient_role: :admin, - noticeable: active_registration, custom_message: nil) + noticeable: active_registration, custom_message: custom_message) ) + end + + it "sends the registrant and admins duplicate emails, with no change notes when nothing changed" do + expect_duplicate_notifications(custom_message: nil) resubmit end - it "notes a newly requested scholarship on the duplicate FYI" do - expect(NotificationServices::CreateNotification).to receive(:call).once.with( - hash_including(kind: :event_registration_duplicate_fyi, - custom_message: described_class::SCHOLARSHIP_ADDED_NOTE) - ) + it "notes a newly requested scholarship" do + expect_duplicate_notifications(custom_message: "Scholarship now requested") resubmit(scholarship_requested: true) end - it "leaves the note off when the registration already requested a scholarship" do + it "leaves the scholarship off when the registration already requested one" do active_registration.update!(scholarship_requested: true) - expect(NotificationServices::CreateNotification).to receive(:call).once.with( - hash_including(kind: :event_registration_duplicate_fyi, custom_message: nil) - ) + expect_duplicate_notifications(custom_message: nil) resubmit(scholarship_requested: true) end + + it "notes every change the resubmission made" do + active_registration.update!(expected_payment_method: "Check") + additional_forms = form.form_fields.create!( + name: "Additional forms", answer_type: :multi_select_checkbox, status: :active, + position: form.form_fields.maximum(:position) + 1, required: false, visibility: :always_ask, + field_identifier: described_class::ADDITIONAL_FORMS_IDENTIFIER, section: "additional_forms" + ) + params = base_form_params(first_name: "Jane", last_name: "Doe", email: "jane@example.com").merge( + field_id("payment_method") => "Credit card", + additional_forms.id.to_s => [ "W-9" ] + ) + + expect_duplicate_notifications( + custom_message: "Scholarship now requested\nW-9 now requested\nPayment method changed from Check to Credit card" + ) + + described_class.call(event: event, registration_form: form, scholarship_requested: true, form_params: params) + end end describe "saving an \"Other\" answer with custom text" do diff --git a/spec/services/event_registration_services/resubmission_changes_spec.rb b/spec/services/event_registration_services/resubmission_changes_spec.rb new file mode 100644 index 0000000000..8500ab73f7 --- /dev/null +++ b/spec/services/event_registration_services/resubmission_changes_spec.rb @@ -0,0 +1,74 @@ +require "rails_helper" + +RSpec.describe EventRegistrationServices::ResubmissionChanges do + let(:unchanged) do + { scholarship_requested: false, ce_requested: false, w9_requested: false, invoice_requested: false, + expected_payment_method: "Check", someone_else_will_pay: false } + end + + describe ".snapshot" do + it "captures the registration's resubmittable state" do + registration = create(:event_registration, scholarship_requested: true, w9_requested: true, + expected_payment_method: "Check") + + expect(described_class.snapshot(registration)).to eq( + scholarship_requested: true, ce_requested: false, w9_requested: true, invoice_requested: false, + expected_payment_method: "Check", someone_else_will_pay: false + ) + end + end + + describe ".between" do + it "has no notes when nothing changed" do + expect(described_class.between(unchanged, unchanged).notes).to be_empty + end + + it "notes every newly requested item" do + after = unchanged.merge(scholarship_requested: true, ce_requested: true, w9_requested: true, invoice_requested: true) + + expect(described_class.between(unchanged, after).notes).to eq([ + "Scholarship now requested", + "Continuing education credit now requested", + "W-9 now requested", + "Invoice now requested" + ]) + end + + it "notes a payment method change, including one set for the first time" do + expect(described_class.between(unchanged, unchanged.merge(expected_payment_method: "Credit card")).notes) + .to eq([ "Payment method changed from Check to Credit card" ]) + expect(described_class.between(unchanged.merge(expected_payment_method: nil), unchanged).notes) + .to eq([ "Payment method changed from none to Check" ]) + end + + it "notes who's paying in either direction" do + paying = unchanged.merge(someone_else_will_pay: true) + + expect(described_class.between(unchanged, paying).notes).to eq([ "Someone else will now pay" ]) + expect(described_class.between(paying, unchanged).notes).to eq([ "Someone else is no longer paying" ]) + end + end + + describe "#subject_suffix" do + it "calls out a scholarship request first" do + changes = described_class.new([ "W-9 now requested", described_class::SCHOLARSHIP_REQUESTED ]) + + expect(changes.subject_suffix).to eq(" (scholarship now requested)") + end + + it "flags any other change generically" do + expect(described_class.new([ "W-9 now requested" ]).subject_suffix).to eq(" (registration changed)") + end + + it "is nil with no changes" do + expect(described_class.new([]).subject_suffix).to be_nil + end + end + + it "round-trips notes through the notification message" do + changes = described_class.new([ "W-9 now requested", "Invoice now requested" ]) + + expect(described_class.from_message(changes.to_message).notes).to eq(changes.notes) + expect(described_class.new([]).to_message).to be_nil + end +end diff --git a/test/mailers/previews/event_mailer_preview.rb b/test/mailers/previews/event_mailer_preview.rb index 065b3f0725..95e7634b6b 100644 --- a/test/mailers/previews/event_mailer_preview.rb +++ b/test/mailers/previews/event_mailer_preview.rb @@ -4,6 +4,15 @@ def event_registration_confirmation EventMailer.event_registration_confirmation(event_registration) end + def event_registration_duplicate + event_registration = sample_event_registration + EventMailer.event_registration_confirmation( + event_registration, + duplicate: true, + changes_message: "Scholarship now requested\nPayment method changed from Check to Credit card" + ) + end + def event_registration_reminder event_registration = sample_event_registration EventMailer.event_registration_reminder( diff --git a/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index 07e60c5883..c96ef98854 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -34,7 +34,7 @@ def event_registration_duplicate_fyi kind: "event_registration_duplicate_fyi", recipient_role: "admin", recipient_email: programs_email, - custom_message: EventRegistrationServices::PublicRegistration::SCHOLARSHIP_ADDED_NOTE + custom_message: "Scholarship now requested\nW-9 now requested" ) NotificationMailer.event_registration_duplicate_fyi(notification) From e59fa70b4bced2b6d101b66d84fa01cc720dee90 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 11:24:05 -0400 Subject: [PATCH 4/4] List changed answers and newly linked orgs on duplicate registration emails Staff need to see everything a resubmission changed, not just the registration flags: a new name, address, sectors, or organization otherwise goes unnoticed. Answers are diffed against the previous submission rather than read from the record-write log, since identity fields aren't overwritten for existing people. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- .../public_registration.rb | 15 ++--- .../resubmission_changes.rb | 37 ++++++++++++- config/features.yml | 2 +- .../public_registration_spec.rb | 10 ++++ .../resubmission_changes_spec.rb | 55 ++++++++++++++++++- test/mailers/previews/event_mailer_preview.rb | 2 +- 7 files changed, 109 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3a909fa36b..25548a9fdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,7 +274,7 @@ action, or `authorize! :workshop, to: :summary?`). - `SurveyFormSeeder` — Creates the four standalone survey template forms from the form-builder presets (the Day 1 / Day 2 evaluations, the overall post-event survey, and the scholarship recipients survey), idempotent on form name and returning the names created. `#link_fanout_resources` then links each fan-out question to its training-topic `Resource`s by title (skipping any that aren't present). Shared by `db/seeds.rb` and the `data:seed_survey_forms` task so prod can create them without the full seed - `CloseProgramFormSeeder` — Creates the standalone "Close Program" agreement form (role `close_program`, sections `person_identifier` + `close_program`) from the form-builder presets, idempotent on form name, returning the name created (or nil). Built as an unpublished template for staff to slug + publish. Shared by `db/seeds.rb` and the `data:seed_close_program_form` task - `EventRegistrationServices::PublicRegistration` — Public registration handling. An affirmative `communication_consent` answer captures a News (mailing-list) `TopicSubscription` via `NewsSubscriptionCapture`, sourced to the event. A resubmission matched to an active registration sends the registrant and admins DUPLICATE emails (`event_registration_duplicate` / `event_registration_duplicate_fyi`) -- `EventRegistrationServices::ResubmissionChanges` — Snapshots a registration's resubmittable state (scholarship, CE, W-9, invoice, payment method, who's paying) before and after a resubmission and turns the difference into plain-language notes; the notes ride in the duplicate notifications' `custom_message` (one per line) and drive the "(scholarship now requested)" / "(registration changed)" subject suffix +- `EventRegistrationServices::ResubmissionChanges` — Snapshots a registration's resubmittable state (scholarship, CE, W-9, invoice, payment method, who's paying, linked organizations) before and after a resubmission and turns the difference into plain-language notes, plus one note per registration-form answer that differs from the person's previous registration submission for the event; the notes ride in the duplicate notifications' `custom_message` (one per line) and drive the "(scholarship now requested)" / "(registration changed)" subject suffix - `NewsSubscriptionCapture` — Successor to the retired person-level mailing-list consent flag: given a `person` + `source`, idempotently creates an active **News** `TopicSubscription` (skips when one is already active, no-op when the News topic isn't seeded). Called from `PublicRegistration` (source = event) and `PublicFormSubmission` (source = form) when the `communication_consent` question is answered - `OrganizationServices::CaptureFromSubmission` — The shared org-capture for **both** the event registration (`PublicRegistration`) and standalone (`PublicFormSubmission`) flows: resolves the submitted organization by name (`FormField::ORGANIZATION_NAME_FIELD_IDENTIFIER`, case-insensitive per the column collation; never creates one — an unmatched name is left for an admin), syncs its website/type (`SyncProfile`, latest-wins) and work address (`UpsertAddress`, which adds another work address rather than rewriting a different one). `AffiliationServices::CreateFromRegistration` then mints the person's job affiliation from the typed position, plus the standing Facilitator one when `facilitator_training:` — which only an event supplies (`@event.facilitator_training`), so a standalone form gets the job affiliation alone, the same as registering for a non-training event. **Agreement-role submissions never come through here** — they run `LinkSubmittedOrganization`, which owns scenario end-dating and the new-job start date. Returns the org + the autofill changes, which the event flow records on its registration↔org link - `PersonServices::CaptureFromSubmission` — The shared person-capture for **both** the event registration (`PublicRegistration`) and standalone (`PublicFormSubmission`) flows, run for a newly-created **and** an existing person, so the person smart fields on the Smart form settings page behave identically across both forms. Fills identity extras (pronouns, name `pronunciation`, `nickname` → first name / legal first name, primary email type, secondary email + type), the profile (`racial_ethnic_identity` + `SyncSharingPreferences`), the mailing address, the phone contact method, and sector/age tags (mirrored onto any organizations passed in). A non-blank answer overwrites what's on file (newest submission wins); a blank never clobbers. Sector and age tags both run through `SubmissionTagging` and follow one rule on the **person**: submitting both a primary and additional set **overwrites** the person's tags with the latest (`replace: true`), submitting only a primary **reassigns** the primary and leaves the rest (additive); the org mirror always stays additive (orgs aggregate their members' tags). Self-gates on each field identifier, so a form that doesn't ask a question no-ops for it diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 3e37fd15cc..1343cbef77 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -101,18 +101,19 @@ def call existing.update!(expected_payment_method: payment_method) if payment_method.present? someone_else = someone_else_will_pay_answer existing.update!(someone_else_will_pay: someone_else) unless someone_else.nil? - if existing.status == "cancelled" - existing.update!(status: "registered") - send_notifications(existing) - else - changes = ResubmissionChanges.between(before_resubmission, ResubmissionChanges.snapshot(existing)) - send_duplicate_notifications(existing, changes) - end + reactivated = existing.status == "cancelled" + existing.update!(status: "registered") if reactivated organization_link = connect_organization(existing, organization) submission = create_form_submission(person) organization_link&.record_form_submission(submission) save_scholarship_submission(person) save_continuing_education_submission(person) + if reactivated + send_notifications(existing) + else + changes = ResubmissionChanges.between(before_resubmission, ResubmissionChanges.snapshot(existing), submission:) + send_duplicate_notifications(existing, changes) + end return Result.new(success?: true, event_registration: existing, form_submission: submission, errors: []) end diff --git a/app/services/event_registration_services/resubmission_changes.rb b/app/services/event_registration_services/resubmission_changes.rb index d7db107c13..28405e5dd8 100644 --- a/app/services/event_registration_services/resubmission_changes.rb +++ b/app/services/event_registration_services/resubmission_changes.rb @@ -11,6 +11,12 @@ class ResubmissionChanges invoice_requested: "Invoice now requested" }.freeze + # Answers already reported through the registration settings above. + SETTINGS_FIELD_IDENTIFIERS = [ + "payment_method", "confirm_email", + PublicRegistration::ADDITIONAL_FORMS_IDENTIFIER, PublicRegistration::SOMEONE_ELSE_WILL_PAY_IDENTIFIER + ].freeze + attr_reader :notes def self.snapshot(registration) @@ -20,11 +26,12 @@ def self.snapshot(registration) w9_requested: registration.w9_requested?, invoice_requested: registration.invoice_requested?, expected_payment_method: registration.expected_payment_method.presence, - someone_else_will_pay: registration.someone_else_will_pay? + someone_else_will_pay: registration.someone_else_will_pay?, + organization_ids: registration.event_registration_organizations.pluck(:organization_id) } end - def self.between(before, after) + def self.between(before, after, submission: nil) notes = NEWLY_REQUESTED_NOTES.filter_map { |key, note| note if after[key] && !before[key] } if before[:expected_payment_method] != after[:expected_payment_method] notes << "Payment method changed from #{before[:expected_payment_method] || "none"} to #{after[:expected_payment_method] || "none"}" @@ -32,9 +39,35 @@ def self.between(before, after) if before[:someone_else_will_pay] != after[:someone_else_will_pay] notes << (after[:someone_else_will_pay] ? "Someone else will now pay" : "Someone else is no longer paying") end + linked = Organization.where(id: after[:organization_ids] - before[:organization_ids]).order(:name).pluck(:name) + notes.concat(linked.map { |name| "Organization linked: #{name}" }) + notes.concat(answer_notes(submission)) if submission new(notes) end + # Each answer that differs from the person's previous submission of this form + # for the event, squished to one line so it fits the newline-separated message. + # A missing answer is skipped: hidden "answers on file" fields aren't posted. + def self.answer_notes(submission) + previous = FormSubmission.where(person_id: submission.person_id, event_id: submission.event_id, role: submission.role) + .where.not(id: submission.id).order(:created_at, :id).last + return [] unless previous + + previous_answers = previous.form_answers.index_by(&:form_field_id) + submission.form_answers.includes(:form_field).order(:id).filter_map do |answer| + next if answer.form_field.nil? || answer.form_field.field_identifier.in?(SETTINGS_FIELD_IDENTIFIERS) + + now = answer.submitted_answer.to_s.squish + was = previous_answers[answer.form_field_id]&.submitted_answer.to_s.squish + next if now.blank? || now == was + + question = (answer.question_name_when_answered.presence || answer.form_field.name).squish.truncate(60) + next "#{question}: added #{now.truncate(80)}" if was.blank? + + "#{question}: changed from #{was.truncate(80)} to #{now.truncate(80)}" + end + end + def self.from_message(message) new(message.to_s.split("\n").map(&:strip).compact_blank) end diff --git a/config/features.yml b/config/features.yml index 25a26dafd9..af14287236 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4523,6 +4523,6 @@ as a new scholarship request. pro_tips: - "The new answers are added to the existing registration, so the person still has just one registration." - - "Changes that get called out: a new scholarship, CE credit, W-9, or invoice request, a different payment method, and a change in whether someone else is paying." + - "Changes that get called out: a new scholarship, CE credit, W-9, or invoice request, a different payment method, a change in whether someone else is paying, a newly linked organization, and any answer (name, address, sectors, and so on) that differs from their previous submission." - "If the resubmission adds a scholarship request, the subject ends with \"(scholarship now requested)\"." - "Filter the Communications page by \"Admin FYI: duplicate event registration\" or \"Duplicate event registration received\" to find these emails." diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 2507df1626..28d2c55b68 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -1137,6 +1137,16 @@ def expect_duplicate_notifications(custom_message:) described_class.call(event: event, registration_form: form, scholarship_requested: true, form_params: params) end + + it "notes answers that differ from the person's previous registration submission" do + jane = base_form_params(first_name: "Jane", last_name: "Doe", email: "jane@example.com") + described_class.call(event: event, registration_form: form, form_params: jane.merge(field_id("referral_source") => "A friend")) + question = form.form_fields.find_by!(field_identifier: "referral_source").name + + expect_duplicate_notifications(custom_message: "#{question}: changed from A friend to Instagram") + + described_class.call(event: event, registration_form: form, form_params: jane.merge(field_id("referral_source") => "Instagram")) + end end describe "saving an \"Other\" answer with custom text" do diff --git a/spec/services/event_registration_services/resubmission_changes_spec.rb b/spec/services/event_registration_services/resubmission_changes_spec.rb index 8500ab73f7..39a8daf04f 100644 --- a/spec/services/event_registration_services/resubmission_changes_spec.rb +++ b/spec/services/event_registration_services/resubmission_changes_spec.rb @@ -3,17 +3,19 @@ RSpec.describe EventRegistrationServices::ResubmissionChanges do let(:unchanged) do { scholarship_requested: false, ce_requested: false, w9_requested: false, invoice_requested: false, - expected_payment_method: "Check", someone_else_will_pay: false } + expected_payment_method: "Check", someone_else_will_pay: false, organization_ids: [] } end describe ".snapshot" do it "captures the registration's resubmittable state" do registration = create(:event_registration, scholarship_requested: true, w9_requested: true, expected_payment_method: "Check") + organization = create(:organization) + registration.event_registration_organizations.create!(organization: organization) expect(described_class.snapshot(registration)).to eq( scholarship_requested: true, ce_requested: false, w9_requested: true, invoice_requested: false, - expected_payment_method: "Check", someone_else_will_pay: false + expected_payment_method: "Check", someone_else_will_pay: false, organization_ids: [ organization.id ] ) end end @@ -47,6 +49,55 @@ expect(described_class.between(unchanged, paying).notes).to eq([ "Someone else will now pay" ]) expect(described_class.between(paying, unchanged).notes).to eq([ "Someone else is no longer paying" ]) end + + it "notes a newly linked organization" do + kept = create(:organization, name: "Kept Org") + added = create(:organization, name: "Helping Hands") + before = unchanged.merge(organization_ids: [ kept.id ]) + after = unchanged.merge(organization_ids: [ kept.id, added.id ]) + + expect(described_class.between(before, after).notes).to eq([ "Organization linked: Helping Hands" ]) + end + + context "with a submission" do + let(:person) { create(:person) } + let(:event) { create(:event) } + let(:form) { create(:form) } + let(:city) { create(:form_field, form: form, name: "City", field_identifier: "city") } + let(:pronouns) { create(:form_field, form: form, name: "Pronouns", field_identifier: "pronouns") } + let(:bio) { create(:form_field, form: form, name: "Bio", field_identifier: "bio") } + let(:payment) { create(:form_field, form: form, name: "How will you pay?", field_identifier: "payment_method") } + + def submit(answers) + submission = FormSubmission.create!(person: person, form: form, event: event, role: "registration") + answers.each { |field, value| submission.persist_answer(field, value) } + submission + end + + it "notes each answer that differs from the previous submission" do + submit(city => "Oakland", bio => "Teacher", payment => "Check") + latest = submit(city => "Berkeley", pronouns => "she/her", bio => "Teacher", payment => "Credit card") + + expect(described_class.between(unchanged, unchanged, submission: latest).notes).to eq([ + "City: changed from Oakland to Berkeley", + "Pronouns: added she/her" + ]) + end + + it "skips answers left out of the resubmission and squishes multi-line ones" do + submit(city => "Oakland", bio => "Teacher") + latest = submit(bio => "Teacher\nand artist") + + expect(described_class.between(unchanged, unchanged, submission: latest).notes) + .to eq([ "Bio: changed from Teacher to Teacher and artist" ]) + end + + it "has no answer notes for a first submission" do + latest = submit(city => "Oakland") + + expect(described_class.between(unchanged, unchanged, submission: latest).notes).to be_empty + end + end end describe "#subject_suffix" do diff --git a/test/mailers/previews/event_mailer_preview.rb b/test/mailers/previews/event_mailer_preview.rb index 95e7634b6b..fcfc0477f5 100644 --- a/test/mailers/previews/event_mailer_preview.rb +++ b/test/mailers/previews/event_mailer_preview.rb @@ -9,7 +9,7 @@ def event_registration_duplicate EventMailer.event_registration_confirmation( event_registration, duplicate: true, - changes_message: "Scholarship now requested\nPayment method changed from Check to Credit card" + changes_message: "Scholarship now requested\nPayment method changed from Check to Credit card\nOrganization linked: Helping Hands\nCity: changed from Oakland to Berkeley" ) end