Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
Expand Down
2 changes: 2 additions & 0 deletions app/jobs/notification_mailer_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ 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) },
"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) },
Expand Down
18 changes: 16 additions & 2 deletions app/mailers/event_mailer.rb
Original file line number Diff line number Diff line change
@@ -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"

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

Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions app/mailers/notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
changes = EventRegistrationServices::ResubmissionChanges.from_message(notification.custom_message)
@change_notes = changes.notes
@notification_type = "Duplicate event registration"

mail(
subject: "#{FYI_PREFIX} DUPLICATE #{@event_registration.registration_subject_noun} by #{@person.full_name} to #{@event.title}#{changes.subject_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)
Expand Down
4 changes: 4 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Notification < ApplicationRecord

event_registration_confirmation
event_registration_confirmation_fyi
event_registration_duplicate
event_registration_duplicate_fyi
event_registration_cancelled
event_registration_cancelled_fyi
event_registration_reminder
Expand Down Expand Up @@ -102,6 +104,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" ],
Expand All @@ -118,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" ],
Expand Down
34 changes: 30 additions & 4 deletions app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def call
)

if existing
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?
Expand All @@ -100,15 +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)
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

Expand Down Expand Up @@ -401,5 +406,26 @@ def send_notifications(event_registration)
notification_type: 0
)
end

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: changes.to_message,
notification_type: 1
)
end
end
end
89 changes: 89 additions & 0 deletions app/services/event_registration_services/resubmission_changes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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

# 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)
{
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?,
organization_ids: registration.event_registration_organizations.pluck(:organization_id)
}
end

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"}"
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
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

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
11 changes: 10 additions & 1 deletion app/views/event_mailer/event_registration_confirmation.html.erb
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
<h1>Event registration received</h1>
<h1><%= @duplicate ? "DUPLICATE event registration received" : "Event registration received" %></h1>

<div style="margin-top: 36px; text-align: left;">
<p>
Hello <strong><%= @person.first_name %></strong>,
</p>

<% if @duplicate %>
<p>
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.
</p>

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

<p>
This message confirms your registration for the following <%= @organization_name %> event:
</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
Loading
Loading