diff --git a/drivers/hmis/app/graphql/types/hmis_schema/ce_referral.rb b/drivers/hmis/app/graphql/types/hmis_schema/ce_referral.rb index d7343fecc97..13d957c3976 100644 --- a/drivers/hmis/app/graphql/types/hmis_schema/ce_referral.rb +++ b/drivers/hmis/app/graphql/types/hmis_schema/ce_referral.rb @@ -44,7 +44,7 @@ def self.authorized?(object, ctx) summary_field :client_id, ID, null: false summary_field :client_name, String, null: true, description: 'The name of the referred client. Always available to those who can view the full referral, even without full client record access.' summary_field :created_at, GraphQL::Types::ISO8601DateTime, null: false - summary_field :source_enrollment_id, ID, null: true + summary_field :source_enrollment_id, ID, null: true # may be null for VSP referrals or if the source enrollment was deleted # source_project_name is resolved separately from source_enrollment as a summary field to minimize data exposure summary_field :source_project_name, String, null: true # Resolve project fields separately, instead of on the project schema object, in case user can't view the project @@ -213,11 +213,11 @@ def target_enrollment end def source_enrollment - return unless object.source_enrollment_id + return unless object.source_enrollment_id # May be null for VSP referrals or if the source enrollment was deleted - # Resolve source Enrollment without checking viewable_by.This resolves as type CeReferralSourceEnrollment, so it only exposes limited data from the Enrollment + # Resolve source Enrollment without checking viewable_by. This resolves as type CeReferralSourceEnrollment, so it only exposes limited data from the Enrollment enrollment = load_ar_association(object, :source_enrollment) - return unless enrollment # May be missing if source enrollment was deleted and reference was not cleaned up + return unless enrollment # May be missing if source enrollment was deleted and reference was not cleaned up (cleanup added with #8539) # Not passing definition_identifiers because we don't need to resolve assessment data in this context (for now) OpenStruct.new(enrollment: enrollment, definition_identifiers: []) diff --git a/drivers/hmis/app/models/hmis/ce/referral.rb b/drivers/hmis/app/models/hmis/ce/referral.rb index 795efd6a8b4..972ea3a4097 100644 --- a/drivers/hmis/app/models/hmis/ce/referral.rb +++ b/drivers/hmis/app/models/hmis/ce/referral.rb @@ -107,6 +107,10 @@ def self.sort_by_option(option) validate :ce_template validate :consistent_data_source validate :consistent_project + # Direct referrals require a source enrollment to identify which project sent the referral. + # Without a source enrollment, the referral would "float" and not appear on the source project's + # outgoing referrals list, making it difficult to track and manage. + validates :source_enrollment_id, presence: true, if: -> { referral_origin == DIRECT_SEND_ORIGIN } # When referral status changes, its CustomReferralStatus (user-facing status) should also be updated. # See ReferralMessageHandler for example. diff --git a/drivers/hmis/app/models/hmis/ce/referral_ce_event_manager.rb b/drivers/hmis/app/models/hmis/ce/referral_ce_event_manager.rb index 0325b574ffd..d6d7e01d4d6 100644 --- a/drivers/hmis/app/models/hmis/ce/referral_ce_event_manager.rb +++ b/drivers/hmis/app/models/hmis/ce/referral_ce_event_manager.rb @@ -16,7 +16,8 @@ def initialize(referral) def create_ce_event(message) enrollment = referral.source_enrollment - raise 'Referral does not have a source enrollment' unless enrollment.present? + # Skip CE Event creation if source Enrollment is missing. (Expected for VSP referrals or if the source enrollment was deleted) + return unless enrollment # If the referral already has a CE event, return early and don't raise return if referral.ce_event.present? diff --git a/drivers/hmis/app/models/hmis/hud/enrollment.rb b/drivers/hmis/app/models/hmis/hud/enrollment.rb index 701cdcd129c..c80023f8339 100644 --- a/drivers/hmis/app/models/hmis/hud/enrollment.rb +++ b/drivers/hmis/app/models/hmis/hud/enrollment.rb @@ -112,6 +112,9 @@ class Hmis::Hud::Enrollment < Hmis::Hud::Base accepts_nested_attributes_for :move_in_addresses, allow_destroy: true + before_destroy :check_active_outgoing_direct_referrals + before_destroy :nullify_outgoing_waitlist_referrals + before_destroy :destroy_outgoing_direct_referrals before_validation :set_hud_project_id_from_project_pk_unless_wip, if: :project_pk_changed? def set_hud_project_id_from_project_pk_unless_wip return unless project_id @@ -538,6 +541,26 @@ def build_synthetic_intake_assessment assessment end + # Prevents deletion of an enrollment that has active outgoing direct referrals. + private def check_active_outgoing_direct_referrals + return unless outgoing_ce_referrals.originated_from_direct_send.active.exists? + + raise ActiveRecord::DeleteRestrictionError, 'active outgoing direct referrals' + end + + # Nullifies source_enrollment_id on waitlist-based referrals when enrollment is deleted. + # Waitlist-based referrals can exist without as source enrollment since they're already resolved. + private def nullify_outgoing_waitlist_referrals + outgoing_ce_referrals.originated_from_waitlist.each { |referral| referral.update!(source_enrollment_id: nil) } + end + + # Destroys all direct send referrals when enrollment is deleted. + # Direct send referrals require the source enrollment to identify which project sent them. + # Without the source enrollment, they would "float" and not appear on the source project's outgoing referrals list. + private def destroy_outgoing_direct_referrals + outgoing_ce_referrals.originated_from_direct_send.each(&:destroy!) + end + # When submitting a new_client_enrollment form, we validate the client too, with the same validation contexts private def client_is_valid return unless client.present? diff --git a/drivers/hmis/spec/models/hmis/hud/enrollment_spec.rb b/drivers/hmis/spec/models/hmis/hud/enrollment_spec.rb index 97d7f7ceb5b..ef7e9ebb7da 100644 --- a/drivers/hmis/spec/models/hmis/hud/enrollment_spec.rb +++ b/drivers/hmis/spec/models/hmis/hud/enrollment_spec.rb @@ -11,13 +11,6 @@ require_relative '../../../support/hmis_base_setup' RSpec.describe Hmis::Hud::Enrollment, type: :model do - before(:all) do - cleanup_test_environment - end - after(:all) do - cleanup_test_environment - end - include_context 'hmis base setup' it 'detects date conflicts' do @@ -82,33 +75,26 @@ end end - describe 'in progress enrollments' do - let!(:enrollment) { build(:hmis_hud_enrollment) } - before(:each) do - enrollment.save_in_progress! - end - end - describe 'saved enrollments' do - let!(:enrollment) { create(:hmis_hud_enrollment) } + let!(:enrollment) { create(:hmis_hud_enrollment, data_source: ds1) } before(:each) do - create(:hmis_hud_exit, data_source: enrollment.data_source, enrollment: enrollment, client: enrollment.client) - create(:hmis_hud_service, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_hud_event, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_income_benefit, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_disability, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_health_and_dv, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_current_living_situation, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_hud_assessment, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_employment_education, data_source: enrollment.data_source, enrollment: enrollment) - create(:hmis_youth_education_status, data_source: enrollment.data_source, enrollment: enrollment) + create(:hmis_hud_exit, data_source: ds1, enrollment: enrollment, client: enrollment.client) + create(:hmis_hud_service, data_source: ds1, enrollment: enrollment) + create(:hmis_hud_event, data_source: ds1, enrollment: enrollment) + create(:hmis_income_benefit, data_source: ds1, enrollment: enrollment) + create(:hmis_disability, data_source: ds1, enrollment: enrollment) + create(:hmis_health_and_dv, data_source: ds1, enrollment: enrollment) + create(:hmis_current_living_situation, data_source: ds1, enrollment: enrollment) + create(:hmis_hud_assessment, data_source: ds1, enrollment: enrollment) + create(:hmis_employment_education, data_source: ds1, enrollment: enrollment) + create(:hmis_youth_education_status, data_source: ds1, enrollment: enrollment) enrollment.save_not_in_progress! end it 'preserve shared data after destroy' do - enrollment.destroy + enrollment.destroy! enrollment.reload [ @@ -137,7 +123,7 @@ expect(enrollment.send(assoc)).to be_present, "expected #{assoc} to be present" end - enrollment.destroy + enrollment.destroy! enrollment.reload [ @@ -527,4 +513,82 @@ expect(hh_member.current_unit).to eq(unit) end end + + describe 'destroy hooks for outgoing CE referrals' do + before(:each) do + allow_any_instance_of(Hmis::Ce::Configuration).to receive(:enabled?).and_return(true) + end + + let!(:source_enrollment) { create(:hmis_hud_enrollment, data_source: ds1, project: p1, client: c1) } + let!(:target_project) { create(:hmis_hud_project, data_source: ds1) } + let!(:target_opportunity1) { create(:hmis_ce_opportunity, data_source: ds1, project: target_project) } + let!(:target_opportunity2) { create(:hmis_ce_opportunity, data_source: ds1, project: target_project) } + let!(:target_opportunity3) { create(:hmis_ce_opportunity, data_source: ds1, project: target_project) } + + context 'when enrollment is the source for active direct referrals' do + let!(:active_direct_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity1, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::DIRECT_SEND_ORIGIN, status: 'in_progress') } + + it 'prevents deletion' do + expect do + source_enrollment.destroy! + end.to raise_error(ActiveRecord::DeleteRestrictionError, /Cannot delete enrollment because it has active outgoing referrals/). + and(not_change { source_enrollment.reload.date_deleted }). + and(not_change { active_direct_referral.reload.source_enrollment_id }) + end + end + + context 'when enrollment is the source for completed direct referrals' do + let!(:accepted_direct_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity1, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::DIRECT_SEND_ORIGIN, status: 'accepted') } + let!(:rejected_direct_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity2, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::DIRECT_SEND_ORIGIN, status: 'rejected') } + + it 'destroys all direct referrals' do + expect do + source_enrollment.destroy! + end.to change { source_enrollment.reload.date_deleted }.from(nil). + and change { accepted_direct_referral.reload.deleted_at }.from(nil). + and change { rejected_direct_referral.reload.deleted_at }.from(nil) + end + end + + context 'when enrollment is the source for waitlist referrals' do + let!(:active_waitlist_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity1, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::WAITLIST_ORIGIN, status: 'in_progress') } + let!(:accepted_waitlist_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity2, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::WAITLIST_ORIGIN, status: 'accepted') } + let!(:rejected_waitlist_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity3, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::WAITLIST_ORIGIN, status: 'rejected') } + + it 'allows deletion even with active waitlist referrals and nullifies source_enrollment_id' do + expect do + source_enrollment.destroy! + end.to change { source_enrollment.reload.date_deleted }.from(nil). + and change { active_waitlist_referral.reload.source_enrollment_id }.from(source_enrollment.id).to(nil). + and change { accepted_waitlist_referral.reload.source_enrollment_id }.from(source_enrollment.id).to(nil). + and change { rejected_waitlist_referral.reload.source_enrollment_id }.from(source_enrollment.id).to(nil) + end + end + + context 'when enrollment has mixed referral types' do + let!(:active_direct_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity1, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::DIRECT_SEND_ORIGIN, status: 'in_progress') } + let!(:completed_direct_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity2, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::DIRECT_SEND_ORIGIN, status: 'accepted') } + let!(:waitlist_referral) { create(:hmis_ce_referral, data_source: ds1, opportunity: target_opportunity3, source_enrollment: source_enrollment, client: c1, referral_origin: Hmis::Ce::Referral::WAITLIST_ORIGIN, status: 'accepted') } + + it 'prevents deletion due to active direct referral' do + expect do + source_enrollment.destroy! + end.to raise_error(ActiveRecord::DeleteRestrictionError). + and(not_change { source_enrollment.reload.date_deleted }). + and(not_change { active_direct_referral.reload.source_enrollment_id }). + and(not_change { completed_direct_referral.reload.source_enrollment_id }). + and(not_change { waitlist_referral.reload.source_enrollment_id }) + end + + it 'allows deletion and processes referrals correctly when active direct referral is completed' do + active_direct_referral.update!(status: 'rejected') + + expect do + source_enrollment.destroy! + end.to change { source_enrollment.reload.date_deleted }.from(nil). + and change { completed_direct_referral.reload.deleted_at }.from(nil). + and change { waitlist_referral.reload.source_enrollment_id }.from(source_enrollment.id).to(nil) + end + end + end end diff --git a/drivers/hmis/spec/requests/hmis/project_outgoing_ce_referrals_spec.rb b/drivers/hmis/spec/requests/hmis/project_outgoing_ce_referrals_spec.rb index d3f0acdb35c..f79b69e514f 100644 --- a/drivers/hmis/spec/requests/hmis/project_outgoing_ce_referrals_spec.rb +++ b/drivers/hmis/spec/requests/hmis/project_outgoing_ce_referrals_spec.rb @@ -48,6 +48,18 @@ status sourceEnrollmentId clientId + targetProjectId + targetProjectName + targetProjectType + customStatus { + id + key + name + } + referredBy { + id + name + } # special case summary field that is resolved when the user has permission to view the client clientName