From cd988cdff9214f9122f06188a94d50100764a538 Mon Sep 17 00:00:00 2001 From: arielsalcepk Date: Wed, 15 Jul 2026 14:46:06 -0400 Subject: [PATCH 1/6] Email volunteers when their reimbursement request is marked complete - Part of issue #7041 --- app/mailers/volunteer_mailer.rb | 7 ++++ .../reimbursement_complete_notifier.rb | 16 +++++---- .../reimbursement_complete_email.html.erb | 29 +++++++++++++++ lib/mailers/debug_preview_mailer.rb | 13 +++++++ .../previews/volunteer_mailer_preview.rb | 14 ++++++++ .../previews/volunteer_mailer_preview_spec.rb | 35 ++++++++++++++++++ spec/mailers/volunteer_mailer_spec.rb | 20 +++++++++++ .../reimbursement_complete_notifier_spec.rb | 36 +++++++++++++++++++ 8 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 app/views/volunteer_mailer/reimbursement_complete_email.html.erb diff --git a/app/mailers/volunteer_mailer.rb b/app/mailers/volunteer_mailer.rb index e56d145160..2dc3a89ed6 100644 --- a/app/mailers/volunteer_mailer.rb +++ b/app/mailers/volunteer_mailer.rb @@ -18,4 +18,11 @@ def case_contacts_reminder(user, cc_recipients) @casa_organization = user.casa_org mail(to: @user.email, cc: cc_recipients, subject: "Reminder to input case contacts") end + + def reimbursement_complete_email(user, case_contact) + @user = user + @case_contact = case_contact + @casa_organization = @user.casa_org + mail(to: @user.email, subject: "Your reimbursement request has been processed") + end end diff --git a/app/notifications/reimbursement_complete_notifier.rb b/app/notifications/reimbursement_complete_notifier.rb index ce94258e66..b390dda8c5 100644 --- a/app/notifications/reimbursement_complete_notifier.rb +++ b/app/notifications/reimbursement_complete_notifier.rb @@ -3,12 +3,16 @@ # ReimbursementCompleteNotifier.with(case_contact: @case_contact).deliver(current_user) # class ReimbursementCompleteNotifier < BaseNotifier - # deliver_by :email do |config| - # config.mailer = "UserMailer" - # ... - # end - # deliver_by :slack - # deliver_by :custom, class: "MyDeliveryMethod" + # This notifier is delivered to both the volunteer who submitted the + # reimbursement and their supervisor (see ReimbursementsController#change_complete_status). + # The email delivery is scoped to volunteer recipients only, since the + # supervisor already knows they just marked it complete. + deliver_by :email do |config| + config.mailer = "VolunteerMailer" + config.method = "reimbursement_complete_email" + config.args = -> { [recipient, params[:case_contact]] } + config.if = -> { recipient.volunteer? && recipient.receive_email_notifications? } + end required_param :case_contact diff --git a/app/views/volunteer_mailer/reimbursement_complete_email.html.erb b/app/views/volunteer_mailer/reimbursement_complete_email.html.erb new file mode 100644 index 0000000000..73cfd602a6 --- /dev/null +++ b/app/views/volunteer_mailer/reimbursement_complete_email.html.erb @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + +
+ Hello <%= @user.display_name %>, +
+ Your reimbursement request for <%= @case_contact.miles_driven %>mi + <% if @case_contact.reimbursement_amount %> + (<%= number_to_currency(@case_contact.reimbursement_amount) %>) + <% end %> + on <%= @case_contact.occurred_at_display %> has been processed and is en route. +
+ You can view your case contacts and their reimbursement status by visiting + " target="_blank">this link. +
+ If you have any questions, please contact your most recent CASA supervisor for assistance. +
\ No newline at end of file diff --git a/lib/mailers/debug_preview_mailer.rb b/lib/mailers/debug_preview_mailer.rb index aa3948d63d..6812042a63 100644 --- a/lib/mailers/debug_preview_mailer.rb +++ b/lib/mailers/debug_preview_mailer.rb @@ -7,4 +7,17 @@ def invalid_user(role) body: "User does not exist or is not a #{role}" ) end + + # Use when the looked-up user is valid but has no related record to preview + # # with, e.g. a volunteer with no case contact wanting reimbursement. Keeps + # # the "missing_#{role}@example.com" convention so existing preview specs + # # asserting on that address don't need to change. + def no_data(role) + mail( + from: "reply@example.com", + to: "missing_#{role}@example.com", + subject: "no_preview_data", + body: "No #{role} record was found to preview" + ) + end end diff --git a/lib/mailers/previews/volunteer_mailer_preview.rb b/lib/mailers/previews/volunteer_mailer_preview.rb index 2bbe95639a..fe30dd0540 100644 --- a/lib/mailers/previews/volunteer_mailer_preview.rb +++ b/lib/mailers/previews/volunteer_mailer_preview.rb @@ -28,5 +28,19 @@ def case_contacts_reminder VolunteerMailer.court_report_reminder(volunteer, true) end end + + def reimbursement_complete_email + volunteer = params.has_key?(:id) ? Volunteer.find_by(id: params[:id]) : Volunteer.last + if volunteer.nil? + DebugPreviewMailer.invalid_user("volunteer") + else + case_contact = CaseContact.where(creator: volunteer, want_driving_reimbursement: true).last + if case_contact.nil? + DebugPreviewMailer.no_data("case_contact") + else + VolunteerMailer.reimbursement_complete_email(volunteer, case_contact) + end + end + end end # :nocov: diff --git a/spec/mailers/previews/volunteer_mailer_preview_spec.rb b/spec/mailers/previews/volunteer_mailer_preview_spec.rb index 7b9c950987..3ae79f25bc 100644 --- a/spec/mailers/previews/volunteer_mailer_preview_spec.rb +++ b/spec/mailers/previews/volunteer_mailer_preview_spec.rb @@ -72,4 +72,39 @@ it { expect(email.to).to eq ["missing_volunteer@example.com"] } end end + + describe "#reimbursement_complete_email" do + let!(:case_contact) { create(:case_contact, :wants_reimbursement, creator: volunteer) } + + context "When no ID is passed" do + let(:preview) { described_class.new } + let(:email) { preview.reimbursement_complete_email } + + it { expect(email.to).to eq [volunteer.email] } + end + + context "When passed ID is valid" do + let(:preview) { described_class.new(id: volunteer.id) } + let(:email) { preview.reimbursement_complete_email } + + it { expect(email.to).to eq [volunteer.email] } + end + + context "When passed ID is invalid" do + let(:preview) { described_class.new(id: -1) } + let(:email) { preview.reimbursement_complete_email } + + it { expect(email.to).to eq ["missing_volunteer@example.com"] } + end + + context "When the volunteer has no case contact wanting reimbursement" do + let(:other_volunteer) { create(:volunteer) } + let(:preview) { described_class.new(id: other_volunteer.id) } + let(:email) { preview.reimbursement_complete_email } + + it "does not fall back to a different volunteer's case contact" do + expect(email.to).to eq ["missing_case_contact@example.com"] + end + end + end end diff --git a/spec/mailers/volunteer_mailer_spec.rb b/spec/mailers/volunteer_mailer_spec.rb index 0ae5571326..54025179be 100644 --- a/spec/mailers/volunteer_mailer_spec.rb +++ b/spec/mailers/volunteer_mailer_spec.rb @@ -41,6 +41,26 @@ end end + describe ".reimbursement_complete_email" do + let(:case_contact) { create(:case_contact, :wants_reimbursement, creator: volunteer) } + let(:mail) { VolunteerMailer.reimbursement_complete_email(volunteer, case_contact) } + + it "sends an email confirming the reimbursement was processed" do + expect(mail.to).to eq([volunteer.email]) + expect(mail.subject).to eq("Your reimbursement request has been processed") + expect(mail.body.encoded).to match("#{case_contact.miles_driven}mi") + expect(mail.body.encoded).to match(case_contact.occurred_at_display) + end + + context "when the casa org has a mileage rate" do + let!(:mileage_rate) { create(:mileage_rate, casa_org: case_contact.casa_case.casa_org, amount: 6.50, effective_date: 3.days.ago) } + + it "includes the reimbursement amount" do + expect(mail.body.encoded).to match("$") + end + end + end + describe ".invitation_instructions for a volunteer" do let(:mail) { volunteer.invite! } let(:expiration_date) { I18n.l(volunteer.invitation_due_at, format: :full, default: nil) } diff --git a/spec/notifications/reimbursement_complete_notifier_spec.rb b/spec/notifications/reimbursement_complete_notifier_spec.rb index 5417760470..91d2935abb 100644 --- a/spec/notifications/reimbursement_complete_notifier_spec.rb +++ b/spec/notifications/reimbursement_complete_notifier_spec.rb @@ -1,6 +1,8 @@ require "rails_helper" RSpec.describe ReimbursementCompleteNotifier, type: :model do + include ActiveJob::TestHelper + describe "title" do it "returns 'Reimbursement Approved'" do case_contact = build(:case_contact, :wants_reimbursement) @@ -31,6 +33,40 @@ end end + describe "email delivery" do + let(:volunteer) { create(:volunteer, receive_email_notifications: true) } + let(:supervisor) { create(:supervisor, receive_email_notifications: true) } + let(:case_contact) { create(:case_contact, :wants_reimbursement, creator: volunteer) } + + it "emails the volunteer recipient" do + perform_enqueued_jobs do + ReimbursementCompleteNotifier.with(case_contact: case_contact).deliver(volunteer) + end + + expect(ActionMailer::Base.deliveries.map(&:to).flatten).to include(volunteer.email) + end + + it "does not email a volunteer who has opted out of email notifications" do + # UserValidator requires at least one communication channel enabled, so + # disabling email requires enabling SMS, which in turn requires a phone number. + volunteer.update!(receive_email_notifications: false, receive_sms_notifications: true, phone_number: "5555555555") + + perform_enqueued_jobs do + ReimbursementCompleteNotifier.with(case_contact: case_contact).deliver(volunteer) + end + + expect(ActionMailer::Base.deliveries.map(&:to).flatten).not_to include(volunteer.email) + end + + it "does not email a supervisor recipient" do + perform_enqueued_jobs do + ReimbursementCompleteNotifier.with(case_contact: case_contact).deliver(supervisor) + end + + expect(ActionMailer::Base.deliveries.map(&:to).flatten).not_to include(supervisor.email) + end + end + describe "url" do it "returns the case contacts URL path for the given case contact" do case_contact = create(:case_contact, :wants_reimbursement) From 6d73a5714a3f8e8772f8ce2c18b6589d7f9a9513 Mon Sep 17 00:00:00 2001 From: arielsalcepk Date: Wed, 15 Jul 2026 15:24:36 -0400 Subject: [PATCH 2/6] Fix standardrb/erb_lint formatting --- .../reimbursement_complete_email.html.erb | 2 +- lib/mailers/debug_preview_mailer.rb | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/views/volunteer_mailer/reimbursement_complete_email.html.erb b/app/views/volunteer_mailer/reimbursement_complete_email.html.erb index 73cfd602a6..bcff38089b 100644 --- a/app/views/volunteer_mailer/reimbursement_complete_email.html.erb +++ b/app/views/volunteer_mailer/reimbursement_complete_email.html.erb @@ -26,4 +26,4 @@ If you have any questions, please contact your most recent CASA supervisor for assistance. - \ No newline at end of file + diff --git a/lib/mailers/debug_preview_mailer.rb b/lib/mailers/debug_preview_mailer.rb index 6812042a63..4f84f52dde 100644 --- a/lib/mailers/debug_preview_mailer.rb +++ b/lib/mailers/debug_preview_mailer.rb @@ -13,11 +13,11 @@ def invalid_user(role) # # the "missing_#{role}@example.com" convention so existing preview specs # # asserting on that address don't need to change. def no_data(role) - mail( - from: "reply@example.com", - to: "missing_#{role}@example.com", - subject: "no_preview_data", - body: "No #{role} record was found to preview" - ) - end + mail( + from: "reply@example.com", + to: "missing_#{role}@example.com", + subject: "no_preview_data", + body: "No #{role} record was found to preview" + ) + end end From 97cb25f92b90c6022e0bf6d1797a083c588e00a0 Mon Sep 17 00:00:00 2001 From: arielsalcepk Date: Wed, 15 Jul 2026 15:43:59 -0400 Subject: [PATCH 3/6] Clean up comment formatting and document reimbursement_complete_email preview --- lib/mailers/debug_preview_mailer.rb | 6 +++--- lib/mailers/previews/volunteer_mailer_preview.rb | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/mailers/debug_preview_mailer.rb b/lib/mailers/debug_preview_mailer.rb index 4f84f52dde..ffe59edb1e 100644 --- a/lib/mailers/debug_preview_mailer.rb +++ b/lib/mailers/debug_preview_mailer.rb @@ -9,9 +9,9 @@ def invalid_user(role) end # Use when the looked-up user is valid but has no related record to preview - # # with, e.g. a volunteer with no case contact wanting reimbursement. Keeps - # # the "missing_#{role}@example.com" convention so existing preview specs - # # asserting on that address don't need to change. + # with, e.g. a volunteer with no case contact wanting reimbursement. Keeps + # the "missing_#{role}@example.com" convention so existing preview specs + # asserting on that address don't need to change. def no_data(role) mail( from: "reply@example.com", diff --git a/lib/mailers/previews/volunteer_mailer_preview.rb b/lib/mailers/previews/volunteer_mailer_preview.rb index fe30dd0540..e3bfce1d66 100644 --- a/lib/mailers/previews/volunteer_mailer_preview.rb +++ b/lib/mailers/previews/volunteer_mailer_preview.rb @@ -29,6 +29,11 @@ def case_contacts_reminder end end + + # Unlike the other previews above, a valid volunteer here isn't enough on + # its own, they also need a case contact wanting reimbursement to render + # against. If they don't have one, DebugPreviewMailer.no_data renders that + # distinctly from invalid_user, which is reserved for a failed ID lookup. def reimbursement_complete_email volunteer = params.has_key?(:id) ? Volunteer.find_by(id: params[:id]) : Volunteer.last if volunteer.nil? From 4d772cae5e3dcda56bb2cbb9b11fa1676bc66c62 Mon Sep 17 00:00:00 2001 From: arielsalcepk Date: Wed, 15 Jul 2026 15:46:13 -0400 Subject: [PATCH 4/6] Remove stray blank line before reimbursement_complete_email preview comment --- lib/mailers/previews/volunteer_mailer_preview.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/mailers/previews/volunteer_mailer_preview.rb b/lib/mailers/previews/volunteer_mailer_preview.rb index e3bfce1d66..dbad416122 100644 --- a/lib/mailers/previews/volunteer_mailer_preview.rb +++ b/lib/mailers/previews/volunteer_mailer_preview.rb @@ -29,7 +29,6 @@ def case_contacts_reminder end end - # Unlike the other previews above, a valid volunteer here isn't enough on # its own, they also need a case contact wanting reimbursement to render # against. If they don't have one, DebugPreviewMailer.no_data renders that From f07b3264139c9f329b2660b1808cab34014124a3 Mon Sep 17 00:00:00 2001 From: arielsalcepk Date: Wed, 15 Jul 2026 20:06:21 -0400 Subject: [PATCH 5/6] Fix currency regex and skip notification when reimbursement is unchecked --- app/controllers/reimbursements_controller.rb | 12 +++++++--- spec/mailers/volunteer_mailer_spec.rb | 9 +++++++- spec/requests/reimbursements_spec.rb | 24 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/controllers/reimbursements_controller.rb b/app/controllers/reimbursements_controller.rb index f78b0ed3d5..7ec4f9b1a2 100644 --- a/app/controllers/reimbursements_controller.rb +++ b/app/controllers/reimbursements_controller.rb @@ -32,9 +32,15 @@ def change_complete_status @grouped_case_contacts = fetch_reimbursements .where({occurred_at: @case_contact.occurred_at, creator_id: @case_contact.creator_id}) @grouped_case_contacts.update_all(reimbursement_params.to_h) - notification_recipients = [@case_contact.creator] - notification_recipients << @case_contact.supervisor if @case_contact.supervisor - ReimbursementCompleteNotifier.with(case_contact: @case_contact).deliver(notification_recipients) + + # This action also backs mark_as_needs_review, so only notify when + # actually marking complete (cast, since the value's format varies). + if ActiveModel::Type::Boolean.new.cast(reimbursement_params[:reimbursement_complete]) + notification_recipients = [@case_contact.creator] + notification_recipients << @case_contact.supervisor if @case_contact.supervisor + ReimbursementCompleteNotifier.with(case_contact: @case_contact).deliver(notification_recipients) + end + redirect_to reimbursements_path unless params[:ajax] end diff --git a/spec/mailers/volunteer_mailer_spec.rb b/spec/mailers/volunteer_mailer_spec.rb index 54025179be..30ea97df7b 100644 --- a/spec/mailers/volunteer_mailer_spec.rb +++ b/spec/mailers/volunteer_mailer_spec.rb @@ -56,7 +56,14 @@ let!(:mileage_rate) { create(:mileage_rate, casa_org: case_contact.casa_case.casa_org, amount: 6.50, effective_date: 3.days.ago) } it "includes the reimbursement amount" do - expect(mail.body.encoded).to match("$") + # Bare "$" is a regex end-anchor via String#match, not a literal dollar sign. + expect(mail.body.encoded).to match(/\$[\d,]+\.\d{2}/) + end + end + + context "when the casa org has no mileage rate" do + it "omits the reimbursement amount rather than rendering a blank or nil value" do + expect(mail.body.encoded).not_to match(/\$[\d,]+\.\d{2}/) end end end diff --git a/spec/requests/reimbursements_spec.rb b/spec/requests/reimbursements_spec.rb index cc932f37ad..150cceaa8e 100644 --- a/spec/requests/reimbursements_spec.rb +++ b/spec/requests/reimbursements_spec.rb @@ -65,6 +65,15 @@ expect(Noticed::Notification.last.recipient).to eq(case_contact.supervisor) end end + + context "when reimbursement_complete arrives as the string \"1\" instead of a boolean" do + # Default Rails checkboxes submit "1"/"0"; the boolean cast must handle this too. + it "still sends a notification" do + expect do + patch reimbursement_mark_as_complete_url(case_contact, case_contact: {reimbursement_complete: "1"}) + end.to change(Noticed::Notification, :count).by(1) + end + end end describe "PATCH /mark_as_needs_review" do @@ -76,5 +85,20 @@ expect(response).to have_http_status(:redirect) expect(case_contact.reload.reimbursement_complete).to be_falsey end + + it "does not send a notification" do + expect do + patch reimbursement_mark_as_needs_review_url(case_contact, case_contact: {reimbursement_complete: false}) + end.not_to change(Noticed::Notification, :count) + end + + context "when reimbursement_complete arrives as the string \"0\" instead of a boolean" do + # Mirror of the "1" case: "0" must cast to false, not a truthy string. + it "still withholds the notification" do + expect do + patch reimbursement_mark_as_needs_review_url(case_contact, case_contact: {reimbursement_complete: "0"}) + end.not_to change(Noticed::Notification, :count) + end + end end end From 80f3c23c8ba3aa08b65f955d15420768c4912beb Mon Sep 17 00:00:00 2001 From: arielsalcepk Date: Wed, 15 Jul 2026 20:24:47 -0400 Subject: [PATCH 6/6] Add regression tests for regex escaping and unchecked reimbursements --- spec/mailers/volunteer_mailer_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/mailers/volunteer_mailer_spec.rb b/spec/mailers/volunteer_mailer_spec.rb index 30ea97df7b..987b9c3f6a 100644 --- a/spec/mailers/volunteer_mailer_spec.rb +++ b/spec/mailers/volunteer_mailer_spec.rb @@ -66,6 +66,14 @@ expect(mail.body.encoded).not_to match(/\$[\d,]+\.\d{2}/) end end + + describe "the currency pattern itself" do + # An unescaped "." would still match a real decimal point, so neither + # test above would catch it regressing; guard the escape directly. + it "does not match if the decimal point is some other character" do + expect("$40X50").not_to match(/\$[\d,]+\.\d{2}/) + end + end end describe ".invitation_instructions for a volunteer" do