Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/controllers/patients_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?)
Expand Down
24 changes: 24 additions & 0 deletions app/views/patients/_patient_dashboard.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,28 @@
</div>
</div>
<% end %>

<%# Handoff form — separate from the patient edit form (no nested forms) %>
<div class="row mt-2">
<div class="col-3 ml-auto">
<div class="mb-3">
<label><%= t('patient.dashboard.handoff_label', default: 'Hand Off To') %></label>
<%= 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 %>
</div>
</div>
</div>
</div>
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down
8 changes: 8 additions & 0 deletions db/migrate/20250101000003_add_handoff_fields_to_patients.rb
Original file line number Diff line number Diff line change
@@ -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
148 changes: 148 additions & 0 deletions test/controllers/patient_handoff_test.rb
Original file line number Diff line number Diff line change
@@ -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