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