From f7e57e9a1fadfa17826ea5cf3b6919d256dab420 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 22 Jun 2026 09:56:33 -0400
Subject: [PATCH 01/10] 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 | 19 ++++++++++---
app/views/events/_form.html.erb | 16 +++++++++++
app/views/events/callouts/ce.html.erb | 6 ++++
app/views/events/ce_hours.html.erb | 16 +++++++++++
...260622135205_add_ce_deadlines_to_events.rb | 11 ++++++++
db/schema.rb | 2 ++
spec/requests/events_spec.rb | 20 +++++++++++++
spec/services/magic_ticket_callouts_spec.rb | 28 +++++++++++++++++++
9 files changed, 116 insertions(+), 4 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 f73ccbbbf..4ee664e3d 100644
--- a/app/policies/event_policy.rb
+++ b/app/policies/event_policy.rb
@@ -129,6 +129,8 @@ def google_analytics?
:ce_hours_details_label,
:ce_hours_offered,
:ce_hours_cost,
+ :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 a0bb5f20b..02c377948 100644
--- a/app/services/magic_ticket_callouts.rb
+++ b/app/services/magic_ticket_callouts.rb
@@ -211,14 +211,18 @@ def ce_hours_subtitle
# "$X due" for the outstanding balance once hours + license are on file (no chip
# once paid in full); otherwise an amber chip naming what's still needed, prefixed
# with the fee when the hours (and so the cost) are already known — e.g.
- # "$250 · License number needed".
+ # "$250 · License number needed". Each deadline the event sets is appended to the
+ # relevant chip while still pending: the payment deadline on the amount-due chip
+ # (until paid), the request deadline on the license-needed chip.
def ce_hours_badge(complete)
ce_registration = registration.continuing_education_registrations.first
remaining_cents = ce_registration&.remaining_cost.to_i
if complete
return unless remaining_cents.positive?
- return "#{MoneyFormatter.dollars_from_cents(remaining_cents)} due"
+ amount = MoneyFormatter.dollars_from_cents(remaining_cents)
+ return "#{amount} due" if event.ce_payment_due_deadline.blank?
+ return "#{amount} due by #{ce_deadline_text(event.ce_payment_due_deadline)}"
end
needed = ce_missing_text
@@ -227,9 +231,16 @@ def ce_hours_badge(complete)
end
# Hours are set by the event now, so the only thing a requesting registrant can
- # still be missing is their license number.
+ # still be missing is their license number. When the event sets a request
+ # deadline, name it so the registrant knows when their license is due.
def ce_missing_text
- "License number needed"
+ return "License number needed" if event.ce_hours_request_deadline.blank?
+ "License number needed by #{ce_deadline_text(event.ce_hours_request_deadline)}"
+ end
+
+ # Short month/day for a deadline shown inline on the CE card, e.g. "Jul 1".
+ def ce_deadline_text(deadline)
+ deadline.strftime("%b %-d")
end
# "Art supplies & what to bring" — the event's own details page.
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb
index eee2108d3..fdaf2fde4 100644
--- a/app/views/events/_form.html.erb
+++ b/app/views/events/_form.html.erb
@@ -588,6 +588,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. Wrap a part in Title… to make it a collapsible section.",
content_placeholder: "e.g.
+ <% end %>
<%= render "events/callouts/rich_content", content: @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 000000000..f28248390
--- /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 a8e904901..367f5f508 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -537,6 +537,8 @@
t.text "ce_hours_details"
t.string "ce_hours_details_label", default: "CE hours", null: false
t.decimal "ce_hours_offered", precision: 5, scale: 2
+ 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 efb5c1eaa..4b989cb87 100644
--- a/spec/requests/events_spec.rb
+++ b/spec/requests/events_spec.rb
@@ -226,6 +226,17 @@ def offer_ce!(target_event)
get ce_hours_event_path(event)
expect(response).to redirect_to(event_path(event))
end
+
+ it "surfaces the CE deadlines when set" do
+ event.update!(ce_hours_offered: 6, 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 CE credit 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
end
describe "GET /new" do
@@ -446,6 +457,15 @@ def offer_ce!(target_event)
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 793ea2708..ee24ef2ce 100644
--- a/spec/services/magic_ticket_callouts_spec.rb
+++ b/spec/services/magic_ticket_callouts_spec.rb
@@ -107,6 +107,23 @@ def card(reg, title)
expect(paid.badge).to be_nil
end
+ it "names the CE request deadline on the license-needed badge" do
+ event.update!(ce_hours_cost_cents: 15_000, ce_hours_request_deadline: Date.new(2026, 7, 1))
+ license = create(:professional_license, :placeholder, person: registration.registrant)
+ create(:continuing_education_registration, event_registration: registration, professional_license: license)
+ expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 · License number needed by Jul 1")
+ end
+
+ it "appends the CE payment deadline to the amount-due badge, dropping it once paid" do
+ event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15))
+ license = create(:professional_license, person: registration.registrant, number: "LIC123")
+ ce_reg = create(:continuing_education_registration, event_registration: registration, professional_license: license)
+ expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 due by Aug 15")
+
+ create(:allocation, source: create(:payment), allocatable: ce_reg, amount: ce_reg.cost_cents)
+ expect(card(registration.reload, event.ce_hours_details_label).badge).to be_nil
+ 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)
@@ -204,5 +221,16 @@ def card(reg, title)
# Certificate isn't unlocked (event not ended, not attended).
expect(described_class.new(registration).card_for(callout)).to be_nil
end
+
+ it "keeps the live CE deadline badge on a materialized CE row" do
+ event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15))
+ license = create(:professional_license, person: registration.registrant, number: "LIC123")
+ create(:continuing_education_registration, event_registration: registration, professional_license: license)
+ callout = create(:registration_ticket_callout, event:, magic_key: "ce_hours", title: "CE credit")
+
+ card = described_class.new(registration.reload).card_for(callout)
+ expect(card.title).to eq("CE credit") # row owns the text
+ expect(card.badge).to eq("$150 due by Aug 15") # app keeps the live deadline badge
+ end
end
end
From 6225157f5a840d6042b9b60146fd823983232ccb Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 22 Jun 2026 10:04:53 -0400
Subject: [PATCH 02/10] 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 | 22 ++++++++++
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 | 41 +++++++++++++++++++
test/mailers/previews/event_mailer_preview.rb | 9 +++-
8 files changed, 82 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 000000000..015e52364
--- /dev/null
+++ b/app/views/event_mailer/_ce_deadlines.html.erb
@@ -0,0 +1,22 @@
+<%# CE deadlines for a CE-eligible event, shown in the confirmation and reminder
+ emails so registrants are nudged before the request/payment cutoffs — gated on
+ the event offering CE, not on this registrant having requested it yet (the
+ confirmation fires before anyone opts in). Locals: event (decorated). The
+ deadlines are plain dates, so no time zone is applied. %>
+<% if event.ce_eligible? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %>
+
+
+ <%= event.ce_hours_details_label %>
+
+ <% if event.ce_hours_request_deadline %>
+
+ Request CE credit 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 000000000..755ff4fc2
--- /dev/null
+++ b/app/views/event_mailer/_ce_deadlines.text.erb
@@ -0,0 +1,5 @@
+<% if event.ce_eligible? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %>
+<%= event.ce_hours_details_label %>
+<% if event.ce_hours_request_deadline %>Request CE credit 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 e06eea9ee..e1be1a0ea 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 %>
+
<% 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 1b0c5bc8c..0cdf4aab6 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 %>
<% 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 121e44363..42d78a0e7 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 %>
<% 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 8aeb3b130..913170528 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 %>
<% 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 4f7bbad5e..d9d4bfdd7 100644
--- a/spec/mailers/event_mailer_spec.rb
+++ b/spec/mailers/event_mailer_spec.rb
@@ -90,6 +90,31 @@
expect(body).not_to include("Meeting ID")
end
end
+
+ context "when the event is CE-eligible and has deadlines" do
+ let(:event) do
+ create(:event, ce_hours_offered: 6,
+ 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) }
+
+ it "surfaces both CE deadlines in the body" do
+ expect(mail.body.encoded).to include("Request CE credit 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 event is not CE-eligible" do
+ let(:event) { create(:event, ce_hours_offered: nil, ce_hours_request_deadline: Date.new(2026, 7, 1)) }
+ let(:event_registration) { create(:event_registration, event: event) }
+
+ it "omits the CE deadlines" do
+ expect(mail.body.encoded).not_to include("Request CE credit by")
+ end
+ end
end
describe "#bulk_payment_confirmation" do
@@ -156,6 +181,22 @@
expect(mail.body.encoded).to include(event_registration.registrant.full_name)
end
+ context "when the event is CE-eligible and has deadlines" do
+ let(:event) do
+ create(:event, ce_hours_offered: 6,
+ 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) }
+
+ it "surfaces both CE deadlines in the body" do
+ expect(mail.body.encoded).to include("Request CE credit 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 ebcda85e0..065b3f072 100644
--- a/test/mailers/previews/event_mailer_preview.rb
+++ b/test/mailers/previews/event_mailer_preview.rb
@@ -47,7 +47,14 @@ 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). The email
+ # gates on the event being CE-eligible, so give it offered hours + deadlines. Set
+ # on registration.event — the object the mailer decorates and reads.
+ registration.event.ce_hours_offered ||= 6
+ 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
end
def create_event
From 73e9c15f3a7dd4fb9a9c74bc5a4dfbff4494de7b Mon Sep 17 00:00:00 2001
From: maebeale
Date: Tue, 14 Jul 2026 06:57:44 -0400
Subject: [PATCH 03/10] Move CE deadlines into the CE settings box, as labeled
fields
The deadlines belonged with hours-offered/cost, not buried in the ticket
callout card. Extract a shared _ce_config_fields partial rendering all
four as label-above-input cells so they read as distinct fields.
Co-Authored-By: Claude Opus 4.8
---
app/views/events/_ce_config_fields.html.erb | 32 +++++++++++++++++++
app/views/events/_form.html.erb | 34 +++------------------
spec/requests/events_spec.rb | 8 +++++
3 files changed, 44 insertions(+), 30 deletions(-)
create mode 100644 app/views/events/_ce_config_fields.html.erb
diff --git a/app/views/events/_ce_config_fields.html.erb b/app/views/events/_ce_config_fields.html.erb
new file mode 100644
index 000000000..d14d09384
--- /dev/null
+++ b/app/views/events/_ce_config_fields.html.erb
@@ -0,0 +1,32 @@
+<%# The CE settings an admin edits once continuing education is enabled: hours
+ offered, total cost, and the two optional deadlines. Rendered inside each
+ "Enable continuing education" box (single-form and multi-form variants).
+ Each field is labelled above its input so they read as distinct fields.
+ locals: f (event form builder). %>
+
Edit the CE hours ticket card and its details below, under Registration ticket callouts.
<% if @event.continuing_education_form.present? %>
@@ -495,13 +490,8 @@
<% end %>
Select a continuing education form for registrants to request CE hours.
-
-
-
-
+
+ <%= render "events/ce_config_fields", f: f %>
<% end %>
@@ -588,22 +578,6 @@
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. Wrap a part in Title… to make it a collapsible section.",
content_placeholder: "e.g.
<% when :event_details %>
<%= render "events/builtin_callout_card", f: f, card: card,
label_field: :event_details_label, content_field: :event_details,
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb
index 4b989cb87..7c6a2e6b5 100644
--- a/spec/requests/events_spec.rb
+++ b/spec/requests/events_spec.rb
@@ -298,6 +298,14 @@ def offer_ce!(target_event)
get edit_event_path(event)
expect(response.body).to include("Frequently asked questions")
end
+
+ it "renders the CE deadline fields in the continuing education settings" do
+ create(:form, :standalone, role: "continuing_education", name: "CE")
+ get edit_event_path(event)
+ expect(response.body).to include('name="event[ce_hours_request_deadline]"')
+ expect(response.body).to include('name="event[ce_payment_due_deadline]"')
+ expect(response.body).to include("Request CE credit by")
+ end
end
describe "POST /create" do
From 8a704e9063b687f3e5de6c86cedf19c0f6214229 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Tue, 14 Jul 2026 06:57:44 -0400
Subject: [PATCH 04/10] Reword the callout colour-override hint in plainer
terms
Co-Authored-By: Claude Opus 4.8
---
app/views/events/_registration_ticket_callout_fields.html.erb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb
index 3c1b3fe38..9743f4580 100644
--- a/app/views/events/_registration_ticket_callout_fields.html.erb
+++ b/app/views/events/_registration_ticket_callout_fields.html.erb
@@ -133,7 +133,7 @@
data: { callout_preview_target: "colorSelect", action: "callout-preview#update" } %>
<% if f.object.app_colored? %>
-
The app overrides this with a live-status color when the registrant has action to take (e.g. amber while something's outstanding, orange while a balance is due).
+
This callout will be a different color when the registrant has action to take (e.g. amber if something's outstanding, orange if a balance is due).
<% end %>
From e3c7ae6dd4ba1027f282849975ad813296ffa9ce Mon Sep 17 00:00:00 2001
From: maebeale
Date: Tue, 14 Jul 2026 07:06:10 -0400
Subject: [PATCH 05/10] Polish the CE settings box: teal fill, white inputs,
clickable label
- Tint the CE settings box teal (its domain colour) instead of grey, with
white inputs so each field reads clearly.
- Make the "Enable continuing education" text a real label for the checkbox.
- Match the deadline date inputs to the event start/end date input style.
Co-Authored-By: Claude Opus 4.8
---
app/views/events/_ce_config_fields.html.erb | 15 ++++++++-------
app/views/events/_form.html.erb | 7 ++++---
2 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/app/views/events/_ce_config_fields.html.erb b/app/views/events/_ce_config_fields.html.erb
index d14d09384..651da1c26 100644
--- a/app/views/events/_ce_config_fields.html.erb
+++ b/app/views/events/_ce_config_fields.html.erb
@@ -1,32 +1,33 @@
<%# The CE settings an admin edits once continuing education is enabled: hours
offered, total cost, and the two optional deadlines. Rendered inside each
"Enable continuing education" box (single-form and multi-form variants).
- Each field is labelled above its input so they read as distinct fields.
- locals: f (event form builder). %>
+ Each field is labelled above its own white input so they read as distinct
+ fields; the date inputs match the event start/end date style. locals: f
+ (event form builder). %>
From b0867b46966593de4cde44722642987741a52fed Mon Sep 17 00:00:00 2001
From: maebeale
Date: Tue, 14 Jul 2026 07:17:10 -0400
Subject: [PATCH 07/10] Point CE seed copy to email or the Portal contact us
form
Co-Authored-By: Claude Opus 4.8
---
db/seeds/dev/events_management.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb
index fa9c2a4c2..81c717f19 100644
--- a/db/seeds/dev/events_management.rb
+++ b/db/seeds/dev/events_management.rb
@@ -408,7 +408,7 @@
Complete the post-training evaluation within one week.
Your CE certificate will be emailed within three weeks of the training.
-
Questions about continuing education? Reach out and our team will follow up with details.
+
Questions about continuing education? Reach out by email or Portal contact us form, and our team will follow up with details.
HTML
end
From abd769eaa4fc8da2be2913546b889fa686a20539 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Tue, 14 Jul 2026 07:23:07 -0400
Subject: [PATCH 08/10] Surface CE deadlines on the public registration CE
section
Registrants filling out the form see the request/payment cutoffs right
by the CE questions.
Co-Authored-By: Claude Opus 4.8
---
.../events/public_registrations/new.html.erb | 16 ++++++++++++++++
.../requests/events/public_registrations_spec.rb | 13 +++++++++++++
2 files changed, 29 insertions(+)
diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb
index d16835d35..719e2f3ed 100644
--- a/app/views/events/public_registrations/new.html.erb
+++ b/app/views/events/public_registrations/new.html.erb
@@ -162,6 +162,22 @@
<%= @continuing_education_form.display_name %>
<%= render "forms/header", form: @continuing_education_form, event: @event %>
+ <% if @event.ce_hours_request_deadline || @event.ce_payment_due_deadline %>
+
<% @continuing_education_form.form_fields.reorder(position: :asc).each do |field| %>
<% if field.group_header? %>
diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb
index b07b0aca3..82a4c5962 100644
--- a/spec/requests/events/public_registrations_spec.rb
+++ b/spec/requests/events/public_registrations_spec.rb
@@ -250,6 +250,19 @@ def post_with_scholarship(scholarship_answer)
expect(response.body).to include("Minimum of 5 words.")
end
+ it "surfaces the CE deadlines on the continuing education section" do
+ ce = FormBuilderService.new(name: "CE", sections: %i[continuing_education], role: "continuing_education").call
+ event.event_forms.create!(form: ce, role: "continuing_education")
+ event.update!(ce_hours_request_deadline: Date.new(2026, 7, 1), ce_payment_due_deadline: Date.new(2026, 8, 15))
+
+ get new_event_public_registration_path(event)
+
+ expect(response.body).to include("Request CE credit 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 "renders a structured details panel from known event data when enabled" do
pacific = ActiveSupport::TimeZone["Pacific Time (US & Canada)"]
event.update!(
From ed0c65a34da5229f284178292c89dfd6278bbaa4 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Tue, 14 Jul 2026 07:28:23 -0400
Subject: [PATCH 09/10] Capture license type on the public CE registration form
The public form only asked for a license number; the ticket callout
already captures both type and number. Add a license-type question to
the continuing_education section and thread its answer into the
ProfessionalLicense kind so both are recorded at signup.
Co-Authored-By: Claude Opus 4.8
---
app/models/professional_license.rb | 11 ++++++-----
.../public_registration.rb | 12 +++++++++++-
app/services/form_builder_service.rb | 9 +++++++--
spec/models/professional_license_spec.rb | 7 +++++++
.../public_registration_spec.rb | 10 +++++++++-
5 files changed, 40 insertions(+), 9 deletions(-)
diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb
index 59971b92e..093e10580 100644
--- a/app/models/professional_license.rb
+++ b/app/models/professional_license.rb
@@ -17,11 +17,12 @@ class ProfessionalLicense < ApplicationRecord
# different kinds is allowed; only a duplicate (kind, number) pair is rejected.
validates :number, uniqueness: { scope: [ :person_id, :kind ] }, allow_nil: true
- # Find the person's license for this number, or create it. A blank number
- # resolves to the person's single placeholder license (number nil) so a CE
- # opt-in without a number on file never spawns duplicate placeholders.
- def self.find_or_create_for(person:, number: nil)
- find_or_create_by(person: person, number: number.presence)
+ # Find the person's license for this type + number, or create it. Licenses are
+ # identified by (kind, number), so an opt-in without either on file resolves to
+ # the person's single placeholder license (both nil) rather than spawning
+ # duplicate placeholders.
+ def self.find_or_create_for(person:, number: nil, kind: nil)
+ find_or_create_by(person: person, number: number.presence, kind: kind.presence)
end
# Completeness: have we recorded the actual license number yet?
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index a644da14b..7ba56d6db 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -11,6 +11,10 @@ class PublicRegistration
# seeds the registrant's ProfessionalLicense.
CE_LICENSE_NUMBER_IDENTIFIER = "ce_license_number".freeze
+ # Well-known field_identifier of the CE license-type question (e.g. "LCSW").
+ # Its answer seeds the ProfessionalLicense's kind.
+ CE_LICENSE_KIND_IDENTIFIER = "ce_license_kind".freeze
+
# Well-known field_identifier of the "Additional forms" multi-select question.
# Checking "Invoice" / "W-9" toggles the registration's invoice_requested /
# w9_requested flags, which the digital ticket reads to surface those downloads.
@@ -412,7 +416,7 @@ def create_ce_registration(event_registration, person)
return unless ce_credit_requested?
return if event_registration.continuing_education_registrations.exists?
- license = ProfessionalLicense.find_or_create_for(person: person, number: ce_license_number)
+ license = ProfessionalLicense.find_or_create_for(person: person, number: ce_license_number, kind: ce_license_kind)
event_registration.continuing_education_registrations.create!(professional_license: license)
end
@@ -428,6 +432,12 @@ def ce_license_number
ce_field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence
end
+ def ce_license_kind
+ return nil unless @continuing_education_form
+
+ ce_field_value(CE_LICENSE_KIND_IDENTIFIER)&.strip.presence
+ end
+
def ce_field_value(key)
field = @continuing_education_form.form_fields.find_by(field_identifier: key)
return nil unless field
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index dd5e727c1..6434557e8 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -97,7 +97,8 @@ def call
],
continuing_education: [
"Do you seek Continuing Education (CE) hours for this training?",
- "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?"
+ "If seeking CE hours, what is your license type? (e.g. LMFT, LCSW, LPCC, LEP)",
+ "What is your license number?"
],
scholarship: [
"I and/or my organization cannot afford the full training cost and need a scholarship to attend.",
@@ -515,7 +516,11 @@ def build_continuing_education_fields(form, position)
key: "ce_credit_interest", group: "continuing_education", required: false,
options: %w[Yes No])
position = add_field(form, position,
- "If seeking CE hours, what is your LMFT, LCSW, LPCC or LEP license number?",
+ "If seeking CE hours, what is your license type? (e.g. LMFT, LCSW, LPCC, LEP)",
+ :free_form_input_one_line,
+ key: "ce_license_kind", group: "continuing_education", required: false)
+ position = add_field(form, position,
+ "What is your license number?",
:free_form_input_one_line,
key: "ce_license_number", group: "continuing_education", required: false,
subtitle: "Acceptance of continuing education hours is determined by each individual state board separately, " \
diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb
index ad43d763b..cf979e787 100644
--- a/spec/models/professional_license_spec.rb
+++ b/spec/models/professional_license_spec.rb
@@ -27,6 +27,13 @@
expect(first.number).to be_nil
expect(person.professional_licenses.count).to eq(1)
end
+
+ it "records the license type when given" do
+ license = described_class.find_or_create_for(person: person, number: "ABC-1", kind: "LCSW")
+
+ expect(license.kind).to eq("LCSW")
+ expect(license.number).to eq("ABC-1")
+ end
end
describe "#number_known?" do
diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb
index 4a75db2ab..c6f4e87df 100644
--- a/spec/services/event_registration_services/public_registration_spec.rb
+++ b/spec/services/event_registration_services/public_registration_spec.rb
@@ -542,11 +542,12 @@ def ce_field_id(key)
ce_form.form_fields.find_by!(field_identifier: key).id.to_s
end
- def register_with_ce(answer, license: nil)
+ def register_with_ce(answer, license: nil, kind: nil)
params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com")
ce_params = {}
ce_params[ce_field_id(described_class::CE_CREDIT_INTEREST_IDENTIFIER)] = answer unless answer.nil?
ce_params[ce_field_id(described_class::CE_LICENSE_NUMBER_IDENTIFIER)] = license if license
+ ce_params[ce_field_id(described_class::CE_LICENSE_KIND_IDENTIFIER)] = kind if kind
described_class.call(event: event, registration_form: form, form_params: params,
continuing_education_form: ce_form, continuing_education_params: ce_params)
end
@@ -595,6 +596,13 @@ def register_with_ce(answer, license: nil)
expect(license.person).to eq(result.event_registration.registrant)
end
+ it "records the typed license type alongside the number" do
+ result = register_with_ce("Yes", license: "555", kind: "LCSW")
+ license = result.event_registration.continuing_education_registrations.first.professional_license
+ expect(license.kind).to eq("LCSW")
+ expect(license.number).to eq("555")
+ end
+
it "uses a placeholder license when no number is given" do
result = register_with_ce("Yes")
expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil
From c4d8867ac5d842cd6472151b883c30b301e709c7 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Tue, 14 Jul 2026 22:57:06 -0400
Subject: [PATCH 10/10] Add license state + expiry to the public CE form;
require CE interest
- Capture issuing state (US-states dropdown via the _state suffix) and
expiry (date input) on the continuing_education section, threaded into
the ProfessionalLicense.
- Note on the form that license details are optional and can be added
later from the ticket.
- Make the CE interest question required and always persist its answer
(Yes or No) as a form answer, not just on Yes.
Co-Authored-By: Claude Opus 4.8
---
.../public_registration.rb | 22 ++++++++++++++++++-
app/services/form_builder_service.rb | 19 +++++++++++++---
.../public_registrations/_form_field.html.erb | 12 ++++++----
.../events/public_registrations_spec.rb | 13 +++++++++++
.../public_registration_spec.rb | 22 ++++++++++++++++---
...ublic_registration_form_submission_spec.rb | 3 +++
6 files changed, 80 insertions(+), 11 deletions(-)
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index 7ba56d6db..b09e250e6 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -15,6 +15,12 @@ class PublicRegistration
# Its answer seeds the ProfessionalLicense's kind.
CE_LICENSE_KIND_IDENTIFIER = "ce_license_kind".freeze
+ # Well-known field_identifiers of the CE license issuing-state and expiry
+ # questions. Their answers fill in the ProfessionalLicense's issuing_state
+ # and expires_on (both optional — registrants can supply them later).
+ CE_LICENSE_ISSUING_STATE_IDENTIFIER = "ce_license_issuing_state".freeze
+ CE_LICENSE_EXPIRES_ON_IDENTIFIER = "ce_license_expires_on".freeze
+
# Well-known field_identifier of the "Additional forms" multi-select question.
# Checking "Invoice" / "W-9" toggles the registration's invoice_requested /
# w9_requested flags, which the digital ticket reads to surface those downloads.
@@ -417,6 +423,9 @@ def create_ce_registration(event_registration, person)
return if event_registration.continuing_education_registrations.exists?
license = ProfessionalLicense.find_or_create_for(person: person, number: ce_license_number, kind: ce_license_kind)
+ if ce_license_issuing_state || ce_license_expires_on
+ license.update!(issuing_state: ce_license_issuing_state, expires_on: ce_license_expires_on)
+ end
event_registration.continuing_education_registrations.create!(professional_license: license)
end
@@ -438,6 +447,18 @@ def ce_license_kind
ce_field_value(CE_LICENSE_KIND_IDENTIFIER)&.strip.presence
end
+ def ce_license_issuing_state
+ return nil unless @continuing_education_form
+
+ ce_field_value(CE_LICENSE_ISSUING_STATE_IDENTIFIER)&.strip.presence
+ end
+
+ def ce_license_expires_on
+ return nil unless @continuing_education_form
+
+ ce_field_value(CE_LICENSE_EXPIRES_ON_IDENTIFIER).presence
+ end
+
def ce_field_value(key)
field = @continuing_education_form.form_fields.find_by(field_identifier: key)
return nil unless field
@@ -524,7 +545,6 @@ def save_scholarship_submission(person)
def save_continuing_education_submission(person)
return unless @continuing_education_form && @continuing_education_params.present?
- return unless ce_credit_requested?
submission = FormSubmission.find_or_create_by!(
person: person, form: @continuing_education_form, role: "continuing_education", event: @event
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index 6434557e8..ee342c7cb 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -98,7 +98,9 @@ def call
continuing_education: [
"Do you seek Continuing Education (CE) hours for this training?",
"If seeking CE hours, what is your license type? (e.g. LMFT, LCSW, LPCC, LEP)",
- "What is your license number?"
+ "What is your license number?",
+ "In which state was your license issued?",
+ "When does your license expire?"
],
scholarship: [
"I and/or my organization cannot afford the full training cost and need a scholarship to attend.",
@@ -513,12 +515,14 @@ def build_continuing_education_fields(form, position)
position = add_field(form, position,
"Do you seek Continuing Education (CE) hours for this training?",
:single_select_radio,
- key: "ce_credit_interest", group: "continuing_education", required: false,
+ key: "ce_credit_interest", group: "continuing_education", required: true,
options: %w[Yes No])
position = add_field(form, position,
"If seeking CE hours, what is your license type? (e.g. LMFT, LCSW, LPCC, LEP)",
:free_form_input_one_line,
- key: "ce_license_kind", group: "continuing_education", required: false)
+ key: "ce_license_kind", group: "continuing_education", required: false,
+ subtitle: "These license details are optional here — you can add or update them later " \
+ "from your registration ticket.")
position = add_field(form, position,
"What is your license number?",
:free_form_input_one_line,
@@ -527,6 +531,15 @@ def build_continuing_education_fields(form, position)
"and AWBW cannot guarantee your specific state board will accept them. " \
"Participants are responsible for confirming whether the hours meet the requirements " \
"for their specific license and state.")
+ position = add_field(form, position,
+ "In which state was your license issued?",
+ :free_form_input_one_line,
+ key: "ce_license_issuing_state", group: "continuing_education", required: false)
+ position = add_field(form, position,
+ "When does your license expire?",
+ :free_form_input_one_line,
+ key: "ce_license_expires_on", group: "continuing_education", required: false,
+ datatype: :date)
position
end
diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb
index 9605cfe1c..e70fb2295 100644
--- a/app/views/events/public_registrations/_form_field.html.erb
+++ b/app/views/events/public_registrations/_form_field.html.erb
@@ -45,11 +45,15 @@
<% else %>
<%
- html_type = case field.field_identifier
- when /email(?!_type)/ then "email"
- when "phone" then "tel"
+ html_type = if field.date?
+ "date"
else
- field.number_integer? ? "number" : "text"
+ case field.field_identifier
+ when /email(?!_type)/ then "email"
+ when "phone" then "tel"
+ else
+ field.number_integer? ? "number" : "text"
+ end
end
extra_attrs = []
extra_attrs << 'min="1" step="1" pattern="[0-9]*" inputmode="numeric"' if field.number_integer?
diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb
index 82a4c5962..85514692c 100644
--- a/spec/requests/events/public_registrations_spec.rb
+++ b/spec/requests/events/public_registrations_spec.rb
@@ -263,6 +263,19 @@ def post_with_scholarship(scholarship_answer)
expect(response.body).to include("August 15, 2026")
end
+ it "renders the CE license type, issuing-state dropdown, expiry date, and provide-later note" do
+ ce = FormBuilderService.new(name: "CE", sections: %i[continuing_education], role: "continuing_education").call
+ event.event_forms.create!(form: ce, role: "continuing_education")
+
+ get new_event_public_registration_path(event)
+
+ expect(response.body).to include("what is your license type?")
+ expect(response.body).to include("In which state was your license issued?")
+ expect(response.body).to include("Select a state") # issuing state renders as the US-states dropdown
+ expect(response.body).to include('type="date"') # expiry renders as a date input
+ expect(response.body).to include("add or update them later")
+ end
+
it "renders a structured details panel from known event data when enabled" do
pacific = ActiveSupport::TimeZone["Pacific Time (US & Canada)"]
event.update!(
diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb
index c6f4e87df..24bec6c87 100644
--- a/spec/services/event_registration_services/public_registration_spec.rb
+++ b/spec/services/event_registration_services/public_registration_spec.rb
@@ -542,16 +542,23 @@ def ce_field_id(key)
ce_form.form_fields.find_by!(field_identifier: key).id.to_s
end
- def register_with_ce(answer, license: nil, kind: nil)
+ def register_with_ce(answer, license: nil, kind: nil, issuing_state: nil, expires_on: nil)
params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com")
ce_params = {}
ce_params[ce_field_id(described_class::CE_CREDIT_INTEREST_IDENTIFIER)] = answer unless answer.nil?
ce_params[ce_field_id(described_class::CE_LICENSE_NUMBER_IDENTIFIER)] = license if license
ce_params[ce_field_id(described_class::CE_LICENSE_KIND_IDENTIFIER)] = kind if kind
+ ce_params[ce_field_id(described_class::CE_LICENSE_ISSUING_STATE_IDENTIFIER)] = issuing_state if issuing_state
+ ce_params[ce_field_id(described_class::CE_LICENSE_EXPIRES_ON_IDENTIFIER)] = expires_on if expires_on
described_class.call(event: event, registration_form: form, form_params: params,
continuing_education_form: ce_form, continuing_education_params: ce_params)
end
+ it "marks the CE interest question as required" do
+ field = ce_form.form_fields.find_by!(field_identifier: described_class::CE_CREDIT_INTEREST_IDENTIFIER)
+ expect(field.required).to be(true)
+ end
+
it "creates a CE registration when answered Yes" do
result = register_with_ce("Yes")
expect(result.event_registration.continuing_education_registrations.count).to eq(1)
@@ -603,6 +610,13 @@ def register_with_ce(answer, license: nil, kind: nil)
expect(license.number).to eq("555")
end
+ it "records the license issuing state and expiry when given" do
+ result = register_with_ce("Yes", license: "555", issuing_state: "CA", expires_on: "2027-05-31")
+ license = result.event_registration.continuing_education_registrations.first.professional_license
+ expect(license.issuing_state).to eq("CA")
+ expect(license.expires_on).to eq(Date.new(2027, 5, 31))
+ end
+
it "uses a placeholder license when no number is given" do
result = register_with_ce("Yes")
expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil
@@ -623,9 +637,11 @@ def register_with_ce(answer, license: nil, kind: nil)
expect(answers[described_class::CE_LICENSE_NUMBER_IDENTIFIER]).to eq("LMFT 555")
end
- it "does not persist a continuing_education submission when answered No" do
+ it "persists the CE interest answer even when answered No" do
register_with_ce("No")
- expect(FormSubmission.find_by(role: "continuing_education", event: event)).to be_nil
+ submission = FormSubmission.find_by(role: "continuing_education", event: event)
+ expect(submission).to be_present
+ expect(submission.answers_by_identifier[described_class::CE_CREDIT_INTEREST_IDENTIFIER]).to eq("No")
end
end
diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb
index 85b66cb54..381f0e2be 100644
--- a/spec/system/public_registration_form_submission_spec.rb
+++ b/spec/system/public_registration_form_submission_spec.rb
@@ -144,6 +144,7 @@
choose_pr_radio reg_field("racial_ethnic_identity"), "Asian"
choose_pr_radio reg_field("referral_source"), "Social Media"
choose_pr_radio reg_field("payment_method"), "Check"
+ choose_pr_radio ce_field("ce_credit_interest"), "No"
check_pr_box reg_field("communication_consent"), "Yes"
expect { click_button "Register" }.not_to change(Person, :count)
@@ -174,6 +175,7 @@
fill_full_registration
choose_pr_radio reg_field("payment_method"), "Check"
+ choose_pr_radio ce_field("ce_credit_interest"), "No"
choose_pr_radio schol_field("scholarship_eligibility"), "Yes"
fill_pr_text schol_field("scholarship_contribution"), with: "Our agency can pay $200."
@@ -205,6 +207,7 @@
visit new_event_public_registration_path(event, scholarship_requested: true)
choose_pr_radio reg_field("payment_method"), "Check"
+ choose_pr_radio ce_field("ce_credit_interest"), "No"
check_pr_box reg_field("communication_consent"), "Yes"
choose_pr_radio schol_field("scholarship_eligibility"), "Yes"