diff --git a/app/controllers/idt/api/v2/appeals_controller.rb b/app/controllers/idt/api/v2/appeals_controller.rb index 74884231964..b93cf8179dc 100644 --- a/app/controllers/idt/api/v2/appeals_controller.rb +++ b/app/controllers/idt/api/v2/appeals_controller.rb @@ -14,11 +14,11 @@ def details result = if docket_number?(case_search) CaseSearchResultsForDocketNumber.new( docket_number: case_search, user: current_user - ).call + ).api_call else CaseSearchResultsForVeteranFileNumber.new( file_number_or_ssn: case_search, user: current_user - ).call + ).api_call end render_search_results_as_json(result) diff --git a/app/decorators/appeal_status_api_decorator.rb b/app/decorators/appeal_status_api_decorator.rb index a93ed1b9bbf..7be8e8e75aa 100644 --- a/app/decorators/appeal_status_api_decorator.rb +++ b/app/decorators/appeal_status_api_decorator.rb @@ -3,6 +3,12 @@ # Extends the Appeal model with methods for the Appeals Status API class AppealStatusApiDecorator < ApplicationDecorator + def initialize(appeal, scheduled_hearing = nil) + super(appeal) + + @scheduled_hearing = scheduled_hearing + end + def appeal_status_id "A#{id}" end @@ -162,11 +168,11 @@ def remanded_sc_decision_issues end def open_pre_docket_task? - tasks.open.any? { |task| task.is_a?(PreDocketTask) } + open_tasks.any? { |task| task.is_a?(PreDocketTask) } end def pending_schedule_hearing_task? - tasks.open.where(type: ScheduleHearingTask.name).any? + pending_schedule_hearing_tasks.any? end def hearing_pending? @@ -174,7 +180,7 @@ def hearing_pending? end def evidence_submission_hold_pending? - tasks.open.where(type: EvidenceSubmissionWindowTask.name).any? + evidence_submission_hold_pending_tasks.any? end def at_vso? diff --git a/app/models/appeal.rb b/app/models/appeal.rb index 4691f672563..59367fa3a36 100644 --- a/app/models/appeal.rb +++ b/app/models/appeal.rb @@ -308,6 +308,18 @@ def decorated_with_status AppealStatusApiDecorator.new(self) end + def open_tasks + tasks.open + end + + def pending_schedule_hearing_tasks + tasks.open.where(type: ScheduleHearingTask.name) + end + + def evidence_submission_hold_pending_tasks + tasks.open.where(type: EvidenceSubmissionWindowTask.name) + end + # :reek:RepeatedConditionals def active_request_issues_or_decision_issues decision_issues.empty? ? active_request_issues : fetch_all_decision_issues @@ -633,7 +645,7 @@ def direct_review_docket? end def active? - tasks.open.of_type(:RootTask).any? + open_tasks.of_type(:RootTask).any? end def ready_for_distribution? @@ -748,7 +760,7 @@ def substitutions end def status - @status ||= BVAAppealStatus.new(appeal: self) + @status ||= BVAAppealStatus.new(tasks: tasks) end def previously_selected_for_quality_review diff --git a/app/models/hearing.rb b/app/models/hearing.rb index 6008a49d157..7ad17f1b3e1 100644 --- a/app/models/hearing.rb +++ b/app/models/hearing.rb @@ -187,12 +187,15 @@ def advance_on_docket_motion .first end + def scheduled_for + scheduled_for_hearing_day(hearing_day, updated_by, regional_office_timezone) + end + # returns scheduled datetime object considering the timezones # @return [nil] if hearing_day is nil # @return [Time] in scheduled_in_timezone timezone - if scheduled_datetime and scheduled_in_timezone are present # @return [Time] else datetime in regional office timezone - # rubocop:disable Metrics/AbcSize - def scheduled_for + def scheduled_for_hearing_day(hearing_day, updated_by, regional_office_timezone) return nil unless hearing_day # returns datetime in scheduled_in_timezone timezone @@ -234,7 +237,6 @@ def scheduled_for ) end end - # rubocop:enable Metrics/AbcSize def scheduled_for_past? scheduled_for < DateTime.yesterday.in_time_zone(regional_office_timezone) diff --git a/app/models/task.rb b/app/models/task.rb index b2244e0f1b0..4c0b7066290 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -101,7 +101,7 @@ class << self; undef_method :open; end # Equivalent to .reject(&:hide_from_queue_table_view) but offloads that to the database. scope :visible_in_queue_table_view, lambda { where.not( - type: Task.descendants.select(&:hide_from_queue_table_view).map(&:name) + type: hidden_task_classes ) } @@ -138,6 +138,10 @@ class << self # With taks that are likely to need Reader to complete READER_PRIORITY_TASK_TYPES = [JudgeAssignTask.name, JudgeDecisionReviewTask.name].freeze + def hidden_task_classes + Task.descendants.select(&:hide_from_queue_table_view).map(&:name) + end + def reader_priority_task_types READER_PRIORITY_TASK_TYPES end diff --git a/app/models/tasks/evidence_submission_window_task.rb b/app/models/tasks/evidence_submission_window_task.rb index 7c9cdb5f8fa..0028fc54366 100644 --- a/app/models/tasks/evidence_submission_window_task.rb +++ b/app/models/tasks/evidence_submission_window_task.rb @@ -11,7 +11,7 @@ class EvidenceSubmissionWindowTask < Task before_validation :set_assignee - def initialize(args) + def initialize(args = {}) @end_date = args&.fetch(:end_date, nil) super(args&.except(:end_date)) end diff --git a/app/services/bva_appeal_status.rb b/app/services/bva_appeal_status.rb index 1577a7ea7c6..c25886c15a3 100644 --- a/app/services/bva_appeal_status.rb +++ b/app/services/bva_appeal_status.rb @@ -3,7 +3,7 @@ # Determine the BVA workflow status of an Appeal (symbol and string) based on its Tasks. class BVAAppealStatus - attr_reader :status + attr_reader :status, :tasks SORT_KEYS = { not_distributed: 1, @@ -69,8 +69,18 @@ def attorney_task_names end end - def initialize(appeal:) - @appeal = appeal + Tasks = Struct.new( + :open, + :active, + :in_progress, + :cancelled, + :completed, + :assigned, + keyword_init: true + ) + + def initialize(tasks:) + @tasks = tasks @status = compute end @@ -86,15 +96,12 @@ def to_i SORT_KEYS[status] end - def as_json(_args) + def as_json(_args = nil) to_sym end private - attr_reader :appeal - - delegate :tasks, to: :appeal # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength def compute if open_pre_docket_task? @@ -113,7 +120,7 @@ def compute :ready_for_signature elsif active_sign_task? :signed - elsif completed_dispatch_task? && open_tasks.empty? + elsif completed_dispatch_task? && tasks.open.empty? :dispatched elsif completed_dispatch_task? :post_dispatch @@ -133,84 +140,60 @@ def compute end # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength - def open_tasks - @open_tasks ||= tasks.open - end - - def active_tasks - @active_tasks ||= tasks.active - end - - def assigned_tasks - @assigned_tasks ||= tasks.assigned - end - - def in_progress_tasks - @in_progress_tasks ||= tasks.in_progress - end - - def cancelled_tasks - @cancelled_tasks ||= tasks.cancelled - end - - def completed_tasks - @completed_tasks ||= tasks.completed - end - def open_pre_docket_task? - open_tasks.any? { |task| task.is_a?(PreDocketTask) } + tasks.open.any? { |task| task.type == "PreDocketTask" } end def open_distribution_task? - open_tasks.any? { |task| task.is_a?(DistributionTask) } + tasks.open.any? { |task| task.type == "DistributionTask" } end def open_timed_hold_task? - open_tasks.any? { |task| task.is_a?(TimedHoldTask) } + tasks.open.any? { |task| task.type == "TimedHoldTask" } end def active_judge_assign_task? - active_tasks.any? { |task| task.is_a?(JudgeAssignTask) } + tasks.active.any? { |task| task.type == "JudgeAssignTask" } end def assigned_attorney_task? - assigned_tasks.any? { |task| self.class.attorney_task_names.include?(task.type) } + tasks.assigned.any? { |task| self.class.attorney_task_names.include?(task.type) } end def active_colocated_task? - active_tasks.any? { |task| self.class.colocated_task_names.include?(task.type) } + tasks.active.any? { |task| self.class.colocated_task_names.include?(task.type) } end def attorney_task_in_progress? - in_progress_tasks.any? { |task| self.class.attorney_task_names.include?(task.type) } + tasks.in_progress.any? { |task| self.class.attorney_task_names.include?(task.type) } end def active_judge_decision_review_task? - active_tasks.any? { |task| task.is_a?(JudgeDecisionReviewTask) } + tasks.active.any? { |task| task.type == "JudgeDecisionReviewTask" } end def active_sign_task? - active_tasks.any? { |task| %w[BvaDispatchTask QualityReviewTask].include?(task.type) } + tasks.active.any? { |task| %w[BvaDispatchTask QualityReviewTask].include?(task.type) } end def completed_dispatch_task? - completed_tasks.any? { |task| task.is_a?(BvaDispatchTask) } + tasks.completed.any? { |task| task.type == "BvaDispatchTask" } end def docket_switched? # TODO: this should be updated to check that there are no active tasks once the task handling is implemented - completed_tasks.any? { |task| task.is_a?(DocketSwitchGrantedTask) } + tasks.completed.any? { |task| task.type == "DocketSwitchGrantedTask" } end def cancelled_root_task? - cancelled_tasks.any? { |task| task.is_a?(RootTask) } + tasks.cancelled.any? { |task| task.type == "RootTask" } end def misc_task? - active_tasks.any? { |task| self.class.misc_task_names.include?(task.type) } + tasks.active.any? { |task| self.class.misc_task_names.include?(task.type) } end def active_specialty_case_team_assign_task? - active_tasks.any? { |task| task.is_a?(SpecialtyCaseTeamAssignTask) } + tasks.active.any? { |task| task.type == "SpecialtyCaseTeamAssignTask" } end end diff --git a/app/services/search_query_service.rb b/app/services/search_query_service.rb new file mode 100644 index 00000000000..f3434d4c9a6 --- /dev/null +++ b/app/services/search_query_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +class SearchQueryService + def initialize(file_number: nil, docket_number: nil, veteran_ids: nil) + @docket_number = docket_number + @file_number = file_number + @veteran_ids = veteran_ids + @queries = SearchQueryService::Query.new + end + + def search_by_veteran_file_number + combined_results + end + + def search_by_docket_number + results = ActiveRecord::Base.connection.exec_query( + sanitize( + queries.docket_number_query(docket_number) + ) + ) + + results.map do |row| + AppealRow.new(row).search_response + end + end + + def search_by_veteran_ids + combined_results + end + + private + + attr_reader :docket_number, :file_number, :queries, :veteran_ids + + def combined_results + search_results.map do |row| + if row["type"] != "legacy_appeal" + AppealRow.new(row).search_response + else + vacols_row = vacols_results.find { |result| result["vacols_id"] == row["external_id"] } + + Rails.logger.warn(no_vacols_record_warning(result)) if vacols_row.blank? + LegacyAppealRow.new(row, vacols_row || null_vacols_row).search_response + end + end + end + + def null_vacols_row + {} + end + + def no_vacols_record_warning(result) + <<-WARN + No corresponding VACOLS record found for appeal with: + id: #{result['id']} + vacols_id: #{result['vacols_id']} + searching with: + #{file_number.present? "file_number #{file_number}"} \ + #{veteran_ids.present? "veteran_ids #{veteran_ids.join(',')}"} \ + #{file_number.present? "docket_number #{docket_number}"} + WARN + end + + def vacols_ids + legacy_results.map { |result| result["external_id"] } + end + + def legacy_results + search_results.select { |result| result["type"] == "legacy_appeal" } + end + + def search_results + @search_results ||= + if file_number.present? + file_number_search_results + else + veteran_ids_search_results + end + end + + def veteran_ids_search_results + ActiveRecord::Base + .connection + .exec_query( + sanitize(queries.veteran_ids_query(veteran_ids)) + ) + .uniq { |result| result["external_id"] } + end + + def file_number_search_results + ActiveRecord::Base + .connection + .exec_query(file_number_or_ssn_query) + .uniq { |result| result["external_id"] } + end + + def file_number_or_ssn_query + sanitize( + queries.veteran_file_number_query(file_number) + ) + end + + def vacols_results + @vacols_results ||= begin + vacols_query = VACOLS::Record.sanitize_sql_array(queries.vacols_query(vacols_ids)) + VACOLS::Record.connection.exec_query(vacols_query) + end + end + + def sanitize(values) + ActiveRecord::Base.sanitize_sql_array(values) + end +end diff --git a/app/services/search_query_service/api_response.rb b/app/services/search_query_service/api_response.rb new file mode 100644 index 00000000000..76a216b678e --- /dev/null +++ b/app/services/search_query_service/api_response.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +SearchQueryService::ApiResponse = Struct.new(:id, :type, :attributes, keyword_init: true) diff --git a/app/services/search_query_service/appeal_row.rb b/app/services/search_query_service/appeal_row.rb new file mode 100644 index 00000000000..1f38c08966b --- /dev/null +++ b/app/services/search_query_service/appeal_row.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +class SearchQueryService::AppealRow + def initialize(query_row) + @query_row = query_row + end + + def search_response + SearchQueryService::SearchResponse.new( + queried_appeal, + :appeal, + SearchQueryService::ApiResponse.new( + id: query_row["id"], + type: "appeal", + attributes: attributes + ) + ) + end + + private + + attr_reader :query_row + + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize + def attributes + SearchQueryService::Attributes.new( + aod: aod, + appellant_full_name: appellant_full_name, + appellant_date_of_birth: appellant_date_of_birth, + appellant_email_address: appellant_person["email_address"], + appellant_first_name: appellant_person["first_name"], + appellant_hearing_email_recipient: json_array("hearing_email_recipient").first, + appellant_is_not_veteran: !!queried_appeal.veteran_is_not_claimant, + appellant_last_name: appellant_person["last_name"], + appellant_middle_name: appellant_person["middle_name"], + appellant_party_type: appellant_party_type, + appellant_phone_number: appellant_phone_number, + appellant_relationship: nil, + appellant_substitution: nil, + appellant_suffix: appellant_person["name_suffix"], + appellant_type: appellant&.type, + appellant_tz: nil, + assigned_to_location: queried_appeal.assigned_to_location, + assigned_attorney: assigned_attorney, + assigned_judge: assigned_judge, + caseflow_veteran_id: query_row["veteran_id"], + contested_claim: contested_claim, + decision_date: decision_date, + decision_issues: decision_issues, + docket_name: query_row["docket_type"], + docket_number: docket_number, + external_id: query_row["external_id"], + hearings: hearings, + issues: issues, + mst: mst_status, + overtime: query_row["overtime"], + pact: pact_status, + paper_case: false, + readable_hearing_request_type: readable_hearing_request_type, + readable_original_hearing_request_type: readable_original_hearing_request_type, + status: queried_appeal.status, + type: stream_type, + veteran_appellant_deceased: veteran_appellant_deceased, + veteran_file_number: veteran_file_number, + veteran_full_name: veteran_full_name, + withdrawn: withdrawn + ) + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + + def appellant_date_of_birth + if appellant.person.present? + Date.parse appellant.person.try(:[], "date_of_birth") + end + rescue TypeError + nil + end + + def appellant_party_type + appellant&.unrecognized_party_details.try(:[], "party_type") + end + + def appellant_phone_number + appellant&.unrecognized_party_details.try(:[], "phone_number") + end + + def aod + queried_appeal.advanced_on_docket_based_on_age? || query_row["aod_granted_for_person"] + end + + def appellant_person + appellant.person || {} + end + + def appellant + queried_appeal.claimant + end + + def decision_issues + json_array("decision_issues") + end + + def docket_number + attrs, = JSON.parse query_row["appeal"] + attrs["stream_docket_number"] + end + + def decision_date + Date.parse(query_row["decision_date"]) + rescue TypeError, Date::Error + nil + end + + def appellant_full_name + FullName.new(query_row["person_first_name"], "", query_row["person_last_name"]).to_s + end + + def veteran_full_name + FullName.new(query_row["veteran_first_name"], "", query_row["veteran_last_name"]).to_s + end + + def veteran_file_number + attrs, = JSON.parse(query_row["appeal"]) + attrs["veteran_file_number"] + end + + def clean_issue_attributes!(attributes) + unless FeatureToggle.enabled?(:pact_identification) + attributes.delete("pact_status") + end + unless FeatureToggle.enabled?(:mst_identification) + attributes.delete("mst_status") + end + end + + def issues + json_array("request_issues").map do |attributes| + attributes.tap do |attrs| + clean_issue_attributes!(attrs) + end + end + end + + def hearings + json_array("hearings").map do |attrs| + AppealHearingSerializer.new( + SearchQueryService::QueriedHearing.new(attrs), + { user: RequestStore[:current_user] } + ).serializable_hash[:data][:attributes] + end + end + + def withdrawn + WithdrawnDecisionReviewPolicy.new( + Struct.new( + :active_request_issues, + :withdrawn_request_issues + ).new( + json_array("active_request_issues"), + json_array("active_request_issues") + ) + ).satisfied? + end + + def stream_type + (query_row["stream_type"] || "Original").titleize + end + + def contested_claim + json_array("active_request_issues").any? do |issue| + %w(Contested Apportionment).any? do |code| + category = issue["nonrating_issue_category"] || "" + category.include?(code) + end + end + end + + def veteran_appellant_deceased + !!query_row["date_of_death"] && !json_array("appeal").first["veteran_is_not_claimant"] + end + + def pact_status + json_array("decision_issues").any? do |issue| + issue["pact_status"] + end + end + + def mst_status + json_array("decision_issues").any? do |issue| + issue["mst_status"] + end + end + + def readable_original_hearing_request_type + queried_appeal.readable_original_hearing_request_type + end + + def readable_hearing_request_type + queried_appeal.readable_current_hearing_request_type + end + + def queried_appeal + @queried_appeal ||= begin + appeal_attrs, = JSON.parse query_row["appeal"] + + SearchQueryService::QueriedAppeal.new( + attributes: appeal_attrs, + tasks_attributes: json_array("tasks"), + hearings_attributes: json_array("hearings") + ) + end + end + + def assigned_attorney + json_array("assigned_attorney").first + end + + def assigned_judge + json_array("assigned_judge").first + end + + def json_array(key) + JSON.parse(query_row[key] || "[]") + end +end diff --git a/app/services/search_query_service/attributes.rb b/app/services/search_query_service/attributes.rb new file mode 100644 index 00000000000..d25485b4282 --- /dev/null +++ b/app/services/search_query_service/attributes.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +SearchQueryService::Attributes = Struct.new( + :aod, + :power_of_attorney, + :appellant_date_of_birth, + :appellant_email_address, + :appellant_first_name, + :appellant_full_name, + :appellant_hearing_email_recipient, + :appellant_is_not_veteran, + :appellant_last_name, + :appellant_middle_name, + :appellant_party_type, + :appellant_phone_number, + :appellant_relationship, + :appellant_substitution, + :appellant_suffix, + :appellant_type, + :appellant_tz, + :assigned_attorney, + :assigned_judge, + :assigned_to_location, + :caseflow_veteran_id, + :decision_date, + :decision_issues, + :docket_name, + :docket_number, + :external_id, + :hearings, + :issues, + :mst, + :overtime, + :pact, + :paper_case, + :readable_hearing_request_type, + :readable_original_hearing_request_type, + :status, + :type, + :veteran_appellant_deceased, + :veteran_file_number, + :veteran_full_name, + :contested_claim, + :withdrawn, + keyword_init: true +) diff --git a/app/services/search_query_service/legacy_appeal_row.rb b/app/services/search_query_service/legacy_appeal_row.rb new file mode 100644 index 00000000000..3b5e15f0847 --- /dev/null +++ b/app/services/search_query_service/legacy_appeal_row.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +class SearchQueryService::LegacyAppealRow + def initialize(search_row, vacols_row) + @search_row = search_row + @vacols_row = vacols_row + end + + def search_response + SearchQueryService::SearchResponse.new( + legacy_appeal, + :legacy_appeal, + SearchQueryService::ApiResponse.new( + id: search_row["id"], + type: "legacy_appeal", + attributes: attributes + ) + ) + end + + private + + attr_reader :search_row, :vacols_row + + # rubocop:disable Metrics/MethodLength + def attributes + SearchQueryService::LegacyAttributes.new( + aod: aod, + appellant_full_name: appellant_full_name, + assigned_attorney: assigned_attorney, + assigned_judge: assigned_judge, + assigned_to_location: vacols_row["bfcurloc"], + caseflow_veteran_id: search_row["veteran_id"], + decision_date: decision_date, + docket_name: "legacy", + docket_number: vacols_row["tinum"], + external_id: vacols_row["vacols_id"], + hearings: hearings, + issues: issues, + mst: mst, + overtime: search_row["overtime"], + pact: pact, + paper_case: paper_case, + readable_hearing_request_type: readable_hearing_request_type, + readable_original_hearing_request_type: readable_original_hearing_request_type, + status: status, + type: stream_type, + veteran_appellant_deceased: veteran_appellant_deceased, + veteran_file_number: search_row["veteran_file_number"], + veteran_full_name: veteran_full_name + ) + end + # rubocop:enable Metrics/MethodLength + + def hearings + vacols_json_array("hearings").map do |attrs| + HearingAttributes.new(attrs).call + end + end + + class HearingAttributes + def initialize(attributes) + @attributes = attributes + end + + def call + { + disposition: VACOLS::CaseHearing::HEARING_DISPOSITIONS[attributes["disposition"].try(:to_sym)], + request_type: attributes["type"], + appeal_type: VACOLS::Case::TYPES[attributes["bfac"]], + external_id: attributes["external_id"], + held_by: held_by, + is_virtual: false, + notes: attributes["notes"], + type: type, + created_at: nil, + scheduled_in_timezone: nil, + date: HearingMapper.datetime_based_on_type( + datetime: attributes["date"], + regional_office: regional_office(attributes["venue"]), + type: attributes["type"] + ) + } + end + + private + + attr_reader :attributes + + def type + Hearing::HEARING_TYPES[attributes["hearing_type"]&.to_sym] + end + + def held_by + fname = attributes["held_by_first_name"] + lname = attributes["held_by_last_name"] + + if fname.present? && lname.present? + "#{fname} #{lname}" + end + end + + def regional_office(ro_key) + RegionalOffice.find!(ro_key) + rescue NotFoundError + nil + end + end + + def issues + vacols_json_array("issues").map do |attrs| + WorkQueue::LegacyIssueSerializer.new( + Issue.load_from_vacols(attrs) + ).serializable_hash[:data][:attributes] + end + end + + def assigned_attorney + json_array("assigned_attorney").first + end + + def assigned_judge + json_array("assigned_judge").first + end + + def json_array(key) + JSON.parse(search_row[key] || "[]") + end + + def vacols_json_array(key) + JSON.parse(vacols_row[key] || "[]") + end + + def veteran_appellant_deceased + search_row["date_of_death"].present? && + search_row["person_first_name"].present? + end + + def stream_type + VACOLS::Case::TYPES[vacols_row["bfac"]] + end + + def status + VACOLS::Case::STATUS[vacols_row["bfmpro"]] + end + + def paper_case + folder = Struct.new(:tivbms, :tisubj2).new( + vacols_row["tivbms"], + vacols_row["tisubj2"] + ) + AppealRepository.folder_type_from(folder) + end + + def mst + vacols_row["issues_mst_count"].to_i > 0 + end + + def pact + vacols_row["issues_pact_count"].to_i > 0 + end + + def appellant_full_name + FullName.new(vacols_row["sspare2"], "", vacols_row["sspare1"]).to_s + end + + def veteran_full_name + FullName.new(vacols_row["snamef"], "", vacols_row["snamel"]).to_s + end + + def aod + vacols_row["aod"].to_i == 1 + end + + def decision_date + AppealRepository.normalize_vacols_date(vacols_row["bfddec"]) + end + + def readable_original_hearing_request_type + legacy_appeal.readable_original_hearing_request_type + end + + def readable_hearing_request_type + legacy_appeal.readable_current_hearing_request_type + end + + def legacy_appeal + @legacy_appeal ||= begin + appeal_attrs, = JSON.parse search_row["appeal"] + SearchQueryService::QueriedLegacyAppeal.new(attributes: appeal_attrs) + end + end +end diff --git a/app/services/search_query_service/legacy_attributes.rb b/app/services/search_query_service/legacy_attributes.rb new file mode 100644 index 00000000000..7d67a0db664 --- /dev/null +++ b/app/services/search_query_service/legacy_attributes.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +SearchQueryService::LegacyAttributes = Struct.new( + :aod, + :appellant_full_name, + :assigned_attorney, + :assigned_judge, + :assigned_to_location, + :caseflow_veteran_id, + :decision_date, + :docket_name, + :docket_number, + :external_id, + :hearings, + :issues, + :mst, + :overtime, + :pact, + :paper_case, + :readable_hearing_request_type, + :readable_original_hearing_request_type, + :status, + :type, + :veteran_appellant_deceased, + :veteran_file_number, + :veteran_full_name, + :withdrawn, + keyword_init: true +) diff --git a/app/services/search_query_service/queried_appeal.rb b/app/services/search_query_service/queried_appeal.rb new file mode 100644 index 00000000000..0fad5583ce4 --- /dev/null +++ b/app/services/search_query_service/queried_appeal.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +class SearchQueryService::QueriedAppeal < SimpleDelegator + def initialize(attributes:, tasks_attributes:, hearings_attributes:) + @attributes = OpenStruct.new( + appeal: attributes, + tasks: tasks_attributes, + hearings: hearings_attributes, + root_task: attributes.delete("root_task") || {}, + claimants: attributes.delete("claimants") || [] + ) + + super(appeal) + end + + def assigned_to_location + return COPY::CASE_LIST_TABLE_POST_DECISION_LABEL if root_task&.status == Constants.TASK_STATUSES.completed + + return most_recently_updated_visible_task.assigned_to_label if most_recently_updated_visible_task + + # this condition is no longer needed since we only want active or on hold tasks + return most_recently_updated_task&.assigned_to_label if most_recently_updated_task.present? + + fetch_api_status + end + + def claimant_participant_ids + claimants.map(&:participant_id) + end + + def claimant + claimants.max_by(&:id) + end + + def claimants + @claimants ||= begin + attributes.claimants.map do |attrs| + OpenStruct.new(attrs) + end + end + end + + def root_task + @root_task ||= begin + if attributes.root_task.present? + Task.new.tap do |task| + task.assign_attributes attributes.root_task + end + end + end + end + + def advanced_on_docket_based_on_age? + claimant&.date_of_birth.present? && Date.parse(claimant.date_of_birth) < 75.years.ago + end + + def open_tasks + @open_tasks ||= tasks.select do |task| + Task.open_statuses.include?(task.status) + end + end + + def active? + Task.active_statuses.include?(attributes.root_task["status"]) + end + + def pending_schedule_hearing_tasks + open_tasks.select { |task| task.type == "ScheduleHearingTask" } + end + + def evidence_submission_hold_pending_tasks + open_tasks.select { |task| task.type == "EvidenceSubmissionWindowTask" } + end + + def status + BVAAppealStatus.new( + tasks: BVAAppealStatus::Tasks.new( + open: tasks.select(&:open?), + active: tasks.select(&:active?), + in_progress: tasks.select(&:in_progress?), + cancelled: tasks.select(&:cancelled?), + completed: tasks.select(&:completed?), + assigned: tasks.select(&:assigned?) + ) + ).status + end + + private + + attr_reader :attributes + + def appeal + @appeal ||= Appeal.new.tap do |appeal| + appeal.assign_attributes(attributes.appeal) + end + end + + def most_recently_updated_visible_task + visible_tasks.select { |task| Task.active_statuses.include?(task.status) }.max_by(&:updated_at) || + visible_tasks.select { |task| task.status == "on_hold" }.max_by(&:updated_at) + end + + def visible_tasks + @visible_tasks ||= tasks.reject do |task| + Task.hidden_task_classes.include?(task.type) + end + end + + def most_recently_updated_task + tasks.max_by(&:updated_at) + end + + def tasks + @tasks ||= begin + attributes.tasks.map do |attrs| + attrs["type"].constantize.new.tap do |task| + task.assign_attributes attrs + end + end + end + end + + def fetch_api_status + AppealStatusApiDecorator.new( + self, + scheduled_hearing + ).fetch_status.to_s.titleize.to_sym + end + + def scheduled_hearing + @scheduled_hearing ||= begin + hearings = attributes.hearings.map do |attrs| + SearchQueryService::QueriedHearing.new(attrs) + end + + hearings.reject(&:disposition).find do |hearing| + hearing.scheduled_for >= Time.zone.today + end + end + end +end diff --git a/app/services/search_query_service/queried_hearing.rb b/app/services/search_query_service/queried_hearing.rb new file mode 100644 index 00000000000..56eb71d03fe --- /dev/null +++ b/app/services/search_query_service/queried_hearing.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +class SearchQueryService::QueriedHearing < SimpleDelegator + def initialize(attributes) + @attributes = attributes + + manage_attributes! + + super(hearing) + end + + def judge + @judge ||= + if judge_attributes.present? + User.new.tap do |j| + j.assign_attributes judge_attributes + end + end + end + + def hearing_views + @hearing_views ||= + if views_attributes.present? + views_attributes.map do |view_attrs| + HearingView.new.tap do |v| + v.assign_attributes view_attrs + end + end + else + [] + end + end + + def readable_request_type + Hearing::HEARING_TYPES[hearing_day&.request_type&.to_sym] + end + + def hearing_day + @hearing_day ||= + if hearing_day_attributes.present? + HearingDay.new.tap do |hd| + hd.assign_attributes hearing_day_attributes + end + end + end + + def updated_by + @updated_by ||= + if updated_by_attributes.present? + User.new.tap do |u| + u.assign_attributes updated_by_attributes + end + end + end + + def virtual? + %w(pending active closed).include?( + virtual_hearing&.status + ) + end + + def scheduled_for + scheduled_for_hearing_day(hearing_day, updated_by, regional_office_timezone) + end + + private + + attr_reader( + :attributes, + :hearing_day_attributes, + :updated_by_attributes, + :views_attributes, + :virtual_hearing_attributes, + :judge_attributes + ) + + def regional_office_timezone + RegionalOffice.find!(hearing_day.regional_office || "C").timezone + end + + def virtual_hearing + @virtual_hearing ||= + if virtual_hearing_attributes.present? + VirtualHearing.new.tap do |vh| + vh.assign_attributes virtual_hearing_attributes + end + end + end + + def hearing + Hearing.new.tap do |hearing| + hearing.assign_attributes attributes + end + end + + def manage_attributes! + @hearing_day_attributes = attributes.delete("hearing_day") + @updated_by_attributes = attributes.delete("updated_by") + @views_attributes = attributes.delete("views") || [] + @virtual_hearing_attributes = attributes.delete("virtual_hearing") + @judge_attributes = attributes.delete("judge") + end +end diff --git a/app/services/search_query_service/queried_legacy_appeal.rb b/app/services/search_query_service/queried_legacy_appeal.rb new file mode 100644 index 00000000000..50aaab31a7f --- /dev/null +++ b/app/services/search_query_service/queried_legacy_appeal.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class SearchQueryService::QueriedLegacyAppeal < SimpleDelegator + def initialize(attributes:) + @attributes = attributes + @root_task_attributes = attributes.delete("root_task") + @veteran_attributes = attributes.delete("veteran") + + super(legacy_appeal) + end + + def veteran + @veteran ||= Veteran.new.tap do |veteran| + veteran.assign_attributes veteran_attributes + end + end + + def root_task + @root_task ||= begin + if root_task_attributes + RootTask.new.tap do |root_task| + root_task.assign_attributes root_task_attributes + end + end + end + end + + def claimant_participant_ids + veteran.participant_id + end + + private + + attr_reader :attributes, :root_task_attributes, :veteran_attributes + + def legacy_appeal + @legacy_appeal ||= LegacyAppeal.new.tap do |appeal| + appeal.assign_attributes(attributes) + end + end +end diff --git a/app/services/search_query_service/query.rb b/app/services/search_query_service/query.rb new file mode 100644 index 00000000000..31eab57a1dd --- /dev/null +++ b/app/services/search_query_service/query.rb @@ -0,0 +1,452 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ClassLength +class SearchQueryService::Query + def initialize + @vacols = SearchQueryService::VacolsQuery.new + end + + def docket_number_query(docket_number) + query = <<-SQL + #{appeals_internal_query} + where a.stream_docket_number=?; + SQL + [query, docket_number] + end + + def veteran_file_number_query(file_number) + query = <<-SQL + ( + #{appeals_internal_query} + where v.ssn=? or v.file_number=? + ) + UNION + ( + #{legacy_appeals_internal_query} + where v.ssn=? or v.file_number=? + ) + SQL + num_params = 4 + [query, *[file_number].cycle(num_params).to_a] + end + + def veteran_ids_query(veteran_id) + query = <<-SQL + ( + #{appeals_internal_query} + where v.id in (?) + ) + UNION + ( + #{legacy_appeals_internal_query} + where v.id in (?) + ) + SQL + [query, veteran_id, veteran_id] + end + + def vacols_query(vacols_ids) + [vacols.query, vacols_ids] + end + + private + + attr_reader :vacols + + def legacy_appeals_internal_query + <<-SQL + select + a.id, + a.vacols_id external_id, + 'legacy_appeal' type, + null aod_granted_for_person, + 'legacy' docket_type, + ( + select + jsonb_agg(la2) + from + ( + select + la.*, + ( + select + row_to_json(t.*) + from tasks t + where + t.appeal_id=a.id and + t.type='RootTask' and + t.appeal_type='Appeal' + order by t.updated_at desc + limit 1 + ) root_task, + (select row_to_json(v.*)) veteran + from legacy_appeals la + where la.id=a.id + ) la2 + ) appeal, + dd.decision_date, + wm.overtime, + pp.first_name person_first_name, + pp.last_name person_last_name, + v.id veteran_id, + v.first_name veteran_first_name, + v.last_name veteran_last_name, + v.file_number as veteran_file_number, + v.date_of_death, + ( + select jsonb_agg(her2) from + ( + select + her.* + from hearing_email_recipients her + where + her.appeal_type = 'LegacyAppeal' and + her.appeal_id = a.id and + her.type = 'AppellantHearingEmailRecipient' + limit 1 + ) her2 + ) hearing_email_recipient, + ( + select jsonb_agg(u2) from + ( + select + u.* + from tasks t + left join users u on + t.assigned_to_id=u.id and + t.assigned_to_type='User' + where + t.appeal_type = 'LegacyAppeal' and + t.appeal_id=a.id and + t.type in ('#{attorney_task_classes.join("','")}') and + t.status != '#{Constants.TASK_STATUSES.cancelled}' + order by t.created_at desc + limit 1 + ) u2 + ) assigned_attorney, + ( + select jsonb_agg(u2) from + ( + select + u.* + from tasks t + left join users u on + t.assigned_to_id=u.id and + t.assigned_to_type='User' + where + t.appeal_type = 'LegacyAppeal' and + t.appeal_id=a.id and + t.type in ('#{judge_task_classes.join("','")}') and + t.status != '#{Constants.TASK_STATUSES.cancelled}' + order by t.created_at desc + limit 1 + ) u2 + ) assigned_judge, + null request_issues, + null active_request_issues, + null withdrawn_request_issues, + null decision_issues, + null hearings_count, + '[]' hearings, + ( + select jsonb_agg(t2) from + ( + select + t.* + from tasks t + left join organizations o on o.id=t.assigned_to_id + left join users u on u.id=t.assigned_to_id + where + t.appeal_id=a.id and + t.appeal_type='LegacyAppeal' + order by updated_at desc + ) t2 + ) tasks + from legacy_appeals a + left join claimants cl on cl.decision_review_id=a.id and cl.decision_review_type='LegacyAppeal' + left join people pp on cl.participant_id=pp.participant_id + left join work_modes wm on wm.appeal_id=a.id and wm.appeal_type='LegacyAppeal' + left join decision_documents dd on dd.appeal_id=a.id and dd.appeal_type='LegacyAppeal' + left join veterans v on v.file_number=( + select + case + when right(a.vbms_id, 1) = 'C' then lpad(regexp_replace(a.vbms_id, '[^0-9]+', '', 'g'), 8, '0') + else regexp_replace(a.vbms_id, '[^0-9]+', '', 'g') + end + ) or v.ssn=( + select + case + when right(a.vbms_id, 1) = 'C' then lpad(regexp_replace(a.vbms_id, '[^0-9]+', '', 'g'), 8, '0') + else regexp_replace(a.vbms_id, '[^0-9]+', '', 'g') + end + ) + SQL + end + + def appeals_internal_query + <<-SQL + select + a.id, + a.uuid::varchar external_id, + a.stream_type type, + aod.granted aod_granted_for_person, + a.docket_type, + ( + select jsonb_agg(a2) + from + ( + select + appeals.*, + ( + select + row_to_json(t.*) + from tasks t + where + t.appeal_id=a.id and + t.type='RootTask' and + t.appeal_type='Appeal' + order by updated_at desc + limit 1 + ) root_task, + ( + select jsonb_agg(c2) from + ( + select + c.*, + row_to_json(p.*) person, + row_to_json(ua.*) unrecognized_appellants, + row_to_json(upd.*) unrecognized_party_details + from claimants c + left join unrecognized_appellants ua on + c.id = ua.claimant_id + left join unrecognized_party_details upd on + ua.unrecognized_party_detail_id = upd.id + left join people p on + c.participant_id = p.participant_id + where + c.decision_review_type = 'Appeal' and + c.decision_review_id=a.id + ) c2 + ) claimants + from appeals + where id=a.id + ) a2 + ) appeal, + dd.decision_date, + wm.overtime, + pp.first_name person_first_name, + pp.last_name person_last_name, + v.id veteran_id, + v.first_name veteran_first_name, + v.last_name veteran_last_name, + v.file_number as veteran_file_number, + v.date_of_death, + ( + select jsonb_agg(her2) from + ( + select + her.* + from hearing_email_recipients her + where + her.appeal_type = 'Appeal' and + her.appeal_id = a.id and + her.type = 'AppellantHearingEmailRecipient' + limit 1 + ) her2 + ) hearing_email_recipient, + ( + select jsonb_agg(u2) from + ( + select + u.* + from tasks t + left join users u on + t.assigned_to_id=u.id and + t.assigned_to_type='User' + where + t.appeal_type = 'Appeal' and + t.appeal_id=a.id and + t.type in ('#{attorney_task_classes.join("','")}') and + t.status != '#{Constants.TASK_STATUSES.cancelled}' + order by t.created_at desc + limit 1 + ) u2 + ) assigned_attorney, + ( + select jsonb_agg(u2) from + ( + select + u.* + from tasks t + left join users u on + t.assigned_to_id=u.id and + t.assigned_to_type='User' + where + t.appeal_type = 'Appeal' and + t.appeal_id=a.id and + t.type in ('#{judge_task_classes.join("','")}') and + t.status != '#{Constants.TASK_STATUSES.cancelled}' + order by t.created_at desc + limit 1 + ) u2 + ) assigned_judge, + ( + select jsonb_agg(ri2) from + ( + select + ri.id, + ri.benefit_type program, + ri.notes, + ri.decision_date, + ri.nonrating_issue_category, + ri.mst_status, + ri.pact_status, + ri.mst_status_update_reason_notes mst_justification, + ri.pact_status_update_reason_notes pact_justification + from request_issues ri + where + ri.decision_review_type='Appeal' and + ri.decision_review_id=a.id + ) ri2 + ) request_issues, + ( + select jsonb_agg(ri2) from + ( + select + nonrating_issue_category + from request_issues ri + where + ri.ineligible_reason is null and + ri.closed_at is null and + (ri.split_issue_status is null or ri.split_issue_status = 'in_progress') and + ri.decision_review_type='Appeal' and + ri.decision_review_id=a.id + ) ri2 + ) active_request_issues, + ( + select jsonb_agg(ri2) from + ( + select + nonrating_issue_category + from request_issues ri + where + ri.ineligible_reason is null and + ri.closed_status = 'widthrawn' and + ri.decision_review_type='Appeal' and + ri.decision_review_id=a.id + ) ri2 + ) withdrawn_request_issues, + ( + select jsonb_agg(di2) from + ( + select + di.id, + di.disposition, + di.description, + di.benefit_type, + di.diagnostic_code, + di.mst_status, + di.pact_status, + array( + select rdi.request_issue_id + from request_decision_issues rdi + where rdi.decision_issue_id=di.id + ) request_issue_ids, + array( + select rr2 from + ( + select + rr.id, + rr.code, + rr.post_aoj + from remand_reasons rr + where rr.decision_issue_id=di.id + ) rr2 + ) remand_reasons + from decision_issues di + where + di.decision_review_type='Appeal' and + di.decision_review_id = a.id + ) di2 + ) decision_issues, + (select count(id) from hearings h where h.appeal_id=a.id) hearings_count, + ( + select jsonb_agg(h2) from + ( + select + h.*, + ( + select row_to_json(ub) + from (select * from users u where u.id=h.updated_by_id limit 1) ub + ) updated_by, + ( + select row_to_json(hd2) + from (select * from hearing_days hd where hd.id=h.hearing_day_id limit 1) hd2 + ) hearing_day, + ( + select row_to_json(vh2) + from (select * from virtual_hearings vh where vh.hearing_id=h.id limit 1) vh2 + ) virtual_hearing, + ( + select jsonb_agg(hv2) + from (select * from hearing_views hv where hv.hearing_id=h.id) hv2 + ) views, + ( + select row_to_json(j2) + from (select u.full_name from users u where u.id=h.judge_id limit 1) j2 + ) judge + from + hearings h + where + h.appeal_id=a.id + ) h2 + ) hearings, + ( + select jsonb_agg(t2) from + ( + select + t.* + from tasks t + left join organizations o on o.id=t.assigned_to_id + left join users u on u.id=t.assigned_to_id + where + t.appeal_id=a.id and + t.appeal_type='Appeal' + order by updated_at desc + ) t2 + ) tasks + from appeals a + left join claimants cl on cl.decision_review_id=a.id and cl.decision_review_type='Appeal' + left join people pp on cl.participant_id=pp.participant_id + left join work_modes wm on wm.appeal_id=a.id and wm.appeal_type='Appeal' + left join decision_documents dd on dd.appeal_id=a.id and dd.appeal_type='Appeal' + left join advance_on_docket_motions aod on aod.appeal_id=a.id and aod.person_id=pp.id and aod.appeal_type='Appeal' + left join veterans v on a.veteran_file_number=v.file_number or a.veteran_file_number=v.ssn + SQL + end + + def attorney_task_classes + [ + "AttorneyTask", + *AttorneyTask.descendants.map(&:name) + # 'AttorneyRewriteTask', + # 'AttorneyDispatchReturnTask', + # 'DocketSwitchGrantedTask', + # 'DocketSwitchDeniedTask', + ] + end + + def judge_task_classes + [ + "JudgeTask", + *JudgeTask.descendants.map(&:name) + # 'JudgeAddressMotionToVacateTask', + # 'JudgeQualityReviewTask', + # 'JudgeDispatchReturnTask', + # 'JudgeAssignTask', + # 'DocketSwitchRulingTask', + # 'JudgeDecisionReviewTask' + ] + end +end +# rubocop:enable Metrics/ClassLength diff --git a/app/services/search_query_service/search_response.rb b/app/services/search_query_service/search_response.rb new file mode 100644 index 00000000000..09ca32c022e --- /dev/null +++ b/app/services/search_query_service/search_response.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +SearchQueryService::SearchResponse = Struct.new(:appeal, :type, :api_response) do + def filter_restricted_info!(statuses) + if statuses.include?(api_response.attributes.status) + api_response.attributes.assigned_to_location = nil + end + end +end diff --git a/app/services/search_query_service/vacols_query.rb b/app/services/search_query_service/vacols_query.rb new file mode 100644 index 00000000000..99b59a9f219 --- /dev/null +++ b/app/services/search_query_service/vacols_query.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +class SearchQueryService::VacolsQuery + def query + <<-SQL + select + aod, + "cases".bfkey vacols_id, + "cases".bfcurloc, + "cases".bfddec, + "cases".bfmpro, + "cases".bfac, + "cases".bfcorlid, + "correspondents".snamef, + "correspondents".snamel, + "correspondents".sspare1, + "correspondents".sspare2, + "correspondents".slogid, + "folders".tinum, + "folders".tivbms, + "folders".tisubj2, + (select + JSON_ARRAYAGG(JSON_OBJECT( + 'venue' value #{case_hearing_venue_select}, + 'external_id' value "h".hearing_pkseq, + 'type' value "h".hearing_type, + 'disposition' value "h".hearing_disp, + 'date' value "h".hearing_date, + 'held_by_first_name' value "s".snamef, + 'held_by_last_name' value "s".snamel, + 'notes' value "h".notes1 + )) + from hearsched "h" + left outer join staff "s" on "s".sattyid = "h".board_member + where "h".folder_nr="cases".bfkey + ) hearings, + (select + JSON_ARRAYAGG(JSON_OBJECT( + 'id' value "i".isskey, + 'vacols_sequence_id' value "i".issseq, + 'issprog' value "i".issprog, + 'isscode' value "i".isscode, + 'isslev1' value "i".isslev1, + 'isslev2' value "i".isslev2, + 'isslev3' value "i".isslev3, + 'issdc' value "i".issdc, + 'issdesc' value "i".issdesc, + 'issdcls' value "i".issdcls, + 'issmst' value "i".issmst, + 'isspact' value "i".isspact, + 'issprog_label' value "iss".prog_desc, + 'isscode_label' value "iss".iss_desc, + 'isslev1_label' value case when "i".isslev1 is not null then + case when "iss".lev1_code = '##' then + "vft".ftdesc else "iss".lev1_desc + end + end , + 'isslev2_label' value case when "i".isslev2 is not null then + case when "iss".lev2_code = '##' then + "vft".ftdesc else "iss".lev2_desc + end + end, + 'isslev3_label' value case when "i".isslev3 is not null then + case when "iss".lev3_code = '##' then + "vft".ftdesc else "iss".lev3_desc + end + end + )) + from issues "i" + inner join issref "iss" + on "i".issprog = "iss".prog_code + and "i".isscode = "iss".iss_code + and ("i".isslev1 is null + or "iss".lev1_code = '##' + or "i".isslev1 = "iss".lev1_code) + and ("i".isslev2 is null + or "iss".lev2_code = '##' + or "i".isslev2 = "iss".lev2_code) + and ("i".isslev3 is null + or "iss".lev3_code = '##' + or "i".isslev3 = "iss".lev3_code) + left join vftypes "vft" + on "vft".fttype = 'dg' + and (("iss".lev1_code = '##' and 'dg' || "i".isslev1 = "vft".ftkey) + or ("iss".lev2_code = '##' and 'dg' || "i".isslev2 = "vft".ftkey) + or ("iss".lev3_code = '##' and 'dg' || "i".isslev3 = "vft".ftkey)) + where "i".isskey="cases".bfkey + ) issues, + (select count("hearings".hearing_pkseq) from hearsched "hearings" where "hearings".folder_nr="cases".bfkey) hearing_count, + (select count("issues".isskey) from issues "issues" where "issues".isskey="cases".bfkey and "issues".issmst='Y') issues_mst_count, + (select count("issues".isskey) from issues "issues" where "issues".isskey="cases".bfkey and "issues".isspact='Y') issues_pact_count + from + brieff "cases" + left join folder "folders" + on "cases".bfkey="folders".ticknum + left join corres "correspondents" + on "cases".bfcorkey="correspondents".stafkey + #{VACOLS::Case::JOIN_AOD} + where + "cases".bfkey in (?) + SQL + end + + private + + def case_hearing_venue_select + <<-SQL + case + when "h".hearing_type='#{VACOLS::CaseHearing::HEARING_TYPE_LOOKUP[:video]}' AND + "h".hearing_date < '#{VACOLS::CaseHearing::VACOLS_VIDEO_HEARINGS_END_DATE}' + then #{Rails.application.config.vacols_db_name}.HEARING_VENUE("h".vdkey) + else "cases".bfregoff + end + SQL + end +end diff --git a/app/services/search_query_service/vso_user_search_results.rb b/app/services/search_query_service/vso_user_search_results.rb new file mode 100644 index 00000000000..7b7e313b29e --- /dev/null +++ b/app/services/search_query_service/vso_user_search_results.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class SearchQueryService::VsoUserSearchResults + def initialize(search_results:, user:) + @user = user + @search_results = search_results + + filter_restricted_results! + end + + def call + established_results.select do |result| + if result.type == :appeal + result.appeal.claimants.any? do |claimant| + vso_participant_ids.include?(poas.dig(claimant.participant_id, :participant_id)) + end + else + vso_participant_ids.include?(poas.dig(result.appeal.veteran.participant_id, :participant_id)) + end + end + end + + private + + attr_reader :search_results, :user + + RESTRICTED_STATUSES = + [ + :distributed_to_judge, + :ready_for_signature, + :on_hold, + :misc, + :unknown, + :assigned_to_attorney + ].freeze + + def filter_restricted_results! + search_results.map do |result| + result.filter_restricted_info!(RESTRICTED_STATUSES) + end + end + + def vso_participant_ids + @vso_participant_ids ||= user.vsos_user_represents.map { |poa| poa[:participant_id] } + end + + def established_results + @established_results ||= search_results.select do |result| + result.type == :legacy_appeal || result.appeal.established_at.present? + end + end + + def claimant_participant_ids + @claimant_participant_ids ||= established_results.flat_map do |result| + result.appeal.claimant_participant_ids + end.uniq + end + + def poas + Rails.logger.info "BGS Called `fetch_poas_by_participant_ids` with \"#{claimant_participant_ids.join('"')}\"" + + @poas ||= bgs.fetch_poas_by_participant_ids(claimant_participant_ids) + end + + def bgs + @bgs ||= BGSService.new + end +end diff --git a/app/workflows/case_search_results_base.rb b/app/workflows/case_search_results_base.rb index f8e4e1a0528..56c7d870c1e 100644 --- a/app/workflows/case_search_results_base.rb +++ b/app/workflows/case_search_results_base.rb @@ -56,16 +56,52 @@ def search_call ) end + def api_call + @success = valid? + + api_search_results if success + + FormResponse.new( + success: success, + errors: errors.messages[:workflow], + extra: error_status_or_api_search_results + ) + end + protected attr_reader :status, :user - def current_user_is_vso_employee? - user.vso_employee? + def search_page_json_appeals(appeals) + ama_appeals, legacy_appeals = appeals.partition { |appeal| appeal.is_a?(Appeal) } + + ama_hash = WorkQueue::AppealSearchSerializer.new( + ama_appeals, is_collection: true, params: { user: user } + ).serializable_hash + + legacy_hash = WorkQueue::LegacyAppealSearchSerializer.new( + legacy_appeals, is_collection: true, params: { user: user } + ).serializable_hash + + ama_hash[:data].concat(legacy_hash[:data]) end - def appeals - AppealFinder.new(user: user).find_appeals_for_veterans(veterans_user_can_access) + def json_appeals(appeals) + ama_appeals, legacy_appeals = appeals.partition { |appeal| appeal.is_a?(Appeal) } + + ama_hash = WorkQueue::AppealSerializer.new( + ama_appeals, is_collection: true, params: { user: user } + ).serializable_hash + + legacy_hash = WorkQueue::LegacyAppealSerializer.new( + legacy_appeals, is_collection: true, params: { user: user } + ).serializable_hash + + ama_hash[:data].concat(legacy_hash[:data]) + end + + def current_user_is_vso_employee? + user.vso_employee? end def claim_reviews @@ -77,10 +113,14 @@ def veterans [] end + def appeals + [] + end + # Users may also view appeals with appellants whom they represent. # We use this to add these appeals back into results when the user is not on the veteran's poa. def additional_appeals_user_can_access - appeals.filter do |appeal| + appeals.map(&:appeal).filter do |appeal| appeal.veteran_is_not_claimant && user.organizations.any? do |uo| appeal.representatives.include?(uo) @@ -92,34 +132,6 @@ def veterans_user_can_access @veterans_user_can_access ||= veterans.select { |veteran| access?(veteran.file_number) } end - def json_appeals(appeals) - ama_appeals, legacy_appeals = appeals.partition { |appeal| appeal.is_a?(Appeal) } - - ama_hash = WorkQueue::AppealSerializer.new( - ama_appeals, is_collection: true, params: { user: user } - ).serializable_hash - - legacy_hash = WorkQueue::LegacyAppealSerializer.new( - legacy_appeals, is_collection: true, params: { user: user } - ).serializable_hash - - ama_hash[:data].concat(legacy_hash[:data]) - end - - def search_page_json_appeals(appeals) - ama_appeals, legacy_appeals = appeals.partition { |appeal| appeal.is_a?(Appeal) } - - ama_hash = WorkQueue::AppealSearchSerializer.new( - ama_appeals, is_collection: true, params: { user: user } - ).serializable_hash - - legacy_hash = WorkQueue::LegacyAppealSearchSerializer.new( - legacy_appeals, is_collection: true, params: { user: user } - ).serializable_hash - - ama_hash[:data].concat(legacy_hash[:data]) - end - private attr_accessor :errors @@ -142,7 +154,11 @@ def valid? def validation_hook; end def access?(file_number) - !current_user_is_vso_employee? || bgs.can_access?(file_number) + return true if !current_user_is_vso_employee? + + Rails.logger.info "BGS Called `can_access?` with \"#{file_number}\"" + + bgs.can_access?(file_number) end def bgs @@ -168,10 +184,40 @@ def error_status_or_case_search_results case_search_results end + def error_status_or_api_search_results + return { status: status } unless success + + api_search_results + end + + def error_status_or_api_case_search_results + return { status: status } unless success + + api_case_search_results + end + + def api_search_results + @api_search_results ||= { + search_results: { + appeals: json_appeals(appeal_finder_appeals), + claim_reviews: claim_reviews.map(&:search_table_ui_hash) + } + } + end + + def api_case_search_results + @api_case_search_results ||= { + case_search_results: { + appeals: search_page_json_appeals(appeal_finder_appeals), + claim_reviews: claim_reviews.map(&:search_table_ui_hash) + } + } + end + def search_results @search_results ||= { search_results: { - appeals: json_appeals(appeals), + appeals: appeals.map(&:api_response), claim_reviews: claim_reviews.map(&:search_table_ui_hash) } } @@ -180,7 +226,7 @@ def search_results def case_search_results @case_search_results ||= { case_search_results: { - appeals: search_page_json_appeals(appeals), + appeals: appeals.map(&:api_response), claim_reviews: claim_reviews.map(&:search_table_ui_hash) } } diff --git a/app/workflows/case_search_results_for_caseflow_veteran_id.rb b/app/workflows/case_search_results_for_caseflow_veteran_id.rb index 3fe2b5604df..b75e13dc0e3 100644 --- a/app/workflows/case_search_results_for_caseflow_veteran_id.rb +++ b/app/workflows/case_search_results_for_caseflow_veteran_id.rb @@ -20,6 +20,28 @@ def validation_hook validate_veterans_exist end + def appeal_finder_appeals + AppealFinder.new(user: user).find_appeals_for_veterans(veterans_user_can_access) + end + + def search_results + @search_results ||= SearchQueryService.new( + veteran_ids: veterans_user_can_access.map(&:id) + ).search_by_veteran_ids + end + + def vso_user_search_results + SearchQueryService::VsoUserSearchResults.new(user: user, search_results: search_results).call + end + + def appeals + if user.vso_employee? + vso_user_search_results + else + search_results + end + end + def not_found_error { "title": "Veteran not found", diff --git a/app/workflows/case_search_results_for_docket_number.rb b/app/workflows/case_search_results_for_docket_number.rb index 6c16a50a46d..78b9dc49656 100644 --- a/app/workflows/case_search_results_for_docket_number.rb +++ b/app/workflows/case_search_results_for_docket_number.rb @@ -13,6 +13,10 @@ def claim_reviews end def appeals + SearchQueryService.new(docket_number: docket_number).search_by_docket_number + end + + def appeal_finder_appeals AppealFinder.find_appeals_by_docket_number(docket_number) end @@ -33,7 +37,7 @@ def not_found_error def veterans # Determine vet that corresponds to docket number so we can validate user can access - @file_numbers_for_appeals ||= appeals.map(&:veteran_file_number) + @file_numbers_for_appeals ||= appeals.map(&:api_response).map(&:attributes).map(&:veteran_file_number) @veterans ||= VeteranFinder.find_or_create_all(@file_numbers_for_appeals) end end diff --git a/app/workflows/case_search_results_for_veteran_file_number.rb b/app/workflows/case_search_results_for_veteran_file_number.rb index 3f0f97fe466..1cd1bf6edeb 100644 --- a/app/workflows/case_search_results_for_veteran_file_number.rb +++ b/app/workflows/case_search_results_for_veteran_file_number.rb @@ -23,6 +23,26 @@ def validate_file_number_or_ssn_presence @status = :bad_request end + def appeals + if user.vso_employee? + vso_user_search_results + else + search_results + end + end + + def vso_user_search_results + SearchQueryService::VsoUserSearchResults.new(user: user, search_results: search_results).call + end + + def search_results + @search_results ||= SearchQueryService.new(file_number: file_number_or_ssn).search_by_veteran_file_number + end + + def appeal_finder_appeals + AppealFinder.new(user: user).find_appeals_for_veterans(veterans_user_can_access) + end + def missing_veteran_file_number_or_ssn_error { "title": "Veteran file number missing", diff --git a/spec/feature/queue/search_spec.rb b/spec/feature/queue/search_spec.rb index 7be69b901dd..02e393a7350 100644 --- a/spec/feature/queue/search_spec.rb +++ b/spec/feature/queue/search_spec.rb @@ -612,7 +612,9 @@ def perform_search(docket_number = appeal.docket_number) context "when backend returns non-serialized error" do it "displays generic server error message" do - allow(LegacyAppeal).to receive(:fetch_appeals_by_file_number).and_raise(StandardError) + allow_any_instance_of(SearchQueryService).to( + receive(:search_by_veteran_file_number).and_raise(StandardError) + ) visit "/search" fill_in "searchBarEmptyList", with: appeal.sanitized_vbms_id click_on "Search" @@ -655,7 +657,7 @@ def perform_search it "shows 'Withdrawn' text on search results page" do policy = instance_double(WithdrawnDecisionReviewPolicy) - allow(WithdrawnDecisionReviewPolicy).to receive(:new).with(caseflow_appeal).and_return policy + allow(WithdrawnDecisionReviewPolicy).to receive(:new).and_return policy allow(policy).to receive(:satisfied?).and_return true perform_search diff --git a/spec/fixes/assigned_to_search_results_spec.rb b/spec/fixes/assigned_to_search_results_spec.rb index 75765fdddd3..1ef26f380a1 100644 --- a/spec/fixes/assigned_to_search_results_spec.rb +++ b/spec/fixes/assigned_to_search_results_spec.rb @@ -29,7 +29,7 @@ end it "creates tasks and other records associated with a dispatched appeal" do - expect(BVAAppealStatus.new(appeal: appeal).status).to eq :unknown # We will fix this + expect(BVAAppealStatus.new(tasks: appeal.tasks).status).to eq :unknown # We will fix this expect(appeal.root_task.status).to eq "on_hold" visit "/search?veteran_ids=#{appeal.veteran.id}" @@ -57,7 +57,7 @@ } visit "/search?veteran_ids=#{appeal.veteran.id}" expect(page).to have_content("Signed") # in the "Appellant Name" column - expect(BVAAppealStatus.new(appeal: appeal).status).to eq :signed + expect(BVAAppealStatus.new(tasks: appeal.tasks).status).to eq :signed bva_dispatcher = org_dispatch_task.children.first.assigned_to expect(page).to have_content(bva_dispatcher.css_id) # in the "Assigned To" column expect(appeal.assigned_to_location).to eq bva_dispatcher.css_id @@ -65,7 +65,7 @@ BvaDispatchTask.outcode(appeal, params, bva_dispatcher) visit "/search?veteran_ids=#{appeal.veteran.id}" expect(page).to have_content("Dispatched") # in the "Appellant Name" column - expect(BVAAppealStatus.new(appeal: appeal).status).to eq :dispatched + expect(BVAAppealStatus.new(tasks: appeal.tasks).status).to eq :dispatched expect(page).to have_content("Post-decision") # in the "Assigned To" column expect(appeal.assigned_to_location).to eq "Post-decision" expect(appeal.root_task.status).to eq "completed" diff --git a/spec/fixes/backfill_early_ama_appeal_spec.rb b/spec/fixes/backfill_early_ama_appeal_spec.rb index e1ed3774706..e26da3bfe4d 100644 --- a/spec/fixes/backfill_early_ama_appeal_spec.rb +++ b/spec/fixes/backfill_early_ama_appeal_spec.rb @@ -28,7 +28,7 @@ let(:atty_draft_date) { dispatch_date - 2.hours } it "creates tasks and other records associated with a dispatched appeal" do - expect(BVAAppealStatus.new(appeal: appeal).status).to eq :unknown # We will fix this + expect(BVAAppealStatus.new(tasks: appeal.tasks).status).to eq :unknown # We will fix this expect(appeal.root_task.status).to eq "completed" # 1. Create tasks to associate appeal with a judge and attorney @@ -134,7 +134,7 @@ decision_doc = DecisionDocument.create!(params) expect(appeal.decision_document).to eq decision_doc - expect(BVAAppealStatus.new(appeal: appeal).status).to eq :dispatched + expect(BVAAppealStatus.new(tasks: appeal.tasks).status).to eq :dispatched end end end diff --git a/spec/services/bva_appeal_status_spec.rb b/spec/services/bva_appeal_status_spec.rb index 5bdb70c2929..8effec2eb4a 100644 --- a/spec/services/bva_appeal_status_spec.rb +++ b/spec/services/bva_appeal_status_spec.rb @@ -18,7 +18,7 @@ status = pair.first sort_key = pair.last appeal = Appeal.find(appeal_id) - appeal_status = described_class.new(appeal: appeal) + appeal_status = described_class.new(tasks: appeal.tasks) expect(appeal_status.to_s).to eq(status) expect(appeal_status.to_i).to eq(sort_key.to_i + 1) # our sort keys are 1-based diff --git a/spec/services/search_query_service_spec.rb b/spec/services/search_query_service_spec.rb new file mode 100644 index 00000000000..d855c18dd29 --- /dev/null +++ b/spec/services/search_query_service_spec.rb @@ -0,0 +1,354 @@ +# frozen_string_literal: true + +describe "SearchQueryService" do + let(:ssn) { "146600001" } + let(:dob) { Faker::Date.in_date_period(year: 1960) } + + let(:uuid) { SecureRandom.uuid } + let(:veteran_first_name) { Faker::Name.first_name } + let(:veteran_last_name) { Faker::Name.last_name } + let(:claimant_first_name) { Faker::Name.first_name } + let(:claimant_last_name) { Faker::Name.last_name } + let(:veteran_full_name) { FullName.new(veteran_first_name, "", veteran_last_name).to_s } + let(:claimant_full_name) { FullName.new(claimant_first_name, "", claimant_last_name).to_s } + let(:docket_type) { "hearing" } + let(:docket_number) { "240111-1111" } + + let(:descision_document_attrs) do + { + decision_date: Faker::Date.between(from: 2.years.ago, to: 1.year.ago) + } + end + + context "all data in caseflow" do + context "veteran is claimant" do + let(:veteran_attrs) do + { + ssn: ssn, + file_number: ssn, + date_of_birth: dob, + date_of_death: nil, + first_name: veteran_first_name, + middle_name: nil, + last_name: veteran_last_name + } + end + + let(:veteran) { FactoryBot.create(:veteran, veteran_attrs) } + + let(:appeal_attributes) do + { + aod_based_on_age: false, + changed_hearing_request_type: "V", + original_hearing_request_type: "central", + stream_docket_number: docket_number, + stream_type: Constants.AMA_STREAM_TYPES.original, + uuid: uuid, + veteran: veteran, + veteran_file_number: ssn + } + end + + let(:judge) { create(:user, :judge) } + + let!(:appeal) do + FactoryBot.create( + :appeal, + # has hearing(s) + :hearing_docket, + :held_hearing, + :tied_to_judge, + # has decision document + :dispatched, + # has issue(s) + :with_request_issues, + :with_decision_issue, + { + associated_judge: judge, + tied_judge: judge + }.merge(appeal_attributes) + ).tap do |appeal| + appeal.decision_issues.first.update( + mst_status: true, + pact_status: true + ) + # create work mode + appeal.overtime = true + AdvanceOnDocketMotion.create( + person: appeal.claimants.first.person, + granted: false, + appeal: appeal + ) + end.reload + end + + context "finds by docket number" do + subject { SearchQueryService.new(docket_number: appeal.stream_docket_number) } + + before do + create( + :virtual_hearing, + hearing: appeal.hearings.first + ) + appeal.hearings.first.update(updated_by: judge) + appeal.hearings.first.hearing_day.update(regional_office: "RO19") + appeal.hearings.first.hearing_views.create(user_id: judge.id) + AppellantHearingEmailRecipient.first.update( + appeal: appeal + ) + end + + it "finds by docket number" do + expect(appeal).to be_persisted + + search_results = subject.search_by_docket_number + + expect(search_results.length).to eq(1) + + result = search_results.first.api_response + + expect(result.id).to be + expect(result.type).to eq "appeal" + + attributes = result.attributes + + expect(attributes.aod).to be_falsy + expect(attributes.appellant_full_name).to eq veteran_full_name + expect(attributes.assigned_to_location).to eq appeal.assigned_to_location + expect(attributes.caseflow_veteran_id).to eq veteran.id + expect(attributes.decision_date).to eq appeal.decision_document.decision_date + expect(attributes.docket_name).to eq appeal.docket_type + expect(attributes.docket_number).to eq appeal.stream_docket_number + expect(attributes.external_id).to eq appeal.uuid + expect(attributes.hearings.length).to eq appeal.hearings.length + expect(attributes.hearings.first[:held_by]).to eq judge.full_name + expect(attributes.issues.length).to eq(appeal.request_issues.length) + expect(attributes.mst).to eq appeal.decision_issues.any?(&:mst_status) + expect(attributes.pact).to eq appeal.decision_issues.any?(&:pact_status) + expect(attributes.paper_case).to be_falsy + expect(attributes.readable_hearing_request_type).to eq("Video") + expect(attributes.readable_original_hearing_request_type).to eq("Central") + expect(attributes.status).to eq Appeal.find(appeal.id).status.status + expect(attributes.veteran_appellant_deceased).to be_falsy + expect(attributes.veteran_file_number).to eq ssn + expect(attributes.veteran_full_name).to eq veteran_full_name + expect(attributes.contested_claim).to be_falsy + expect(attributes.withdrawn).to eq(false) + end + + it "finds by docket number with not all hearing values" do + expect(appeal).to be_persisted + + search_results = subject.search_by_docket_number + + expect(search_results.length).to eq(1) + end + end + + context "finds by file number" do + subject { SearchQueryService.new(file_number: ssn) } + + it "finds by veteran file number" do + expect(appeal).to be_persisted + + search_results = subject.search_by_veteran_file_number + + expect(search_results.length).to eq(1) + + result = search_results.first.api_response + + expect(result.id).to be + expect(result.type).to eq "appeal" + end + end + + context "finds by veteran ids" do + subject { SearchQueryService.new(veteran_ids: [veteran.id]) } + + it "finds by veteran ids" do + expect(appeal).to be_persisted + + search_results = subject.search_by_veteran_ids + + expect(search_results.length).to eq(1) + + result = search_results.first.api_response + + expect(result.id).to be + expect(result.type).to eq "appeal" + end + end + end + end + + let(:veteran_address) do + { + addrs_one_txt: nil, + addrs_two_txt: nil, + addrs_three_txt: nil, + city_nm: nil, + cntry_nm: nil, + postal_cd: nil, + zip_prefix_nbr: nil, + ptcpnt_addrs_type_nm: nil + } + end + + let(:legacy_appeal) do + create( + :legacy_appeal, + vbms_id: ssn, + vacols_case: vacols_case, + veteran_address: veteran_address + ) + end + + let(:judge) do + create( + :staff, + :hearing_judge, + snamel: Faker::Name.last_name, + snamef: Faker::Name.first_name + ) + end + + # must be created first for legacy_appeal factory to find it + let!(:veteran) do + create( + :veteran, + file_number: ssn, + first_name: veteran_first_name, + last_name: veteran_last_name + ) + end + + let(:vacols_decision_date) { 2.weeks.ago } + let(:vacols_case_attrs) do + { + bfkey: ssn, + bfcorkey: ssn, + bfac: "1", + bfcorlid: "100000099", + bfcurloc: "CASEFLOW", + bfddec: vacols_decision_date, + bfmpro: "ACT" + + # bfregoff: "RO18", + # bfdloout: "2024-03-26T11:13:32.000Z", + # bfcallup: "", + # bfhr: "2", + # bfdocind: "T", + } + end + + let(:issues_count) { 5 } + let(:vacols_case_issues) do + create_list( + :case_issue, + issues_count, + isspact: "Y", + issmst: "Y" + ) + end + + let(:hearings_count) { 5 } + let(:vacols_case_hearings) do + hearings = create_list( + :case_hearing, + hearings_count + ) + + hearings.map do |hearing| + hearing.board_member = judge.sattyid + hearing.save + end + + hearings + end + + let(:vacols_correspondent) do + create(:correspondent, vacols_correspondent_attrs) + end + + let(:vacols_folder) do + build(:folder) + end + + let(:vacols_case) do + create( + :case, + { + correspondent: vacols_correspondent, + case_issues: vacols_case_issues, + case_hearings: vacols_case_hearings, + folder: vacols_folder + }.merge(vacols_case_attrs) + ) + end + + context "when appeal is a legacy appeal with data in vacols and caseflow" do + context "when veteran is claimant" do + let(:vacols_correspondent_attrs) do + { + sspare2: veteran_first_name, + sspare1: veteran_last_name, + snamel: veteran_last_name, + snamef: veteran_first_name, + stafkey: ssn + } + end + + let!(:claimant) do + create( + :claimant, + type: "VeteranClaimant", + decision_review: legacy_appeal + ) + end + + subject { SearchQueryService.new(file_number: ssn) } + + it "finds by file number" do + search_results = subject.search_by_veteran_file_number + result = search_results.first.api_response + + expect(result.id).to be + expect(result.type).to eq "legacy_appeal" + + attributes = result.attributes + expect(attributes.docket_name).to eq "legacy" + expect(attributes.aod).to be_falsy + expect(attributes.appellant_full_name).to eq veteran_full_name + expect(attributes.assigned_to_location).to eq legacy_appeal.assigned_to_location + expect(attributes.caseflow_veteran_id).to eq veteran.id + expect(attributes.decision_date).to eq AppealRepository.normalize_vacols_date(vacols_decision_date) + expect(attributes.docket_name).to eq "legacy" + expect(attributes.docket_number).to eq vacols_folder.tinum + expect(attributes.external_id).to eq vacols_case.id + expect(attributes.hearings.length).to eq hearings_count + expect(attributes.hearings.first[:held_by]).to eq "#{judge.snamef} #{judge.snamel}" + expect(attributes.issues.length).to eq issues_count + expect(attributes.mst).to be_truthy + expect(attributes.pact).to be_truthy + expect(attributes.paper_case).to eq "Paper" + expect(attributes.status).to eq "Active" + expect(attributes.veteran_appellant_deceased).to be_falsy + expect(attributes.veteran_file_number).to eq ssn + expect(attributes.veteran_full_name).to eq veteran_full_name + expect(attributes.withdrawn).to be_falsy + end + + context "finds by veteran ids" do + subject { SearchQueryService.new(veteran_ids: [veteran.id]) } + + it "finds by veteran ids" do + search_results = subject.search_by_veteran_ids + result = search_results.first.api_response + + expect(result.id).to be + expect(result.type).to eq "legacy_appeal" + end + end + end + end +end