From bdcf6571fe3e03577855f05a175dcd62942d5b6e Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 22 Jul 2026 07:41:18 -0400 Subject: [PATCH] Add Pay for Others submitters to the event bulk-reminder page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event admins chase unpaid balances from the bulk-reminder page, but the payer behind a "Pay for Others" submission was invisible there — so nobody nudged them when attendees they said they'd cover never registered or were never paid for. Surface those submitters in their own section so the admin can remind the payer in the same send. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + app/controllers/events_controller.rb | 69 ++++++++++++-- app/jobs/notification_mailer_job.rb | 1 + app/mailers/event_mailer.rb | 44 +++++++-- app/models/form_submission.rb | 15 +++ app/models/notification.rb | 1 + .../bulk_payment_reminder_recipients.rb | 66 +++++++++++++ .../event_bulk_payment_reminder.html.erb | 43 +++++++++ .../event_bulk_payment_reminder.text.erb | 25 +++++ .../event_registration_reminder_fyi.html.erb | 6 +- .../event_registration_reminder_fyi.text.erb | 6 +- .../_reminder_submitter_recipients.html.erb | 71 ++++++++++++++ app/views/events/confirm_reminder.html.erb | 93 +++++++++++++------ app/views/events/preview_reminder.html.erb | 27 +++--- config/brakeman.ignore | 34 +++++++ spec/mailers/event_mailer_spec.rb | 56 ++++++++++- spec/requests/events/bulk_reminders_spec.rb | 66 +++++++++++++ .../bulk_payment_reminder_recipients_spec.rb | 82 ++++++++++++++++ 18 files changed, 642 insertions(+), 64 deletions(-) create mode 100644 app/services/bulk_payment_reminder_recipients.rb create mode 100644 app/views/event_mailer/event_bulk_payment_reminder.html.erb create mode 100644 app/views/event_mailer/event_bulk_payment_reminder.text.erb create mode 100644 app/views/events/_reminder_submitter_recipients.html.erb create mode 100644 spec/services/bulk_payment_reminder_recipients_spec.rb diff --git a/AGENTS.md b/AGENTS.md index a3ea19ff71..b26e400b93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,7 @@ end - `EventRegistrationServices::PublicRegistration` — Public registration handling - `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:certificate_due` → `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter - `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids) +- `BulkPaymentReminderRecipients` — Finds the "Pay for Others" submitters that belong on the bulk reminder page: those whose submission still has an attendee not connected to a registration, or a matched registration not paid in full. Reuses `FormSubmissionDecorator#matched_attendees` so "connected" matches the bulk-payments dashboard; returns `Recipient` value objects (submission + email + unregistered/unpaid counts) that render the "Pay for Others submitters" section and receive `EventMailer#event_bulk_payment_reminder` - `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Art supplies, Handouts, and FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) - `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Art supplies, Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours and Art supplies are edited like every other built-in — their title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) - `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `
` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `
` starts expanded) rebuilt into a styled collapsible card. `
`/`` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save — the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the art-supplies ("Art supplies & what to bring", a content callout on the generic page), CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `
`), falling back to `BuiltinCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index c6db2a6fff..76c6e3f642 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -331,6 +331,11 @@ def preview_reminder return render partial: "events/reminder_recipients" if turbo_frame_request? + # "Pay for Others" submitters with loose ends (an attendee not registered, or + # a matched registration not paid in full). They render in their own section + # below the registrant list and can be reminded in the same send. + @bulk_payment_recipients = BulkPaymentReminderRecipients.new(@event).call + @sample_registration = @event_registrations.first days_until_event = @event.start_date.present? ? (@event.start_date.to_date - Date.current).to_i : nil # Pre-fill the editable message with the standard reminder sentence (days @@ -342,11 +347,17 @@ def preview_reminder # edit; a blank subject falls back to the default at send time. @custom_subject = params.key?(:custom_subject) ? params[:custom_subject].to_s : helpers.default_reminder_subject(@event) + # Preview a real recipient in preview mode (custom-message container always + # present for the live preview). Prefer a registrant; fall back to a submitter + # so an event with only Pay-for-Others recipients still shows a sample. if @sample_registration - # Render in preview mode so the custom-message container is always present - # in the markup for the live preview, even before any text is typed. + @preview_kind = :registrant mail = EventMailer.event_registration_reminder(@sample_registration, custom_message: @custom_message, custom_subject: @custom_subject, preview: true) @reminder_preview_html = mail.html_part&.body&.decoded + elsif @bulk_payment_recipients.any? + @preview_kind = :submitter + mail = EventMailer.event_bulk_payment_reminder(@bulk_payment_recipients.first.submission, custom_message: @custom_message, custom_subject: @custom_subject, preview: true) + @reminder_preview_html = mail.html_part&.body&.decoded end end @@ -357,17 +368,26 @@ def confirm_reminder authorize! @event, to: :send_reminder? @event = @event.decorate @event_registrations = selected_reminder_registrations + @submitters = selected_reminder_submitters @custom_message = params[:custom_message].to_s @custom_subject = params[:custom_subject].to_s - if @event_registrations.empty? + if @event_registrations.empty? && @submitters.empty? redirect_to preview_reminder_event_path(@event, custom_message: @custom_message, custom_subject: @custom_subject), alert: "Please select at least one recipient." return end - mail = EventMailer.event_registration_reminder(@event_registrations.first, custom_message: @custom_message, custom_subject: @custom_subject) - @reminder_subject = mail.subject - @reminder_preview_html = mail.html_part&.body&.decoded + if @event_registrations.any? + mail = EventMailer.event_registration_reminder(@event_registrations.first, custom_message: @custom_message, custom_subject: @custom_subject) + @reminder_subject = mail.subject + @reminder_preview_html = mail.html_part&.body&.decoded + end + + if @submitters.any? + submitter_mail = EventMailer.event_bulk_payment_reminder(@submitters.first, custom_message: @custom_message, custom_subject: @custom_subject) + @submitter_reminder_subject = submitter_mail.subject + @submitter_reminder_preview_html = submitter_mail.html_part&.body&.decoded + end end def send_reminder @@ -375,8 +395,9 @@ def send_reminder custom_message = params[:custom_message].to_s custom_subject = params[:custom_subject].to_s registrations = selected_reminder_registrations + submitters = selected_reminder_submitters - if registrations.empty? + if registrations.empty? && submitters.empty? redirect_to preview_reminder_event_path(@event, custom_message: custom_message, custom_subject: custom_subject), alert: "Please select at least one recipient." return end @@ -396,13 +417,29 @@ def send_reminder ) end + # Pay-for-Others submitters get the payer-facing reminder, tracked against + # their submission so it shows in the payer's communication history too. + submitters.each do |submission| + NotificationServices::CreateNotification.call( + noticeable: submission, + kind: "event_bulk_payment_reminder", + recipient_role: :person, + recipient_email: submission.bulk_payment_reminder_email, + notification_type: 0, + custom_message: custom_message.presence, + custom_subject: custom_subject.presence + ) + end + # One admin summary for the whole batch: count, roster, and a copy of what # was sent. Roster passed as plain "Name " labels so the delivery job - # needs no record lookups. - recipient_labels = registrations.map { |r| "#{r.registrant.full_name} <#{r.registrant.preferred_email}>" } + # needs no record lookups; submitters are tagged so the two groups read apart. + recipient_labels = registrations.map { |r| "#{r.registrant.full_name} <#{r.registrant.preferred_email}>" } + + submitters.map { |s| "#{s.bulk_payment_payer_name} <#{s.bulk_payment_reminder_email}> (Pay for Others)" } EventMailer.event_registration_reminder_fyi(@event, recipient_labels, custom_message: custom_message.presence).deliver_later - redirect_to registrants_event_path(@event), notice: "Reminder emails are being sent to #{registrations.size} registrant#{'s' if registrations.size != 1}." + count = registrations.size + submitters.size + redirect_to registrants_event_path(@event), notice: "Reminder emails are being sent to #{count} recipient#{'s' if count != 1}." end def create @@ -516,6 +553,18 @@ def selected_reminder_registrations .select { |r| r.registrant.preferred_email.present? } end + # The Pay-for-Others submitters the admin checked, narrowed to those we can + # actually email. Mirrors selected_reminder_registrations for the submitter + # section so confirm and send operate on the same set. + def selected_reminder_submitters + allowed_ids = Array(params[:form_submission_ids]).map(&:to_i).reject(&:zero?) + @event.form_submissions + .bulk_payment + .where(id: allowed_ids) + .includes(:person, form_answers: :form_field) + .select { |submission| submission.bulk_payment_reminder_email.present? } + end + # Reloads the payment and the data its bulk payment card needs, so the # allocate turbo stream can re-render the whole card with fresh due/allocated # totals and re-evaluate whether each registration is now paid in full. diff --git a/app/jobs/notification_mailer_job.rb b/app/jobs/notification_mailer_job.rb index 298afb5c2d..18f024b059 100644 --- a/app/jobs/notification_mailer_job.rb +++ b/app/jobs/notification_mailer_job.rb @@ -16,6 +16,7 @@ def perform(notification_id, persist_delivered_email: true) "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) }, + "event_bulk_payment_reminder" => ->(n) { EventMailer.event_bulk_payment_reminder(n.noticeable, custom_message: n.custom_message, custom_subject: n.custom_subject) }, "bulk_payment_confirmation" => ->(n) { EventMailer.bulk_payment_confirmation(n.noticeable) }, "bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) } } diff --git a/app/mailers/event_mailer.rb b/app/mailers/event_mailer.rb index a846ae0a6d..02c6c27a3a 100644 --- a/app/mailers/event_mailer.rb +++ b/app/mailers/event_mailer.rb @@ -73,11 +73,43 @@ def event_registration_reminder(event_registration, custom_message: nil, custom_ ) end - # Single admin summary sent once per bulk-reminder send: how many registrants - # were emailed, who they were, and a copy of the reminder content. The roster - # is passed as "Name " labels (not records), so the job that delivers - # this needs no extra lookups. The per-recipient reminders are tracked - # notifications; this is just an at-a-glance heads-up for the team. + # The bulk-reminder counterpart for a "Pay for Others" submitter. They have no + # ticket of their own, so instead of a registration reminder they get a nudge + # to their public payment ticket (attendees + total). Shares the admin's custom + # subject/message with the registrant reminder so one compose drives both. + def event_bulk_payment_reminder(form_submission, custom_message: nil, custom_subject: nil, preview: false) + @submission = form_submission + @person = form_submission.person + @event = form_submission.event&.decorate + @answers = form_submission.answers_by_identifier + @attendee_count = form_submission.bulk_payment_attendee_count + @custom_message = custom_message.presence + @custom_subject = custom_subject.presence + # See event_registration_reminder: renders the live-preview message container + # even when blank. Never set on a real send. + @preview = preview + + @notification_type = "Event bulk payment reminder" + + @time_zone = @person&.user&.time_zone || Time.zone.name + @ticket_url = bulk_payment_ticket_url(@submission.slug) if @submission.event.present? && @submission.slug.present? + @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") + + default_subject = "AWBW Portal: Reminder: complete your payment for #{@event&.title}" + mail( + to: form_submission.bulk_payment_reminder_email, + from: ENV.fetch("REPLY_TO_EMAIL", "no-reply@awbw.org"), + reply_to: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"), + subject: @custom_subject || default_subject + ) + end + + # Single admin summary sent once per bulk-reminder send: how many people were + # emailed, who they were, and a copy of the reminder content. Recipients span + # both registrants and Pay-for-Others submitters, so the roster is passed as + # "Name " labels (not records) and the count uses the neutral noun + # "recipient". The per-recipient reminders are tracked notifications; this is + # just an at-a-glance heads-up for the team. def event_registration_reminder_fyi(event, recipient_labels, custom_message: nil) @event = event.decorate @recipient_labels = Array(recipient_labels) @@ -91,7 +123,7 @@ def event_registration_reminder_fyi(event, recipient_labels, custom_message: nil to: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"), from: ENV.fetch("REPLY_TO_EMAIL", "no-reply@awbw.org"), reply_to: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"), - subject: "AWBW Portal: [FYI] Reminder sent to #{count} registrant#{'s' if count != 1} for #{@event.title}" + subject: "AWBW Portal: [FYI] Reminder sent to #{count} recipient#{'s' if count != 1} for #{@event.title}" ) end diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb index 6714a1d2bf..215959c276 100644 --- a/app/models/form_submission.rb +++ b/app/models/form_submission.rb @@ -67,6 +67,21 @@ def bulk_payment_amount_cents(event) event.cost_cents.to_i * bulk_payment_attendee_count end + # The payer's name for admin-facing summaries. Prefers the linked person, then + # the submitted payer answers (the payer often has no account). + def bulk_payment_payer_name + person&.full_name.presence || + [ answers_by_identifier["payer_first_name"], answers_by_identifier["payer_last_name"] ] + .compact_blank.join(" ").presence || + "Payer" + end + + # The address a bulk-payment reminder is sent to: the payer's account/contact + # email, falling back to the email typed on the form. + def bulk_payment_reminder_email + person&.preferred_email.presence || answers_by_identifier["payer_email"].presence + end + private def generate_slug diff --git a/app/models/notification.rb b/app/models/notification.rb index fa875e9316..3892d56751 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -27,6 +27,7 @@ class Notification < ApplicationRecord event_registration_cancelled event_registration_cancelled_fyi event_registration_reminder + event_bulk_payment_reminder bulk_payment_confirmation bulk_payment_confirmation_fyi idea_submitted diff --git a/app/services/bulk_payment_reminder_recipients.rb b/app/services/bulk_payment_reminder_recipients.rb new file mode 100644 index 0000000000..cdfcfd6e37 --- /dev/null +++ b/app/services/bulk_payment_reminder_recipients.rb @@ -0,0 +1,66 @@ +# The "Pay for Others" submitters who belong on an event's bulk-reminder page: +# those whose submission still has an attendee not connected to a registration, +# or a matched registration that isn't paid in full. Reuses the same fuzzy +# attendee-to-registration matching the bulk-payments dashboard uses +# (FormSubmissionDecorator#matched_attendees), so "connected" means the same +# thing on both pages. Fully-resolved submissions (everyone registered and paid) +# are left out — there's nothing to remind the payer about. +class BulkPaymentReminderRecipients + # One qualifying submitter plus the counts that explain why they're here. + Recipient = Data.define(:submission, :email, :attendee_count, :unregistered_count, :unpaid_count) do + def payer_name + submission.bulk_payment_payer_name + end + + def outstanding_count + unregistered_count + unpaid_count + end + end + + def initialize(event) + @event = event + end + + def call + submissions.filter_map { |submission| recipient_for(submission) } + end + + private + + def submissions + @event.form_submissions + .bulk_payment + .includes(:person, form_answers: :form_field) + .order(created_at: :desc) + end + + # Every active registration on the event — matched in memory against each + # submission's attendees. Loaded once (with registrant + allocations) so the + # per-submission matching and paid-in-full checks cost no extra queries. + def registrations + @registrations ||= @event.event_registrations + .active + .includes(registrant: :contact_methods, allocations: []) + .to_a + end + + def recipient_for(submission) + email = submission.bulk_payment_reminder_email + return if email.blank? + + matched = submission.decorate.matched_attendees(registrations) + unregistered = matched.count { |attendee| attendee[:matches].empty? } + # Matched, but the registration we linked them to still owes money. An + # attendee tied to several registrations counts as paid once any is settled. + unpaid = matched.count { |attendee| attendee[:matches].present? && attendee[:matches].none?(&:paid_in_full?) } + return if unregistered.zero? && unpaid.zero? + + Recipient.new( + submission: submission, + email: email, + attendee_count: matched.size, + unregistered_count: unregistered, + unpaid_count: unpaid + ) + end +end diff --git a/app/views/event_mailer/event_bulk_payment_reminder.html.erb b/app/views/event_mailer/event_bulk_payment_reminder.html.erb new file mode 100644 index 0000000000..93f79a74cf --- /dev/null +++ b/app/views/event_mailer/event_bulk_payment_reminder.html.erb @@ -0,0 +1,43 @@ +

Payment reminder

+ +
+

+ Hello <%= @submission.bulk_payment_payer_name %>, +

+ + <%# Editable intro the admin sets on the bulk-reminder page. In preview mode the + container always renders (hidden when empty) with the hooks the + reminder-preview Stimulus controller fills in live; a real send only renders + it when a message is present. Mirrors event_registration_reminder. %> + <% if @custom_message.present? || @preview %> + <%= content_tag :div, + reminder_message_html(@custom_message), + id: ("reminder-custom-message" if @preview), + data: ({ reminder_preview_target: "message" } if @preview), + style: "margin: 8px 0; font-size: 14px; line-height: 1.5;#{' display: none;' if @preview && @custom_message.blank?}" %> + <% end %> + +

+ You submitted a "Pay for Others" form for + <%= pluralize(@attendee_count, "attendee") %><%= " for #{@event.title}" if @event %>. + Some of them still need to complete their registration or payment. +

+ + <% if @event %> + <%= render "event_details_card", event: @event, time_zone: @time_zone %> + <% end %> +
+ +<% if @ticket_url %> +

+ Review who you're paying for and your total, and finish any outstanding payment: +

+ +

+ View payment +

+<% end %> + +

+ This is an automated reminder from <%= @organization_name %>. +

diff --git a/app/views/event_mailer/event_bulk_payment_reminder.text.erb b/app/views/event_mailer/event_bulk_payment_reminder.text.erb new file mode 100644 index 0000000000..a3ff0ecad1 --- /dev/null +++ b/app/views/event_mailer/event_bulk_payment_reminder.text.erb @@ -0,0 +1,25 @@ +Payment reminder + +Hello <%= @submission.bulk_payment_payer_name %>, + +<% if @custom_message.present? %> +<%= strip_tags(@custom_message).strip %> + +<% end %>You submitted a "Pay for Others" form for <%= pluralize(@attendee_count, "attendee") %><%= " for #{@event.title}" if @event %>. Some of them still need to complete their registration or payment. + +<% if @event %><% if @event.respond_to?(:pre_title) && @event.pre_title.present? %> +<%= @event.pre_title %> +<% end %><%= @event.title %> +<% Time.use_zone(@time_zone) do %><% if event_dates_detail_label(@event.object).present? %><%= event_dates_detail_label(@event.object) %> +<% end %><% if event_times_label(@event.object).present? %><%= event_times_label(@event.object) %> +<% end %><% end %> +<% if event_location_label(@event.object).present? %> +Location: <%= event_location_label(@event.object) %> +<% end %> +<% end %><% if @ticket_url %> +Review who you're paying for and your total, and finish any outstanding payment: +<%= @ticket_url %> +<% end %> + +-- +This is an automated reminder from <%= @organization_name %>. diff --git a/app/views/event_mailer/event_registration_reminder_fyi.html.erb b/app/views/event_mailer/event_registration_reminder_fyi.html.erb index 5c60ad1be5..b1e30c8ba8 100644 --- a/app/views/event_mailer/event_registration_reminder_fyi.html.erb +++ b/app/views/event_mailer/event_registration_reminder_fyi.html.erb @@ -2,7 +2,7 @@

- A reminder email was sent to <%= @recipient_labels.size %> registrant<%= "s" if @recipient_labels.size != 1 %> for <%= @event.title %>. + A reminder email was sent to <%= @recipient_labels.size %> recipient<%= "s" if @recipient_labels.size != 1 %> for <%= @event.title %>.

<% if @recipient_labels.any? %> @@ -17,7 +17,7 @@

- Reminder content sent to each registrant: + Reminder content sent to each recipient:

@@ -30,6 +30,6 @@ <%= render "event_details_card", event: @event, time_zone: @time_zone %>

- Each registrant also received a personalized "View ticket" link. + Each recipient also received a personalized link to their ticket or payment.

diff --git a/app/views/event_mailer/event_registration_reminder_fyi.text.erb b/app/views/event_mailer/event_registration_reminder_fyi.text.erb index 7a418c5527..f3de4c5921 100644 --- a/app/views/event_mailer/event_registration_reminder_fyi.text.erb +++ b/app/views/event_mailer/event_registration_reminder_fyi.text.erb @@ -1,11 +1,11 @@ Reminder sent -A reminder email was sent to <%= @recipient_labels.size %> registrant<%= "s" if @recipient_labels.size != 1 %> for <%= @event.title %>. +A reminder email was sent to <%= @recipient_labels.size %> recipient<%= "s" if @recipient_labels.size != 1 %> for <%= @event.title %>. <% @recipient_labels.each do |label| %>- <%= label %> <% end %> -- -Reminder content sent to each registrant: +Reminder content sent to each recipient: <% if @custom_message.present? %><%= strip_tags(@custom_message).strip %> @@ -20,4 +20,4 @@ Location: <%= event_location_label(@event.object) %> <%= event_platform_label(@event.object) %> <% end %> -Each registrant also received a personalized "View ticket" link. +Each recipient also received a personalized link to their ticket or payment. diff --git a/app/views/events/_reminder_submitter_recipients.html.erb b/app/views/events/_reminder_submitter_recipients.html.erb new file mode 100644 index 0000000000..4e51008403 --- /dev/null +++ b/app/views/events/_reminder_submitter_recipients.html.erb @@ -0,0 +1,71 @@ +<%# "Pay for Others" submitters with loose ends, in their own section below the + registrant list. Only rendered when there are any; checkboxes submit under + form_submission_ids[] alongside the registrant registration_ids[]. %> +<% if @bulk_payment_recipients.present? %> +
+

+ Pay for Others submitters + (<%= @bulk_payment_recipients.size %>) +

+

+ People who submitted a "Pay for Others" form but still have attendees who + aren't registered yet, or whose registration hasn't been paid in full. + Remind them to follow up. They're a separate group — the filters above don't + change this list. +

+ +
+ + + + + + + + + + + <% @bulk_payment_recipients.each do |recipient| %> + <% submission = recipient.submission %> + + + + + + + <% end %> + +
+ + PayerOutstandingBulk payment
+ <%= check_box_tag "form_submission_ids[]", submission.id, true, class: "submitter-checkbox rounded border-gray-300", id: "form_submission_ids_#{submission.id}" %> + + <%= recipient.payer_name %> + (<%= recipient.email %>) + + <% if recipient.unregistered_count.positive? %> + + + <%= recipient.unregistered_count %> not registered + + <% end %> + <% if recipient.unpaid_count.positive? %> + + + <%= recipient.unpaid_count %> unpaid + + <% end %> + of <%= pluralize(recipient.attendee_count, "attendee") %> + + <%# Opens the payer's card on the bulk payments dashboard in a new + tab so the in-progress draft on this page isn't lost. %> + <%= link_to bulk_payments_event_path(@event, expand: submission.id, anchor: "payment-card-#{submission.id}"), + title: "View #{recipient.payer_name}'s bulk payment (opens in a new tab)", + target: "_blank", rel: "noopener", + class: "inline-flex items-center shrink-0 rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-600 shadow-sm hover:bg-gray-50 hover:text-gray-800 transition-colors" do %> + + <% end %> +
+
+
+<% end %> diff --git a/app/views/events/confirm_reminder.html.erb b/app/views/events/confirm_reminder.html.erb index 5b3b97435d..bcfa7c0303 100644 --- a/app/views/events/confirm_reminder.html.erb +++ b/app/views/events/confirm_reminder.html.erb @@ -14,55 +14,90 @@

- <% count = @event_registrations.size %> + <% registrant_count = @event_registrations.size %> + <% submitter_count = @submitters.size %> + <% count = registrant_count + submitter_count %>
-

You're about to email <%= count %> registrant<%= "s" if count != 1 %>.

+

You're about to email <%= count %> recipient<%= "s" if count != 1 %>.

Review the recipients and the message below, then send. Each person receives their own copy, and one FYI email will be sent to the admin.

-
-

Recipients (<%= count %>)

+ <% if registrant_count.positive? %> +
+

Registrants (<%= registrant_count %>)

-

Comma-separated names

-

- <%= @event_registrations.map { |reg| reg.registrant.full_name }.join(", ") %> -

+

Comma-separated names

+

+ <%= @event_registrations.map { |reg| reg.registrant.full_name }.join(", ") %> +

-

Comma-separated emails

-

- <%= @event_registrations.map { |reg| reg.registrant.preferred_email }.join(", ") %> -

+

Comma-separated emails

+

+ <%= @event_registrations.map { |reg| reg.registrant.preferred_email }.join(", ") %> +

-

Full list

-
    - <% @event_registrations.each do |reg| %> -
  • - <%= reg.registrant.full_name %> - <<%= reg.registrant.preferred_email %>> -
  • - <% end %> -
-
+

Full list

+
    + <% @event_registrations.each do |reg| %> +
  • + <%= reg.registrant.full_name %> + <<%= reg.registrant.preferred_email %>> +
  • + <% end %> +
+
+ <% end %> -
-
-

Subject

-

<%= @reminder_subject %>

+ <% if submitter_count.positive? %> +
+

Pay for Others submitters (<%= submitter_count %>)

+
    + <% @submitters.each do |submission| %> +
  • + <%= submission.bulk_payment_payer_name %> + <<%= submission.bulk_payment_reminder_email %>> +
  • + <% end %> +
- + <% end %> + + <% if @submitter_reminder_preview_html.present? %> +
+
+

Pay for Others email — subject

+

<%= @submitter_reminder_subject %>

+
+ +
+ <% end %> <%= form_with url: send_reminder_event_path(@event), method: :post, data: { turbo: false } do |f| %> <% @event_registrations.each do |reg| %> <%= hidden_field_tag "registration_ids[]", reg.id %> <% end %> + <% @submitters.each do |submission| %> + <%= hidden_field_tag "form_submission_ids[]", submission.id %> + <% end %> <%# Carry the composed subject/message through to the send untouched. %> <%= hidden_field_tag :custom_subject, @custom_subject %> <%= hidden_field_tag :custom_message, @custom_message %> diff --git a/app/views/events/preview_reminder.html.erb b/app/views/events/preview_reminder.html.erb index e7b0295cac..d89f20abd3 100644 --- a/app/views/events/preview_reminder.html.erb +++ b/app/views/events/preview_reminder.html.erb @@ -14,23 +14,28 @@ <%= @event.title %><% if @event.start_date.present? %> • <%= @event.date_range %><% end %>

- <% if @event_registrations.empty? %> -

There are no registrants with an email address to send a reminder to.

+ <% if @event_registrations.empty? && @bulk_payment_recipients.empty? %> +

There are no registrants or Pay for Others submitters with an email address to send a reminder to.

<%= link_to "Back to registrants", registrants_event_path(@event), class: "btn btn-secondary-outline" %> <% else %>

Recipients

-

- Choose who will receive this reminder. Filters keep everyone in the list and - check the matches. Separate multiple values with - '--' to match multiple values (e.g. - amy--aisha). -

- <%= render "events/reminder_recipient_filters" %> + <% if @event_registrations.any? %> +

+ Choose who will receive this reminder. Filters keep everyone in the list and + check the matches. Separate multiple values with + '--' to match multiple values (e.g. + amy--aisha). +

+ <%= render "events/reminder_recipient_filters" %> + <% end %> <%# Turbo disabled: this POST renders the confirmation interstitial (a full page), not a redirect, so a normal browser navigation is what we want. reminder-preview drives the live message/subject preview as the admin types. %> <%= form_with url: confirm_reminder_event_path(@event), method: :post, data: { controller: "reminder-preview", turbo: false } do |f| %> - <%= render "events/reminder_recipients" %> + <% if @event_registrations.any? %> + <%= render "events/reminder_recipients" %> + <% end %> + <%= render "events/reminder_submitter_recipients" %>

Email draft

@@ -60,7 +65,7 @@
<% if @reminder_preview_html.present? %>
-

Preview (sample registrant)

+

Preview (<%= @preview_kind == :submitter ? "Pay for Others submitter" : "sample registrant" %>)

Subject: diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 785b764f14..4da15c5638 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -68,6 +68,40 @@ ], "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 2, + "fingerprint": "21ebe80a4297e807ac16cbcce9a837e46dadaa4f47f6ececb0821b7dc50a386e", + "check_name": "CrossSiteScripting", + "message": "Unescaped parameter value", + "file": "app/views/events/confirm_reminder.html.erb", + "line": 89, + "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "EventMailer.event_bulk_payment_reminder(selected_reminder_submitters.first, :custom_message => params[:custom_message].to_s, :custom_subject => params[:custom_subject].to_s).html_part.body.decoded", + "render_path": [ + { + "type": "controller", + "class": "EventsController", + "method": "confirm_reminder", + "line": 387, + "file": "app/controllers/events_controller.rb", + "rendered": { + "name": "events/confirm_reminder", + "file": "app/views/events/confirm_reminder.html.erb" + } + } + ], + "location": { + "type": "template", + "template": "events/confirm_reminder" + }, + "user_input": "params[:custom_message].to_s", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "Admin-only Pay-for-Others reminder preview. Same as the registrant reminder preview: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." + }, { "warning_type": "Redirect", "warning_code": 18, diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb index d9d4bfdd76..9ce0288282 100644 --- a/spec/mailers/event_mailer_spec.rb +++ b/spec/mailers/event_mailer_spec.rb @@ -273,6 +273,58 @@ end end + describe "#event_bulk_payment_reminder" do + let(:event) { create(:event, title: "Art Workshop", cost_cents: 10_000) } + let(:form) { create(:form) } + let(:payer) { create(:person, first_name: "Pat", last_name: "Payer") } + let(:submission) do + create(:form_submission, form: form, event: event, person: payer, role: "bulk_payment").tap do |s| + field = create(:form_field, form: form, field_identifier: "bulk_payment_attendees", name: "Attendees") + s.form_answers.create!(form_field: field, + submitted_answer: [ { "first_name" => "Nora", "last_name" => "West" } ].to_json) + end + end + let(:mail) { described_class.event_bulk_payment_reminder(submission) } + + it "renders without raising" do + expect { mail.deliver_now }.not_to raise_error + end + + it "sends to the payer" do + expect(mail.to).to eq([ payer.preferred_email ]) + end + + it "addresses the payer by name and links to their payment ticket" do + expect(mail.html_part.body.encoded).to include("Pat Payer") + expect(mail.html_part.body.encoded).to include(bulk_payment_ticket_url(submission.slug)) + end + + it "falls back to a payment-focused default subject" do + expect(mail.subject).to include("complete your payment") + expect(mail.subject).to include("Art Workshop") + end + + context "with a custom subject and message" do + let(:mail) { described_class.event_bulk_payment_reminder(submission, custom_subject: "Please pay!", custom_message: "Thanks so much.") } + + it "uses the custom subject verbatim" do + expect(mail.subject).to eq("Please pay!") + end + + it "includes the sanitized custom message" do + expect(mail.html_part.body.encoded).to include("Thanks so much.") + end + end + + context "in preview mode" do + let(:mail) { described_class.event_bulk_payment_reminder(submission, preview: true) } + + it "renders the custom-message container even when blank" do + expect(mail.html_part.body.encoded).to include("reminder-custom-message") + end + end + end + describe "#event_registration_reminder_fyi" do let(:event) { create(:event, title: "Art Workshop") } let(:recipient_labels) { [ "Alex Rivera ", "Sam Lee " ] } @@ -288,7 +340,7 @@ it "summarizes the count and event in the subject" do expect(mail.subject).to include("[FYI]") - expect(mail.subject).to include("2 registrants") + expect(mail.subject).to include("2 recipients") expect(mail.subject).to include("Art Workshop") end @@ -304,7 +356,7 @@ it "uses the singular noun for a single recipient" do mail = described_class.event_registration_reminder_fyi(event, [ "Alex Rivera " ]) - expect(mail.subject).to include("1 registrant ") + expect(mail.subject).to include("1 recipient ") end end end diff --git a/spec/requests/events/bulk_reminders_spec.rb b/spec/requests/events/bulk_reminders_spec.rb index 37126a731b..d7c22610ed 100644 --- a/spec/requests/events/bulk_reminders_spec.rb +++ b/spec/requests/events/bulk_reminders_spec.rb @@ -16,6 +16,19 @@ def checked?(body, registration) node.present? && node["checked"].present? end + # A bulk-payment submission for this event whose single attendee isn't + # registered, so it qualifies for the Pay-for-Others reminder section. + def bulk_submission_with_unregistered_attendee(payer_name: "Pat Payer") + form = create(:form) + first, last = payer_name.split + payer = create(:person, first_name: first, last_name: last) + submission = create(:form_submission, form: form, event: event, person: payer, role: "bulk_payment") + field = create(:form_field, form: form, field_identifier: "bulk_payment_attendees", name: "Attendees") + submission.form_answers.create!(form_field: field, + submitted_answer: [ { "first_name" => "Nora", "last_name" => "West" } ].to_json) + submission + end + it "checks every registrant by default" do get preview_reminder_event_path(event) @@ -93,4 +106,57 @@ def checked?(body, registration) expect(response).to redirect_to(registrants_event_path(event)) end end + + describe "Pay for Others submitters" do + let!(:submission) { bulk_submission_with_unregistered_attendee } + + it "lists a qualifying submitter, pre-checked, below the registrants" do + get preview_reminder_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Pay for Others submitters") + expect(response.body).to include("Pat Payer") + node = Nokogiri::HTML(response.body).at_css("#form_submission_ids_#{submission.id}") + expect(node).to be_present + expect(node["checked"]).to be_present + end + + it "omits a submitter whose attendees are all registered and paid" do + registrant = create(:person, first_name: "Nora", last_name: "West") + reg = create(:event_registration, event: event, registrant: registrant) + create(:allocation, allocatable: reg, amount: event.cost_cents) + + get preview_reminder_event_path(event) + + expect(response.body).not_to include("Pat Payer") + end + + it "lists the submitter on the confirm interstitial without sending" do + expect { + post confirm_reminder_event_path(event), params: { form_submission_ids: [ submission.id ], custom_message: "Please pay!" } + }.not_to change(Notification, :count) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Pat Payer") + expect(response.body).to include("value=\"#{submission.id}\"") + end + + it "sends a bulk-payment reminder notification to a selected submitter" do + expect { + post send_reminder_event_path(event), params: { registration_ids: [ jane.id ], form_submission_ids: [ submission.id ] } + }.to change { Notification.where(kind: "event_registration_reminder").count }.by(1) + .and change { Notification.where(kind: "event_bulk_payment_reminder").count }.by(1) + .and have_enqueued_mail(EventMailer, :event_registration_reminder_fyi).once + + expect(response).to redirect_to(registrants_event_path(event)) + end + + it "can send to a submitter alone" do + expect { + post send_reminder_event_path(event), params: { form_submission_ids: [ submission.id ] } + }.to change { Notification.where(kind: "event_bulk_payment_reminder").count }.by(1) + + expect(response).to redirect_to(registrants_event_path(event)) + end + end end diff --git a/spec/services/bulk_payment_reminder_recipients_spec.rb b/spec/services/bulk_payment_reminder_recipients_spec.rb new file mode 100644 index 0000000000..565604014b --- /dev/null +++ b/spec/services/bulk_payment_reminder_recipients_spec.rb @@ -0,0 +1,82 @@ +require "rails_helper" + +RSpec.describe BulkPaymentReminderRecipients do + let(:event) { create(:event, cost_cents: 10_000) } + + # Builds a bulk-payment submission whose attendees are the given hashes. + def bulk_submission(attendees, payer: create(:person)) + form = create(:form) + submission = create(:form_submission, form: form, event: event, person: payer, role: "bulk_payment") + field = create(:form_field, form: form, field_identifier: "bulk_payment_attendees", name: "Attendees") + submission.form_answers.create!(form_field: field, submitted_answer: attendees.to_json) + submission + end + + # A registration for a person with the given name so attendees match by name. + def registration_for(first, last) + create(:event_registration, event: event, registrant: create(:person, first_name: first, last_name: last)) + end + + def pay_in_full(registration) + create(:allocation, allocatable: registration, amount: event.cost_cents) + end + + it "includes a submitter whose attendee isn't connected to a registration" do + bulk_submission([ { "first_name" => "Nora", "last_name" => "West" } ]) + + recipients = described_class.new(event).call + + expect(recipients.size).to eq(1) + expect(recipients.first.unregistered_count).to eq(1) + expect(recipients.first.unpaid_count).to eq(0) + end + + it "includes a submitter whose matched registration hasn't been paid in full" do + registration_for("Jane", "Adams") + bulk_submission([ { "first_name" => "Jane", "last_name" => "Adams" } ]) + + recipients = described_class.new(event).call + + expect(recipients.size).to eq(1) + expect(recipients.first.unregistered_count).to eq(0) + expect(recipients.first.unpaid_count).to eq(1) + end + + it "excludes a submitter whose attendees are all registered and paid" do + paid = registration_for("Jane", "Adams") + pay_in_full(paid) + bulk_submission([ { "first_name" => "Jane", "last_name" => "Adams" } ]) + + expect(described_class.new(event).call).to be_empty + end + + it "counts both unregistered and unpaid attendees on one submission" do + unpaid_reg = registration_for("Jane", "Adams") + paid_reg = registration_for("Sam", "Cole") + pay_in_full(paid_reg) + bulk_submission([ + { "first_name" => "Jane", "last_name" => "Adams" }, # matched, unpaid + { "first_name" => "Sam", "last_name" => "Cole" }, # matched, paid + { "first_name" => "Nora", "last_name" => "West" } # unregistered + ]) + + recipient = described_class.new(event).call.first + expect(recipient.unregistered_count).to eq(1) + expect(recipient.unpaid_count).to eq(1) + expect(recipient.attendee_count).to eq(3) + expect(recipient.outstanding_count).to eq(2) + end + + it "skips a submission with no payer email" do + payer = create(:person, user: nil, email: nil, email_2: nil) + bulk_submission([ { "first_name" => "Nora", "last_name" => "West" } ], payer: payer) + + expect(described_class.new(event).call).to be_empty + end + + it "ignores non-bulk-payment submissions" do + create(:form_submission, event: event, role: "registration") + + expect(described_class.new(event).call).to be_empty + end +end