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/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..bcff38089b --- /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. +
diff --git a/lib/mailers/debug_preview_mailer.rb b/lib/mailers/debug_preview_mailer.rb index aa3948d63d..ffe59edb1e 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..dbad416122 100644 --- a/lib/mailers/previews/volunteer_mailer_preview.rb +++ b/lib/mailers/previews/volunteer_mailer_preview.rb @@ -28,5 +28,23 @@ def case_contacts_reminder VolunteerMailer.court_report_reminder(volunteer, true) 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? + 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..987b9c3f6a 100644 --- a/spec/mailers/volunteer_mailer_spec.rb +++ b/spec/mailers/volunteer_mailer_spec.rb @@ -41,6 +41,41 @@ 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 + # 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 + + 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 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) 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