Skip to content
Draft
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details><summary>…</summary>…</details>` disclosure (the markup any HTML generator/LLM produces; `<toggle>` and a `title` attribute are accepted aliases; `<details open>` starts expanded) rebuilt into a styled collapsible card. `<details>`/`<summary>` 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 `<details>`), falling back to `BuiltinCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged
Expand Down
69 changes: 59 additions & 10 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -357,26 +368,36 @@ 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
authorize! @event, to: :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
Expand All @@ -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 <email>" 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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/jobs/notification_mailer_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
44 changes: 38 additions & 6 deletions app/mailers/event_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email>" 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 <email>" 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)
Expand All @@ -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

Expand Down
15 changes: 15 additions & 0 deletions app/models/form_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions app/services/bulk_payment_reminder_recipients.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading