From 83abc6664b2821b5cd5facbfe9bdc21988a217ec Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 09:56:33 -0400 Subject: [PATCH 1/2] Add CE request/payment deadlines to events and surface them Admins set per-event CE deadlines so registrants know when to request hours and pay; the CE callout, its detail page, and the public CE page show them dynamically based on what the registrant still owes. Co-Authored-By: Claude Opus 4.8 --- app/policies/event_policy.rb | 2 ++ app/services/magic_ticket_callouts.rb | 9 ++++++++- app/views/events/_form.html.erb | 16 +++++++++++++++ app/views/events/callouts/ce.html.erb | 7 ++++++- app/views/events/ce_hours.html.erb | 16 +++++++++++++++ ...260622135205_add_ce_deadlines_to_events.rb | 11 ++++++++++ db/schema.rb | 4 +++- spec/requests/events_spec.rb | 20 +++++++++++++++++++ spec/services/magic_ticket_callouts_spec.rb | 18 +++++++++++++++++ 9 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260622135205_add_ce_deadlines_to_events.rb diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index e345eb5352..3e7c7fbf61 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -115,6 +115,8 @@ def google_analytics? :event_details_label, :ce_hours_details, :ce_hours_details_label, + :ce_hours_request_deadline, + :ce_payment_due_deadline, :autoshow_cost, :autoshow_date, :autoshow_location, diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index 73ee8d6a88..bf51063053 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -135,6 +135,7 @@ def ce_hours_card def ce_hours_subtitle return "#{registration.ce_hours_requested} hours" if registration.ce_hours_requested.present? + return "Request your hours by #{ce_deadline_text(event.ce_hours_request_deadline)}" if event.ce_hours_request_deadline "Continuing education credit" end @@ -147,13 +148,19 @@ def ce_hours_badge(complete) if complete return unless amount_cents.positive? - return "#{amount} due" + return "#{amount} due" if registration.paid_in_full? || event.ce_payment_due_deadline.blank? + return "#{amount} due by #{ce_deadline_text(event.ce_payment_due_deadline)}" end needed = ce_missing_text amount_cents.positive? ? "#{amount} · #{needed}" : needed end + # Short month/day for a deadline shown inline on the CE card, e.g. "Jun 30". + def ce_deadline_text(deadline) + deadline.strftime("%b %-d") + end + def ce_missing_text missing_hours = registration.ce_hours_requested.blank? missing_license = !registration.ce_license_provided? diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index b5790dc9fe..574b289cf7 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -460,6 +460,22 @@ title_placeholder: "CE hours", content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.", content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

" %> +
+
+ <%= f.label :ce_hours_request_deadline, "Request hours by", class: "block text-xs font-medium text-gray-600 mb-0.5" %> +

Deadline for registrants to request their CE hours. Shown on the CE callout. Leave blank to hide.

+ <%= f.text_field :ce_hours_request_deadline, + type: "date", + class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> +
+
+ <%= f.label :ce_payment_due_deadline, "Payment due by", class: "block text-xs font-medium text-gray-600 mb-0.5" %> +

Deadline for CE payment. Shown on the CE callout. Leave blank to hide.

+ <%= f.text_field :ce_payment_due_deadline, + type: "date", + class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> +
+
<% when :event_details %> <%= render "events/builtin_callout_card", f: f, card: card, label_field: :event_details_label, content_field: :event_details, diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 25423fdb6f..1f90540b60 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -21,12 +21,17 @@ <% if @event_registration.ce_hours_requested.present? %>
<%= @event_registration.ce_hours_requested %>
<% else %> -
We don't have your requested hours yet.
+
+ We don't have your requested hours yet.<% if @event.ce_hours_request_deadline %> Please request by <%= @event.ce_hours_request_deadline.strftime("%B %-d, %Y") %>.<% end %> +
<% end %>
Cost<% if @event_registration.ce_hours_requested.present? %> (<%= @event_registration.ce_hours_requested %> × $<%= EventRegistration::CE_HOURLY_RATE_DOLLARS %>)<% end %>
<%= @event_registration.ce_hours_requested.present? ? dollars_from_cents(@event_registration.ce_amount_owed_cents) : "—" %>
+ <% if @event.ce_payment_due_deadline && !@event_registration.paid_in_full? %> +

Due by <%= @event.ce_payment_due_deadline.strftime("%B %-d, %Y") %>

+ <% end %>
License number
diff --git a/app/views/events/ce_hours.html.erb b/app/views/events/ce_hours.html.erb index b41405c443..c5a6d16b8a 100644 --- a/app/views/events/ce_hours.html.erb +++ b/app/views/events/ce_hours.html.erb @@ -25,6 +25,22 @@
+ <% if @event.ce_hours_request_deadline || @event.ce_payment_due_deadline %> +
+ <% if @event.ce_hours_request_deadline %> +
+
Request hours by
+
<%= @event.ce_hours_request_deadline.strftime("%B %-d, %Y") %>
+
+ <% end %> + <% if @event.ce_payment_due_deadline %> +
+
Payment due by
+
<%= @event.ce_payment_due_deadline.strftime("%B %-d, %Y") %>
+
+ <% end %> +
+ <% end %>
<%= form_label_html(@event.ce_hours_details) %>
diff --git a/db/migrate/20260622135205_add_ce_deadlines_to_events.rb b/db/migrate/20260622135205_add_ce_deadlines_to_events.rb new file mode 100644 index 0000000000..f282483908 --- /dev/null +++ b/db/migrate/20260622135205_add_ce_deadlines_to_events.rb @@ -0,0 +1,11 @@ +class AddCeDeadlinesToEvents < ActiveRecord::Migration[8.1] + def up + add_column :events, :ce_hours_request_deadline, :date unless column_exists?(:events, :ce_hours_request_deadline) + add_column :events, :ce_payment_due_deadline, :date unless column_exists?(:events, :ce_payment_due_deadline) + end + + def down + remove_column :events, :ce_hours_request_deadline, if_exists: true + remove_column :events, :ce_payment_due_deadline, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 87e2573c42..be3385e992 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_21_213947) do +ActiveRecord::Schema[8.1].define(version: 2026_06_22_135205) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -519,6 +519,8 @@ t.boolean "autoshow_videoconference_link", default: true, null: false t.text "ce_hours_details" t.string "ce_hours_details_label", default: "CE hours", null: false + t.date "ce_hours_request_deadline" + t.date "ce_payment_due_deadline" t.integer "cost_cents" t.datetime "created_at", null: false t.integer "created_by_id" diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 6366abd982..57639aed95 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -166,6 +166,17 @@ expect(response.body).to include("Email your license number") end + it "surfaces the CE deadlines when set" do + event.update!(ce_hours_details: "

CE info

", + ce_hours_request_deadline: Date.new(2026, 7, 1), + ce_payment_due_deadline: Date.new(2026, 8, 15)) + get ce_hours_event_path(event) + expect(response.body).to include("Request hours by") + expect(response.body).to include("July 1, 2026") + expect(response.body).to include("Payment due by") + expect(response.body).to include("August 15, 2026") + end + it "redirects to the event when details are blank" do get ce_hours_event_path(event) expect(response).to redirect_to(event_path(event)) @@ -391,6 +402,15 @@ expect(event.reload.hint_dates).to eq("must attend both days") expect(event.hint_registration_cost).to eq("due within 3 weeks of registration") end + + it "persists the CE deadlines" do + patch event_path(event), params: { event: { + ce_hours_request_deadline: "2026-07-01", + ce_payment_due_deadline: "2026-08-15" + } } + expect(event.reload.ce_hours_request_deadline).to eq(Date.new(2026, 7, 1)) + expect(event.ce_payment_due_deadline).to eq(Date.new(2026, 8, 15)) + end end context "as non-admin" do diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index b7f3b77578..c2eaf2f409 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -98,6 +98,24 @@ def card(reg, title) expect(complete.badge_classes).to include("teal") end + it "prompts to request CE hours by the deadline until hours are on file" do + event.update!(ce_hours_request_deadline: Date.new(2026, 7, 1)) + registration.update!(ce_credit_requested: true, ce_hours_requested: nil) + expect(card(registration, event.ce_hours_details_label).subtitle).to eq("Request your hours by Jul 1") + + registration.update!(ce_hours_requested: 6) + expect(card(registration, event.ce_hours_details_label).subtitle).to eq("6 hours") + end + + it "appends the CE payment deadline to the amount-due badge until paid" do + event.update!(ce_payment_due_deadline: Date.new(2026, 8, 15)) + registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: "LIC123") + expect(card(registration, event.ce_hours_details_label).badge).to eq("$150 due by Aug 15") + + create(:allocation, source: create(:payment), allocatable: registration, amount: event.cost_cents) + expect(card(registration, event.ce_hours_details_label).badge).to eq("$150 due") + end + it "shows the scholarship card only when requested, without an amount chip until awarded" do expect(card_titles(registration)).not_to include("Scholarship") registration.update!(scholarship_requested: true) From ef34e0bbb195e7f43a9ba353a0daa258b3b7967e Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 10:04:53 -0400 Subject: [PATCH 2/2] Surface CE deadlines in confirmation and reminder emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrants who requested CE credit now see the request/payment deadlines in their confirmation and reminder emails — email is where a deadline actually nudges. Shared _ce_deadlines partial keeps the four templates in sync. Co-Authored-By: Claude Opus 4.8 --- app/views/event_mailer/_ce_deadlines.html.erb | 21 ++++++++++ app/views/event_mailer/_ce_deadlines.text.erb | 5 +++ .../event_registration_confirmation.html.erb | 2 + .../event_registration_confirmation.text.erb | 1 + .../event_registration_reminder.html.erb | 2 + .../event_registration_reminder.text.erb | 2 +- spec/mailers/event_mailer_spec.rb | 39 +++++++++++++++++++ test/mailers/previews/event_mailer_preview.rb | 8 +++- 8 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 app/views/event_mailer/_ce_deadlines.html.erb create mode 100644 app/views/event_mailer/_ce_deadlines.text.erb diff --git a/app/views/event_mailer/_ce_deadlines.html.erb b/app/views/event_mailer/_ce_deadlines.html.erb new file mode 100644 index 0000000000..5406040cc4 --- /dev/null +++ b/app/views/event_mailer/_ce_deadlines.html.erb @@ -0,0 +1,21 @@ +<%# CE deadlines for a registrant who requested continuing-education credit, + shown in the confirmation and reminder emails. Mirrors the CE callout's + deadline copy. Locals: event (decorated), registration. The deadlines are + plain dates, so no time zone is applied. %> +<% if registration.ce_credit_requested? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %> +
+

+ <%= event.ce_hours_details_label %> +

+ <% if event.ce_hours_request_deadline %> +

+ Request your CE hours by <%= event.ce_hours_request_deadline.strftime("%B %-d, %Y") %> +

+ <% end %> + <% if event.ce_payment_due_deadline %> +

+ CE payment due by <%= event.ce_payment_due_deadline.strftime("%B %-d, %Y") %> +

+ <% end %> +
+<% end %> diff --git a/app/views/event_mailer/_ce_deadlines.text.erb b/app/views/event_mailer/_ce_deadlines.text.erb new file mode 100644 index 0000000000..d9c52b3060 --- /dev/null +++ b/app/views/event_mailer/_ce_deadlines.text.erb @@ -0,0 +1,5 @@ +<% if registration.ce_credit_requested? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %> +<%= event.ce_hours_details_label %> +<% if event.ce_hours_request_deadline %>Request your CE hours by <%= event.ce_hours_request_deadline.strftime("%B %-d, %Y") %> +<% end %><% if event.ce_payment_due_deadline %>CE payment due by <%= event.ce_payment_due_deadline.strftime("%B %-d, %Y") %> +<% end %><% end %> \ No newline at end of file diff --git a/app/views/event_mailer/event_registration_confirmation.html.erb b/app/views/event_mailer/event_registration_confirmation.html.erb index e06eea9ee2..3622f1c358 100644 --- a/app/views/event_mailer/event_registration_confirmation.html.erb +++ b/app/views/event_mailer/event_registration_confirmation.html.erb @@ -53,6 +53,8 @@ <% end %>
+ <%= render "ce_deadlines", event: @event, registration: @event_registration %> + <% if @event.rhino_description.present? %> Details

diff --git a/app/views/event_mailer/event_registration_confirmation.text.erb b/app/views/event_mailer/event_registration_confirmation.text.erb index 1b0c5bc8cc..82e98af58b 100644 --- a/app/views/event_mailer/event_registration_confirmation.text.erb +++ b/app/views/event_mailer/event_registration_confirmation.text.erb @@ -27,6 +27,7 @@ Videoconference: <%= event_platform_label(@event) %> View your ticket: <%= registration_ticket_url(@event_registration.slug) %> +<%= render "ce_deadlines", event: @event, registration: @event_registration %> <% if @event.registration_close_date %> Registration closes on diff --git a/app/views/event_mailer/event_registration_reminder.html.erb b/app/views/event_mailer/event_registration_reminder.html.erb index 121e44363e..04436b43ea 100644 --- a/app/views/event_mailer/event_registration_reminder.html.erb +++ b/app/views/event_mailer/event_registration_reminder.html.erb @@ -18,6 +18,8 @@ <% end %> <%= render "event_details_card", event: @event, time_zone: @time_zone %> + + <%= render "ce_deadlines", event: @event, registration: @event_registration %> <% if @event_registration.persisted? && @event_registration.slug.present? %> diff --git a/app/views/event_mailer/event_registration_reminder.text.erb b/app/views/event_mailer/event_registration_reminder.text.erb index 8aeb3b1302..41c016f952 100644 --- a/app/views/event_mailer/event_registration_reminder.text.erb +++ b/app/views/event_mailer/event_registration_reminder.text.erb @@ -22,7 +22,7 @@ Location: <%= event_location_label(@event.object) %> <% if @event.labelled_cost.present? %> <%= @event.labelled_cost %> <% end %> - +<%= render "ce_deadlines", event: @event, registration: @event_registration %> <% if @event_registration.slug.present? %> View your ticket for event details, directions, and calendar links: <%= registration_ticket_url(@event_registration.slug) %> diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb index 4f7bbad5ee..edcd12b0bb 100644 --- a/spec/mailers/event_mailer_spec.rb +++ b/spec/mailers/event_mailer_spec.rb @@ -90,6 +90,30 @@ expect(body).not_to include("Meeting ID") end end + + context "when the registrant requested CE credit and the event has deadlines" do + let(:event) do + create(:event, ce_hours_request_deadline: Date.new(2026, 7, 1), + ce_payment_due_deadline: Date.new(2026, 8, 15)) + end + let(:event_registration) { create(:event_registration, event: event, ce_credit_requested: true) } + + it "surfaces both CE deadlines in the body" do + expect(mail.body.encoded).to include("Request your CE hours by") + expect(mail.body.encoded).to include("July 1, 2026") + expect(mail.body.encoded).to include("CE payment due by") + expect(mail.body.encoded).to include("August 15, 2026") + end + end + + context "when the registrant did not request CE credit" do + let(:event) { create(:event, ce_hours_request_deadline: Date.new(2026, 7, 1)) } + let(:event_registration) { create(:event_registration, event: event, ce_credit_requested: false) } + + it "omits the CE deadlines" do + expect(mail.body.encoded).not_to include("Request your CE hours by") + end + end end describe "#bulk_payment_confirmation" do @@ -156,6 +180,21 @@ expect(mail.body.encoded).to include(event_registration.registrant.full_name) end + context "when the registrant requested CE credit and the event has deadlines" do + let(:event) do + create(:event, ce_hours_request_deadline: Date.new(2026, 7, 1), + ce_payment_due_deadline: Date.new(2026, 8, 15)) + end + let(:event_registration) { create(:event_registration, event: event, ce_credit_requested: true) } + + it "surfaces both CE deadlines in the body" do + expect(mail.body.encoded).to include("Request your CE hours by") + expect(mail.body.encoded).to include("July 1, 2026") + expect(mail.body.encoded).to include("CE payment due by") + expect(mail.body.encoded).to include("August 15, 2026") + end + end + context "with a custom subject" do let(:mail) { described_class.event_registration_reminder(event_registration, custom_subject: "Don't forget us tomorrow!") } diff --git a/test/mailers/previews/event_mailer_preview.rb b/test/mailers/previews/event_mailer_preview.rb index ebcda85e09..1e856c5310 100644 --- a/test/mailers/previews/event_mailer_preview.rb +++ b/test/mailers/previews/event_mailer_preview.rb @@ -47,7 +47,13 @@ def sample_event_registration event = Event.first || create_event person = Person.first || create_person - EventRegistration.find_or_create_by!(event: event, registrant: person) + registration = EventRegistration.find_or_create_by!(event: event, registrant: person) + # Showcase the CE deadlines block (set in memory only, not persisted). Set on + # registration.event — the object the mailer decorates and reads. + registration.event.ce_hours_request_deadline ||= 2.weeks.from_now.to_date + registration.event.ce_payment_due_deadline ||= 3.weeks.from_now.to_date + registration.ce_credit_requested = true + registration end def create_event