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