From 443abb967c2ebdc1ae4731c0201d11a6db23a612 Mon Sep 17 00:00:00 2001 From: martha Date: Mon, 27 Jan 2025 12:55:31 -0500 Subject: [PATCH] Implement Split Households mutation --- .../app/graphql/mutations/split_household.rb | 76 ++++++++ drivers/hmis/app/graphql/schema.graphql | 30 +++ .../types/hmis_schema/mutation_type.rb | 1 + .../requests/hmis/split_household_spec.rb | 182 ++++++++++++++++++ .../hmis/split_households_workflow_spec.rb | 123 ++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 drivers/hmis/app/graphql/mutations/split_household.rb create mode 100644 drivers/hmis/spec/requests/hmis/split_household_spec.rb create mode 100644 drivers/hmis/spec/system/hmis/split_households_workflow_spec.rb diff --git a/drivers/hmis/app/graphql/mutations/split_household.rb b/drivers/hmis/app/graphql/mutations/split_household.rb new file mode 100644 index 00000000000..7fcc02c8296 --- /dev/null +++ b/drivers/hmis/app/graphql/mutations/split_household.rb @@ -0,0 +1,76 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +module Mutations + class SplitHousehold < BaseMutation + argument :splitting_enrollment_inputs, [Types::HmisSchema::EnrollmentRelationshipInput], required: true + + field :new_household, Types::HmisSchema::Household, null: false + field :remaining_household, Types::HmisSchema::Household, null: false + + def resolve(splitting_enrollment_inputs:) + map_enrollment_id_to_relationship = splitting_enrollment_inputs.map { |e| [e.enrollment_id, e.relationship_to_hoh] }.to_h + + splitting_enrollment_ids = map_enrollment_id_to_relationship.keys + splitting_enrollments = Hmis::Hud::Enrollment. + where(id: splitting_enrollment_ids). + viewable_by(current_user). + includes(:household, :project) + access_denied! unless splitting_enrollments.count == splitting_enrollment_inputs.count + + project = splitting_enrollments.map(&:project).uniq.sole + access_denied! unless current_permission?(permission: :can_split_households, entity: project) + + donor_household = splitting_enrollments.map(&:household).uniq.sole + remaining_enrollments = Hmis::Hud::Enrollment. + where(household_id: donor_household.household_id). + where.not(id: splitting_enrollment_ids) + remaining_hoh = remaining_enrollments.any? { |enrollment| enrollment.relationship_to_hoh == 1 } + + raise 'Splitting all clients to a new household is invalid' if remaining_enrollments.empty? + raise 'This operation would leave behind a household with no HoH, which is not allowed' unless remaining_hoh + + donor_before_state = Hmis::Hud::Enrollment.snapshot_enrollments([*splitting_enrollments, *remaining_enrollments]) + new_household_id = Hmis::Hud::Base.generate_uuid + + Hmis::Hud::Enrollment.transaction do + splitting_enrollments.each do |enrollment| + enrollment.update!( + household_id: new_household_id, + relationship_to_hoh: map_enrollment_id_to_relationship[enrollment.id.to_s], + ) + + enrollment.active_unit_occupancy&.assign_attributes(occupancy_period_attributes: { end_date: Date.current }) + + enrollment.save! + end + + donor_household.reload + + event = Hmis::HouseholdEvent.new + event.user = current_user + event.household = donor_household + event.event_type = Hmis::HouseholdEvent::SPLIT + event.event_details = { + 'receivingHouseholdId': new_household_id, + 'before': donor_before_state, + 'after': Hmis::Hud::Enrollment.snapshot_enrollments(remaining_enrollments), + } + event.save! + + remaining_enrollments.invalidate_processing! + end + + enrollment = splitting_enrollments.first + enrollment.reload + + { + new_household: enrollment.household, + remaining_household: donor_household, + } + end + end +end diff --git a/drivers/hmis/app/graphql/schema.graphql b/drivers/hmis/app/graphql/schema.graphql index 3967d06d9e0..88a3e6f7a36 100644 --- a/drivers/hmis/app/graphql/schema.graphql +++ b/drivers/hmis/app/graphql/schema.graphql @@ -6273,6 +6273,12 @@ type Mutation { """ input: SaveAssessmentInput! ): SaveAssessmentPayload + splitHousehold( + """ + Parameters for SplitHousehold + """ + input: SplitHouseholdInput! + ): SplitHouseholdPayload """ Create/Submit assessment, and create/update related HUD records @@ -11193,6 +11199,30 @@ enum SexualOrientation { QUESTIONING_UNSURE } +""" +Autogenerated input type of SplitHousehold +""" +input SplitHouseholdInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + splittingEnrollmentInputs: [EnrollmentRelationshipInput!]! +} + +""" +Autogenerated return type of SplitHousehold. +""" +type SplitHouseholdPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + errors: [ValidationError!]! + newHousehold: Household! + remainingHousehold: Household! +} + """ Staff Assignment """ diff --git a/drivers/hmis/app/graphql/types/hmis_schema/mutation_type.rb b/drivers/hmis/app/graphql/types/hmis_schema/mutation_type.rb index 844b63ccd3c..32511684334 100644 --- a/drivers/hmis/app/graphql/types/hmis_schema/mutation_type.rb +++ b/drivers/hmis/app/graphql/types/hmis_schema/mutation_type.rb @@ -58,6 +58,7 @@ class HmisSchema::MutationType < Types::BaseObject field :bulk_merge_clients, mutation: Mutations::BulkMergeClients field :join_household, mutation: Mutations::JoinHousehold + field :split_household, mutation: Mutations::SplitHousehold field :create_form_definition, mutation: Mutations::CreateFormDefinition field :update_form_definition, mutation: Mutations::UpdateFormDefinition diff --git a/drivers/hmis/spec/requests/hmis/split_household_spec.rb b/drivers/hmis/spec/requests/hmis/split_household_spec.rb new file mode 100644 index 00000000000..47e831bd020 --- /dev/null +++ b/drivers/hmis/spec/requests/hmis/split_household_spec.rb @@ -0,0 +1,182 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +require 'rails_helper' +require_relative 'login_and_permissions' +require_relative '../../support/hmis_base_setup' + +RSpec.describe Hmis::GraphqlController, type: :request do + include_context 'hmis base setup' + let!(:access_control) { create_access_control(hmis_user, ds1) } + + let(:mutation) do + <<~GRAPHQL + mutation SplitHousehold($input: SplitHouseholdInput!) { + splitHousehold(input: $input) { + newHousehold { + id + householdSize + householdClients { + client { + id + } + enrollment { + id + } + } + } + remainingHousehold { + id + householdSize + } + } + } + GRAPHQL + end + + let!(:donor_household_id) { Hmis::Hud::Base.generate_uuid } + let!(:remaining) { create :hmis_hud_enrollment, data_source: ds1, project: p1, entry_date: 2.weeks.ago, household_id: donor_household_id } + let!(:new_hoh) { create :hmis_hud_enrollment, data_source: ds1, project: p1, entry_date: 2.weeks.ago, relationship_to_hoh: 3, household_id: donor_household_id } + let!(:child) { create :hmis_hud_enrollment, data_source: ds1, project: p1, entry_date: 2.weeks.ago, relationship_to_hoh: 2, household_id: donor_household_id } + + before(:each) do + hmis_login(user) + remaining.update!(processed_as: 'PROCESSED', processed_hash: 'PROCESSED') + new_hoh.update!(processed_as: 'PROCESSED', processed_hash: 'PROCESSED') + child.update!(processed_as: 'PROCESSED', processed_hash: 'PROCESSED') + Delayed::Job.jobs_for_class('GrdaWarehouse::Tasks::ServiceHistory::Enrollment').delete_all + end + + def perform_mutation( + splitting_enrollment_inputs = [ + { + enrollment_id: new_hoh.id, + relationship_to_hoh: 'SELF_HEAD_OF_HOUSEHOLD', + }, + { + enrollment_id: child.id, + relationship_to_hoh: 'CHILD', + }, + ] + ) + input = { + input: { + splitting_enrollment_inputs: splitting_enrollment_inputs, + }, + } + response, result = post_graphql(input) { mutation } + + expect(response.status).to eq(200), result.inspect + result = result.dig('data', 'splitHousehold') + return result['newHousehold'], result['remainingHousehold'] + end + + it 'should successfully split households' do + expect do + new_household, remaining_household = perform_mutation + expect(new_household.dig('householdSize')).to eq(2) + expect(remaining_household.dig('id')).to eq(donor_household_id) + expect(remaining_household.dig('householdSize')).to eq(1) + remaining.reload + new_hoh.reload + child.reload + end.to change(new_hoh, :household_id). + and change(new_hoh, :processed_as).from('PROCESSED').to(nil). + and change(child, :household_id). + and change(child, :processed_as).from('PROCESSED').to(nil). + and not_change(remaining, :household_id). + and change(remaining, :processed_as).from('PROCESSED').to(nil). # Triggers reprocessing for remaining hh even though no fields have changed + and change(Delayed::Job.jobs_for_class('GrdaWarehouse::Tasks::ServiceHistory::Enrollment'), :count).by(1) + + expect(new_hoh.household_id).to eq(child.household_id) + expect(new_hoh.relationship_to_hoh).to eq(1) # Self + expect(child.relationship_to_hoh).to eq(2) # Child + + split_event = remaining.household.events.sole + expect(split_event.event_type).to eq('split') + dets = split_event.event_details + expect(dets['receivingHouseholdId']).to eq(new_hoh.household_id) + expect(dets['before'].map { |enrollment_snap| enrollment_snap['enrollmentId'] }).to contain_exactly(remaining.id, new_hoh.id, child.id) + expect(dets['after'].map { |enrollment_snap| enrollment_snap['enrollmentId'] }).to contain_exactly(remaining.id) + end + + it 'fails when the user does not have can_split_households permission' do + remove_permissions(access_control, :can_split_households) + input = { + splitting_enrollment_inputs: [ + { + enrollment_id: new_hoh.id, + relationship_to_hoh: 'SELF_HEAD_OF_HOUSEHOLD', + }, + ], + } + expect_access_denied post_graphql(input: input) { mutation } + end + + it 'fails when the given enrollment IDs are invalid' do + input = { + splitting_enrollment_inputs: [ + { + enrollment_id: 'fake-enrollment', + relationship_to_hoh: 'SELF_HEAD_OF_HOUSEHOLD', + }, + ], + } + expect_access_denied post_graphql(input: input) { mutation } + end + + context 'when the given enrollment IDs come from different households' do + let!(:child) { create :hmis_hud_enrollment, data_source: ds1, project: p1, entry_date: 2.weeks.ago, relationship_to_hoh: 2 } + + it 'fails to process' do + input = { + splitting_enrollment_inputs: [ + { + enrollment_id: new_hoh.id, + relationship_to_hoh: 'SELF_HEAD_OF_HOUSEHOLD', + }, + { + enrollment_id: child.id, + relationship_to_hoh: 'CHILD', + }, + ], + } + expect_gql_error post_graphql(input: input) { mutation } + end + end + + it 'fails when the split would not leave behind any users' do + input = { + splitting_enrollment_inputs: [ + { + enrollment_id: remaining.id, + relationship_to_hoh: 'SELF_HEAD_OF_HOUSEHOLD', + }, + { + enrollment_id: new_hoh.id, + relationship_to_hoh: 'SPOUSE_OR_PARTNER', + }, + { + enrollment_id: child.id, + relationship_to_hoh: 'CHILD', + }, + ], + } + expect_gql_error post_graphql(input: input) { mutation }, message: /Splitting all clients to a new household is invalid/ + end + + it 'fails when the split would leave behind a headless household' do + input = { + splitting_enrollment_inputs: [ + { + enrollment_id: remaining.id, + relationship_to_hoh: 'SELF_HEAD_OF_HOUSEHOLD', + }, + ], + } + expect_gql_error post_graphql(input: input) { mutation }, message: /This operation would leave behind a household with no HoH, which is not allowed/ + end +end diff --git a/drivers/hmis/spec/system/hmis/split_households_workflow_spec.rb b/drivers/hmis/spec/system/hmis/split_households_workflow_spec.rb new file mode 100644 index 00000000000..ac8e075aee8 --- /dev/null +++ b/drivers/hmis/spec/system/hmis/split_households_workflow_spec.rb @@ -0,0 +1,123 @@ +### +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +### + +# Copyright 2016 - 2025 Green River Data Analysis, LLC +# +# License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md +# + +require 'rails_helper' +require_relative '../../requests/hmis/login_and_permissions' +require_relative '../../support/hmis_base_setup' + +RSpec.feature 'Split Households', type: :system do + include_context 'hmis base setup' + let!(:ds1) { create(:hmis_data_source, hmis: 'localhost') } + let!(:access_control) { create_access_control(hmis_user, p1) } + + let!(:donor_household_id) { Hmis::Hud::Base.generate_uuid } + let!(:prior_hoh) { create :hmis_hud_client, data_source: ds1, first_name: 'Apple', last_name: 'Orange' } + let!(:new_hoh) { create :hmis_hud_client, data_source: ds1, first_name: 'Watermelon', last_name: 'Grapefruit' } + let!(:child) { create :hmis_hud_client, data_source: ds1, first_name: 'Pear', last_name: 'Mango' } + + let!(:prior_hoh_e) { create :hmis_hud_enrollment, client: prior_hoh, data_source: ds1, project: p1, household_id: donor_household_id, relationship_to_hoh: 1, entry_date: 2.weeks.ago } + let!(:new_hoh_e) { create :hmis_hud_enrollment, client: new_hoh, data_source: ds1, project: p1, household_id: donor_household_id, relationship_to_hoh: 3, entry_date: 2.weeks.ago } + let!(:child_e) { create :hmis_hud_enrollment, client: child, data_source: ds1, project: p1, household_id: donor_household_id, relationship_to_hoh: 2, entry_date: 2.weeks.ago } + + before(:each) do + sign_in(hmis_user) + visit "/client/#{prior_hoh.id}/enrollments/#{prior_hoh_e.id}/household" + click_link 'Manage Household' + assert_text 'Edit Household' + find("button[aria-label='Action menu for #{new_hoh.brief_name}").click + find("li[aria-label='Split #{new_hoh.brief_name} to new household']").click + assert_text 'STEP 1 Select Clients' + end + + describe 'select clients screen' do + it 'selects the initiator and disallows selecting HoH' do + hoh_checkbox = find("input[aria-label='Select #{prior_hoh_e.id}:#{prior_hoh.id} ']", visible: :all) + expect(hoh_checkbox.disabled?).to be_truthy + expect(hoh_checkbox.checked?).to be_falsey + + new_hoh_checkbox = find("input[aria-label='Select #{new_hoh_e.id}:#{new_hoh.id} ']", visible: :all) + expect(new_hoh_checkbox.checked?).to be_truthy + + child_checkbox = find("input[aria-label='Select #{child_e.id}:#{child.id} ']", visible: :all) + expect(child_checkbox.checked?).to be_falsey + child_checkbox.click + expect(child_checkbox.checked?).to be_truthy + + all('button', text: 'Add Relationships').last.click + assert_text 'STEP 2 Add Relationships' + end + + it 'disables proceeding if you de-select all clients' do + find("input[aria-label='Select #{new_hoh_e.id}:#{new_hoh.id} ']", visible: :all).click + + next_button = all('button', text: 'Add Relationships').last + expect(next_button.disabled?).to be_truthy + end + end + + describe 'add relationships screen' do + before(:each) do + find("input[aria-label='Select #{child_e.id}:#{child.id} ']", visible: :all).click + all('button', text: 'Add Relationships').last.click + assert_text 'STEP 2 Add Relationships' + end + + it 'requires you to enter relationships' do + table = find("table[aria-label='Add Relationships']") + rows = table.first('tbody').all('tr') + next_button = all('button', text: 'Review Split').last + expect(rows.count).to eq(2) + expect(next_button.disabled?).to be_truthy + + # todo @martha - these should be in right order + mui_table_select 'Self (HoH)', row: new_hoh.brief_name, column: 'Relationship', from: table + mui_table_select 'Child', row: child.brief_name, column: 'Relationship', from: table + + next_button = all('button', text: 'Review Split').last + expect(next_button.disabled?).to be_falsey + next_button.click + assert_text 'STEP 3 Review Split' + end + end + + describe 'review join and submit' do + before(:each) do + find("input[aria-label='Select #{child_e.id}:#{child.id} ']", visible: :all).click + all('button', text: 'Add Relationships').last.click + assert_text 'STEP 2 Add Relationships' + table = find("table[aria-label='Add Relationships']") + mui_table_select 'Self (HoH)', row: new_hoh.brief_name, column: 'Relationship', from: table + mui_table_select 'Child', row: child.brief_name, column: 'Relationship', from: table + all('button', text: 'Review Split').last.click + end + + it 'correctly displays the info about the split' do + split_table = find("table[aria-label='Split Household']") + mui_table_expect(new_hoh.brief_name, row_index: 0, column_header: 'Client Name', from: split_table) + mui_table_expect('Self (HoH)', row_index: 0, column_header: 'Relationship', from: split_table) + mui_table_expect(child.brief_name, row_index: 1, column_header: 'Client Name', from: split_table) + mui_table_expect('Child', row_index: 1, column_header: 'Relationship', from: split_table) + + remaining_table = find("table[aria-label='Remaining Household']") + mui_table_expect(prior_hoh.brief_name, row_index: 0, column_header: 'Client Name', from: remaining_table) + mui_table_expect('Self (HoH)', row_index: 0, column_header: 'Relationship', from: remaining_table) + end + + it 'submits the mutation and shows success' do + click_button 'Split Enrollments' + assert_text 'Successful Split' + click_button "Return to #{prior_hoh.brief_name}’s Enrollment" + table = find("table[aria-label='Manage Household']") + rows = table.first('tbody').all('tr') + expect(rows.count).to eq(1) + end + end +end