diff --git a/app/controllers/patients_controller.rb b/app/controllers/patients_controller.rb index a9b3df98e..5573cd227 100644 --- a/app/controllers/patients_controller.rb +++ b/app/controllers/patients_controller.rb @@ -3,6 +3,7 @@ class PatientsController < ApplicationController include ActionController::Live before_action :confirm_admin_user, only: [:destroy] before_action :confirm_data_access, only: [:index] + before_action :confirm_handoff_authorization, only: [:handoff] before_action :find_patient, if: :should_preload_patient_with_versions? before_action :find_patient_minimal, if: :should_preload_patient_minimally? rescue_from ActiveRecord::RecordNotFound, @@ -157,8 +158,67 @@ def destroy end end + def handoff + @patient = Patient.find(params[:id]) + target_user = User.find_by!(id: params[:target_user_id]) + + ActiveRecord::Base.transaction do + # Remove from current user's call list (if present) + begin + current_user.remove_patient(@patient) + rescue ActiveRecord::RecordNotFound + # Patient wasn't on current user's call list — that's fine + end + + # Add to target user's call list + target_user.add_patient(@patient) + + # Record handoff metadata + @patient.update!( + handed_off_at: Time.current, + handed_off_from_id: current_user.id, + handed_off_to_id: target_user.id, + handoff_note: params[:handoff_note] + ) + + # Create a note documenting the handoff (PaperTrail tracks whodunnit) + @patient.notes.create!( + full_text: "Handed off from #{current_user.name} to #{target_user.name}#{params[:handoff_note].present? ? ": #{params[:handoff_note]}" : ''}" + ) + + # Send notification to receiving user (guarded: Notification model + # lives on feature/notification-center and may not be merged yet) + if defined?(Notification) + Notification.notify!( + user: target_user, + notification_type: "handoff", + title: "Patient handed off to you: #{@patient.name}", + body: "#{current_user.name} handed off #{@patient.name}#{params[:handoff_note].present? ? " — #{params[:handoff_note]}" : ''}", + link: edit_patient_path(@patient) + ) + else + Rails.logger.info('Notification skipped: Notification model not available') + end + end + + flash[:notice] = t('flash.patient_handed_off', patient: @patient.name, user: target_user.name) + redirect_to edit_patient_path(@patient) + end + private + # Only an assigned case manager or an admin can hand off a patient. + # Data volunteers are never permitted to hand off patients. + def confirm_handoff_authorization + patient = Patient.find(params[:id]) + is_assigned_cm = current_user.cm? && + CallListEntry.where(patient: patient, user: current_user).exists? + unless is_assigned_cm || current_user.admin? + flash[:alert] = t('flash.not_authorized', default: 'Not authorized.') + redirect_to edit_patient_path(patient) + end + end + # preload patient with versions for edit and js format update requests def should_preload_patient_with_versions? action_name.to_sym == :edit || (action_name.to_sym == :update && !request.format.json?) diff --git a/app/views/patients/_patient_dashboard.html.erb b/app/views/patients/_patient_dashboard.html.erb index 32145efcd..158de6d5e 100644 --- a/app/views/patients/_patient_dashboard.html.erb +++ b/app/views/patients/_patient_dashboard.html.erb @@ -74,4 +74,28 @@ <% end %> + + <%# Handoff form — separate from the patient edit form (no nested forms) %> +
+
+
+ + <%= form_tag handoff_patient_path(patient), method: :post, class: 'd-flex flex-column' do %> + <%= select_tag :target_user_id, + options_from_collection_for_select( + User.where.not(id: current_user.id).where(disabled_by_fund: [false, nil]).order(:name), + :id, :name + ), + prompt: t('patient.dashboard.handoff_select', default: 'Select case manager...'), + class: 'form-control form-control-sm mb-1' %> + <%= text_field_tag :handoff_note, nil, + placeholder: t('patient.dashboard.handoff_note_placeholder', default: 'Handoff note (optional)'), + class: 'form-control form-control-sm' %> + <%= submit_tag t('patient.dashboard.handoff_button', default: 'Hand Off'), + class: 'btn btn-sm btn-outline-primary', + data: { confirm: t('patient.dashboard.confirm_handoff', default: 'Hand off this patient?') } %> + <% end %> +
+
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index ea4a99f9f..1e94124f6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -306,6 +306,7 @@ en: patient_info_saved: Patient info successfully saved at %{timestamp} patient_removed_database: Patient successfully removed from database. patient_removed_database_error: Can't delete patients with pledges; please correct the patient record and try again. + patient_handed_off: "%{patient} has been handed off to %{user}." patient_save_error: Errors prevented this patient from being saved - %{error} patient_save_success: "%{patient} has been successfully saved! Add notes and external pledges, confirm the hard pledge and the %{fund} pledge amounts are the same, and you're set." pledge_download_alert: You need to enter your name in the box to sign and download the pledge diff --git a/config/routes.rb b/config/routes.rb index f1e14cdfa..d26c488f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,6 +40,7 @@ member do get :download, as: 'generate_pledge' post :fetch_pledge, as: 'fetch_pledge' + post :handoff, as: 'handoff' end resources :calls, only: [ :create, :destroy, :new ] diff --git a/db/migrate/20250101000003_add_handoff_fields_to_patients.rb b/db/migrate/20250101000003_add_handoff_fields_to_patients.rb new file mode 100644 index 000000000..9fcfad677 --- /dev/null +++ b/db/migrate/20250101000003_add_handoff_fields_to_patients.rb @@ -0,0 +1,8 @@ +class AddHandoffFieldsToPatients < ActiveRecord::Migration[8.1] + def change + add_column :patients, :handed_off_at, :datetime + add_column :patients, :handed_off_from_id, :bigint + add_column :patients, :handed_off_to_id, :bigint + add_column :patients, :handoff_note, :text + end +end diff --git a/test/controllers/patient_handoff_test.rb b/test/controllers/patient_handoff_test.rb new file mode 100644 index 000000000..d429de8a2 --- /dev/null +++ b/test/controllers/patient_handoff_test.rb @@ -0,0 +1,148 @@ +require 'test_helper' + +class PatientHandoffTest < ActionDispatch::IntegrationTest + before do + @user = create :user + @target_user = create :user + @line = create :line + @patient = create :patient + sign_in @user + choose_line @line + # Put patient on current user's call list (required for handoff auth) + @user.add_patient(@patient) + end + + describe 'handoff action' do + it 'should hand off patient to target user' do + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id, + handoff_note: 'Transferring for follow-up' + } + assert_redirected_to edit_patient_path(@patient) + + @patient.reload + assert_equal @target_user.id, @patient.handed_off_to_id + assert_equal @user.id, @patient.handed_off_from_id + assert_equal 'Transferring for follow-up', @patient.handoff_note + assert_not_nil @patient.handed_off_at + end + + it 'should create a note documenting the handoff' do + assert_difference '@patient.notes.count', 1 do + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + end + + note = @patient.notes.last + assert_match @user.name, note.full_text + assert_match @target_user.name, note.full_text + end + + it 'should work without a handoff note' do + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + assert_redirected_to edit_patient_path(@patient) + + @patient.reload + assert_nil @patient.handoff_note + end + + it 'should reject invalid target_user_id' do + assert_raises ActiveRecord::RecordNotFound do + post handoff_patient_path(@patient), params: { + target_user_id: 999999 + } + end + end + + it 'should set flash notice on success' do + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + assert_match @patient.name, flash[:notice] + assert_match @target_user.name, flash[:notice] + end + + it 'should transfer patient from source to target call list' do + assert CallListEntry.where(patient: @patient, user: @user).exists?, + 'precondition: patient should be on source user call list' + + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + + refute CallListEntry.where(patient: @patient, user: @user).exists?, + 'patient should be removed from source user call list' + assert CallListEntry.where(patient: @patient, user: @target_user).exists?, + 'patient should be added to target user call list' + end + + it 'should record a recent handed_off_at timestamp' do + freeze_time do + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + @patient.reload + assert_equal Time.current, @patient.handed_off_at + end + end + + it 'should include handoff note in the created note text' do + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id, + handoff_note: 'Needs Spanish speaker' + } + + note = @patient.notes.last + assert_match 'Needs Spanish speaker', note.full_text + end + end + + describe 'handoff authorization' do + it 'should require authentication' do + delete user_session_path # sign out + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + assert_response :redirect + @patient.reload + assert_nil @patient.handed_off_to_id + end + + it 'should allow admin even without patient on call list' do + @user.update!(role: :admin) + @user.remove_patient(@patient) + + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + assert_redirected_to edit_patient_path(@patient) + @patient.reload + assert_equal @target_user.id, @patient.handed_off_to_id + end + + it 'should reject CM who does not have patient on call list' do + @user.remove_patient(@patient) + + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + assert_redirected_to edit_patient_path(@patient) + assert_equal 'Not authorized.', flash[:alert] + @patient.reload + assert_nil @patient.handed_off_to_id + end + + it 'should reject data volunteer' do + @user.update!(role: :data_volunteer) + + post handoff_patient_path(@patient), params: { + target_user_id: @target_user.id + } + assert_redirected_to edit_patient_path(@patient) + assert_equal 'Not authorized.', flash[:alert] + end + end +end \ No newline at end of file