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