diff --git a/.fasterer.yml b/.fasterer.yml index 6e0821c009a..b679a509670 100644 --- a/.fasterer.yml +++ b/.fasterer.yml @@ -14,3 +14,7 @@ exclude_paths: - "db/seeds.rb" - "vendor/**/*.rb" - "app/mappers/zip_code_to_lat_lng_mapper.rb" + + # Flagged for using sort instead of using sort_by - however due to complexity of sorting + # conditionally makes more sense to use sort + - "app/services/auto_assignable_user_finder.rb" diff --git a/Gemfile b/Gemfile index 17eec027d43..71763cae1b5 100644 --- a/Gemfile +++ b/Gemfile @@ -114,8 +114,8 @@ group :test, :development, :demo do gem "rails-erd" gem "rb-readline" gem "rspec" - gem "rspec-rails" # For CircleCI test metadata analysis + gem "rspec-rails" gem "rspec_junit_formatter" gem "rswag-specs" gem "rubocop", "= 0.83", require: false diff --git a/Makefile.example b/Makefile.example index e27b6efcb99..81c8c29d740 100644 --- a/Makefile.example +++ b/Makefile.example @@ -113,6 +113,10 @@ realclean: clean ## TODO rm -rf client/node_modules rm -f client/package-lock.json +precompile: ## Precompiles assets for testing + bundle exec rake assets:precompile + + facols-bash: ## Connect to the docker FACOLS instance docker exec --tty -i VACOLS_DB bash @@ -286,6 +290,8 @@ client-build-all client-all: client-test client-demo ## Builds webpack for both one-test: ## run the rspec test passed in bundle exec rspec $(RUN_ARGS) +clean-test: clean precompile one-test ## Cleans and precompiles assets for testing, then runs spec test passed in + run-all-queues: ## start shoryuken with all queues bundle exec shoryuken -q caseflow_development_send_notifications caseflow_development_high_priority caseflow_development_low_priority -R diff --git a/app/controllers/appeals_controller.rb b/app/controllers/appeals_controller.rb index cdbb1a6e236..0fed6bf1502 100644 --- a/app/controllers/appeals_controller.rb +++ b/app/controllers/appeals_controller.rb @@ -42,7 +42,7 @@ def show_case_list format.html { render template: "queue/index" } format.json do result = CaseSearchResultsForCaseflowVeteranId.new( - caseflow_veteran_ids: params[:veteran_ids]&.split(","), user: current_user + caseflow_veteran_ids: appeals_controller_params[:veteran_ids]&.split(","), user: current_user ).search_call render_search_results_as_json(result) @@ -52,7 +52,7 @@ def show_case_list # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def fetch_notification_list - appeals_id = params[:appeals_id] + appeals_id = appeals_controller_params[:appeals_id] respond_to do |format| format.json do results = find_notifications_by_appeals_id(appeals_id) @@ -138,7 +138,7 @@ def show format.html { render template: "queue/index" } format.json do if appeal.accessible? - id = params[:appeal_id] + id = url_appeal_uuid MetricsService.record("Get appeal information for ID #{id}", service: :queue, name: "AppealsController.show") do @@ -162,16 +162,15 @@ def edit helper_method :appeal, :url_appeal_uuid def appeal - @appeal ||= Appeal.find_appeal_by_uuid_or_find_or_create_legacy_appeal_by_vacols_id(params[:appeal_id]) + @appeal ||= Appeal.find_appeal_by_uuid_or_find_or_create_legacy_appeal_by_vacols_id(url_appeal_uuid) end def url_appeal_uuid - params[:appeal_id] + params.permit(:appeal_id)[:appeal_id] end def update if appeal.is_a?(LegacyAppeal) && feature_enabled?(:legacy_mst_pact_identification) - legacy_mst_pact_updates elsif request_issues_update.perform! set_flash_success_message @@ -187,8 +186,50 @@ def update end end + def active_evidence_submissions + appeal = Appeal.find(url_appeal_uuid) + render json: appeal.evidence_submission_task + end + private + REQUEST_ISSUE_PARAMS = %w[ + request_issue_id + rating_issue_reference_id + rating_decision_reference_id + rating_issue_profile_date + notes + ramp_claim_id + vacols_id + vacols_sequence_id + contested_decision_issue_id + vbms_mst_status + vbms_pact_status + rating_issue_diagnostic_code + mst_status_update_reason_notes + pact_status_update_reason_notes + benefit_type + nonrating_issue_category + decision_text + decision_date + ineligible_due_to_id + ineligible_reason + withdrawal_date + is_predocket_needed + mst_status + pact_status + ].freeze + + def appeals_controller_params + params.permit( + :appeal_id, + :any, + :appeals_id, + :veteran_ids, + request_issues: REQUEST_ISSUE_PARAMS + ) + end + def create_subtasks! # if cc appeal, create SendInitialNotificationLetterTask if appeal.contested_claim? && feature_enabled?(:cc_appeal_workflow) @@ -216,7 +257,7 @@ def request_issues_update @request_issues_update ||= RequestIssuesUpdate.new( user: current_user, review: appeal, - request_issues_data: params[:request_issues] + request_issues_data: appeals_controller_params[:request_issues] ) end @@ -284,7 +325,7 @@ def mst_and_pact_edited_issues pact_removed = 0 # get edited issues from params and reject new issues without id if !appeal.is_a?(LegacyAppeal) - existing_issues = params[:request_issues].reject { |iss| iss[:request_issue_id].nil? } + existing_issues = appeals_controller_params[:request_issues].reject { |iss| iss[:request_issue_id].nil? } # get added issues new_issues = request_issues_update.after_issues - request_issues_update.before_issues @@ -448,7 +489,7 @@ def create_legacy_issue_update_task(before_issue, current_issue) # close out any tasks that might be open open_issue_task = Task.where( assigned_to: SpecialIssueEditTeam.singleton - ).where(status: "assigned").where(appeal: appeal) + ).where(status: Constants.TASK_STATUSES.assigned).where(appeal: appeal) open_issue_task[0].delete unless open_issue_task.empty? task = IssuesUpdateTask.create!( diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c34519195e4..615d9971158 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,7 +9,7 @@ class ApplicationController < ApplicationBaseController before_action :set_raven_user before_action :verify_authentication before_action :set_paper_trail_whodunnit - before_action :deny_vso_access, except: [:unauthorized, :feedback] + before_action :deny_vso_access, except: [:unauthorized, :feedback, :under_construction] before_action :set_no_cache_headers rescue_from StandardError do |e| @@ -196,7 +196,12 @@ def defult_menu_items def manage_teams_menu_items current_user.administered_teams.map do |team| { - title: "#{team.name} team management", + title: + if team.type == InboundOpsTeam.singleton.type + "#{team.name} management" + else + "#{team.name} team management" + end, link: team.user_admin_path } end diff --git a/app/controllers/concerns/correspondence_controller_concern.rb b/app/controllers/concerns/correspondence_controller_concern.rb new file mode 100644 index 00000000000..8b6148ffe9b --- /dev/null +++ b/app/controllers/concerns/correspondence_controller_concern.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ModuleLength +# :reek:DataClump + +# Contains most of the logic inside of CorrespondenceController +module CorrespondenceControllerConcern + private + + # :reek:FeatureEnvy + def process_tasks_if_applicable(mail_team_user, task_ids, tab) + # candidate for refactor using PATCH request + return unless mail_team_user && task_ids.present? + + # Instantiate AutoAssignableUserFinder with current_user + permission_checker = AutoAssignableUserFinder.new(mail_team_user) + + # iterate through each task and check if the user can work the correspondence + task_ids.each do |id| + correspondence = Task.find(id)&.correspondence + check_result = permission_checker.can_user_work_this_correspondence?( + user: mail_team_user, + correspondence: correspondence + ) + + # assign the task if the user can work the correspondence + update_task(mail_team_user, id) if check_result + end + + # use permission_checker.unassignable_reasons errors to generate the banner + set_banner_params( + mail_team_user, + permission_checker.unassignable_reasons, + task_ids.count, + tab + ) + end + + def update_task(mail_team_user, task_id) + task = Task.find_by(id: task_id) + task.update( + assigned_to_id: mail_team_user.id, + assigned_to_type: "User", + status: Constants.TASK_STATUSES.assigned + ) + end + + # :reek:LongParameterList + def set_banner_params(user, errors, task_count, tab) + template = message_template(user, errors, task_count, tab) + @response_type = errors.empty? ? "success" : "warning" + @response_header = template[:header] + @response_message = template[:message] + end + + # :reek:ControlParameter and :reek:LongParameterList + def message_template(user, errors, task_count, tab) + case tab + when "correspondence_unassigned" + if task_count == 1 + single_assignment_banner_text(user, errors, task_count) + else + multiple_assignment_banner_text(user, errors, task_count) + end + when "correspondence_team_assigned" + if task_count == 1 + single_assignment_banner_text(user, errors, task_count, action_prefix: "re") + else + multiple_assignment_banner_text(user, errors, task_count, action_prefix: "re") + end + end + end + + # :reek:FeatureEnvy + def single_assignment_banner_text(*args, action_prefix: "") + success_header_unassigned = "You have successfully #{action_prefix}"\ + "assigned #{args[2]} Correspondence to #{args[0].css_id}." + failure_header_unassigned = "Correspondence was not #{action_prefix}assigned to #{args[0].css_id}" + success_message = "Please go to your individual queue to see any self-assigned correspondence." + + failure_message = build_single_error_message(action_prefix, error_reason(args[1][0])) + + { + header: args[1].empty? ? success_header_unassigned : failure_header_unassigned, + message: args[1].empty? ? success_message : failure_message + } + end + + # :reek:FeatureEnvy + def multiple_assignment_banner_text(*args, action_prefix: "") + success_header = "You have successfully #{action_prefix}"\ + "assigned #{args[2]} Correspondences to #{args[0].css_id}." + success_message = "Please go to your individual queue to see any self-assigned correspondences." + failure_header = "Not all correspondence were #{action_prefix}assigned to #{args[0].css_id}" + + failure_message = build_multi_error_message(args[1], action_prefix) + + # return JSON message + { + header: args[1].blank? ? success_header : failure_header, + message: args[1].blank? ? success_message : failure_message.join(" \n") + } + end + + # :reek:FeatureEnvy + def build_multi_error_message(errors, action_prefix) + failure_message = [] + + # Get error counts + error_counts = { + Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.NOD_ERROR => errors.count( + Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.NOD_ERROR + ), + Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.SENSITIVITY_ERROR => errors.count( + Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.SENSITIVITY_ERROR + ), + Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.CAPACITY_ERROR => errors.count( + Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.CAPACITY_ERROR + ) + } + + error_counts.each do |error, count| + if count.positive? + multiple_errors = error_counts.values.count(&:positive?) > 1 + failure_message << build_error_message(count, action_prefix, error_reason(error), multiple_errors) + end + end + + failure_message + end + + def error_reason(error) + return "" unless error.is_a?(String) + + case error + when Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.NOD_ERROR then "of NOD permissions settings" + when Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.SENSITIVITY_ERROR then "of sensitivity level mismatch" + when Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.CAPACITY_ERROR then "maximum capacity has been reached for user's + queue" + end + end + + def build_single_error_message(action_prefix, reason) + # Build error message for single correspondence based on error types + "Case was not #{action_prefix}assigned to user because #{reason}." + end + + def build_error_message(*args) + # Build error message for multiple correspondence based on error types + message = "#{args[0]} cases were not #{args[1]}assigned to user" + message = "• #{message}" if args[3].present? + message += " because #{args[2]}." unless args[0].zero? + message + end + + def set_flash_intake_success_message + # intake error message is handled in client/app/queue/correspondence/intake/components/CorrespondenceIntake.jsx + vet = veteran_by_correspondence + flash[:correspondence_intake_success] = [ + "You have successfully submitted a correspondence record for #{vet.name}(#{vet.file_number})", + "The mail package has been uploaded to the Veteran's eFolder as well." + ] + end + + # :reek:ControlParameter + def intake_cancel_message(action_type) + vet = veteran_by_correspondence + if action_type == "cancel_intake" + @response_header = "You have successfully cancelled the intake form" + @response_message = "#{vet.name}'s correspondence (ID: #{correspondence.id}) "\ + "has been returned to the supervisor's queue for assignment." + else + @response_header = "You have successfully saved the intake form" + @response_message = "You can continue from step three of the intake form for #{vet.name}'s "\ + "correspondence (ID: #{correspondence.id}) at a later date." + end + @response_type = "success" + end + + def general_information + vet = veteran_by_correspondence + { + notes: correspondence.notes, + file_number: vet.file_number, + veteran_name: vet.name, + correspondence_type_id: correspondence.correspondence_type_id, + correspondence_tasks: correspondence.tasks.map do |task| + WorkQueue::CorrespondenceTaskSerializer.new(task).serializable_hash[:data][:attributes] + end + } + end + + def correspondence + @correspondence = Correspondence.find_by(uuid: params[:correspondence_uuid]) + end + + def veteran_by_correspondence + return nil if correspondence&.veteran_id.blank? + + @veteran_by_correspondence ||= Veteran.find_by(id: correspondence.veteran_id) + end + + def veterans_with_correspondences + veterans = Veteran.includes(:correspondences).where(correspondences: { id: Correspondence.select(:id) }) + veterans.map { |veteran| vet_info_serializer(veteran, veteran.correspondences.last) } + end + + def auto_texts + @auto_texts ||= AutoText.all.pluck(:name) + end + + def vet_info_serializer(veteran, correspondence) + { + firstName: veteran.first_name, + lastName: veteran.last_name, + fileNumber: veteran.file_number, + correspondenceUuid: correspondence.uuid, + packageDocumentType: correspondence.correspondence_type_id + } + end + + def correspondence_intake_processor + @correspondence_intake_processor ||= CorrespondenceIntakeProcessor.new + end + + def correspondence_documents_efolder_uploader + @correspondence_documents_efolder_uploader ||= CorrespondenceDocumentsEfolderUploader.new + end + + def upload_documents_to_claim_evidence + rpt = ReviewPackageTask.find_by(appeal_id: correspondence.id, type: ReviewPackageTask.name) + correspondence_documents_efolder_uploader.upload_documents_to_claim_evidence(correspondence, current_user, rpt) + end +end +# rubocop:enable Metrics/ModuleLength diff --git a/app/controllers/concerns/explain_timeline_concern.rb b/app/controllers/concerns/explain_timeline_concern.rb index a72722a4354..0c19c27bb1a 100644 --- a/app/controllers/concerns/explain_timeline_concern.rb +++ b/app/controllers/concerns/explain_timeline_concern.rb @@ -9,6 +9,7 @@ module ExplainTimelineConcern # :reek:FeatureEnvy def timeline_data return "(LegacyAppeals are not yet supported)".to_json if legacy_appeal? + return "(Correspondences are not yet supported)".to_json if correspondence? (tasks_timeline_data + intake_timeline_data + hearings_timeline_data).map(&:as_json) end diff --git a/app/controllers/concerns/task_pagination_concern.rb b/app/controllers/concerns/task_pagination_concern.rb index eb2ce725391..e162a59bf1d 100644 --- a/app/controllers/concerns/task_pagination_concern.rb +++ b/app/controllers/concerns/task_pagination_concern.rb @@ -17,8 +17,28 @@ def pagination_json } end + def correspondence_pagination_json + { + tasks: correspondence_json_tasks(correspondence_task_pager.paged_tasks), + task_page_count: correspondence_task_pager.task_page_count, + total_task_count: correspondence_task_pager.total_task_count, + tasks_per_page: CorrespondenceTaskPager::TASKS_PER_PAGE + } + end + private + def correspondence_task_pager + @correspondence_task_pager ||= CorrespondenceTaskPager.new( + assignee: assignee, + tab_name: params[Constants.QUEUE_CONFIG.TAB_NAME_REQUEST_PARAM.to_sym], + page: params[Constants.QUEUE_CONFIG.PAGE_NUMBER_REQUEST_PARAM.to_sym], + sort_order: params[Constants.QUEUE_CONFIG.SORT_DIRECTION_REQUEST_PARAM.to_sym], + sort_by: params[Constants.QUEUE_CONFIG.SORT_COLUMN_REQUEST_PARAM.to_sym], + filters: params[Constants.QUEUE_CONFIG.FILTER_COLUMN_REQUEST_PARAM.to_sym] + ) + end + def task_pager @task_pager ||= TaskPager.new( assignee: assignee, @@ -30,6 +50,16 @@ def task_pager ) end + def correspondence_json_tasks(tasks) + tab = CorrespondenceQueueTab.from_name(params[Constants.QUEUE_CONFIG.TAB_NAME_REQUEST_PARAM.to_sym]) + + { data: WorkQueue::CorrespondenceTaskColumnSerializer.new( + tasks, + is_collection: true, + params: { columns: tab.column_names } + ).serializable_hash[:data] } + end + def json_tasks(tasks) tasks = AppealRepository.eager_load_legacy_appeals_for_tasks(tasks) params = { user: current_user } diff --git a/app/controllers/correspondence_controller.rb b/app/controllers/correspondence_controller.rb new file mode 100644 index 00000000000..76f71e3f276 --- /dev/null +++ b/app/controllers/correspondence_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# :reek:RepeatedConditional +class CorrespondenceController < ApplicationController + include CorrespondenceControllerConcern + include RunAsyncable + + before_action :verify_correspondence_access + before_action :verify_feature_toggle + before_action :correspondence + before_action :auto_texts + + def auto_assign_correspondences + batch = BatchAutoAssignmentAttempt.create!( + user: current_user, + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.started + ) + + job_args = { + current_user_id: current_user.id, + batch_auto_assignment_attempt_id: batch.id + } + + begin + perform_later_or_now(AutoAssignCorrespondenceJob, job_args) + render json: { batch_auto_assignment_attempt_id: batch&.id }, status: :ok + rescue StandardError => error + Rails.logger.error(error.full_message) + render json: { success: false, error: error } + end + end + + # :reek:FeatureEnvy + def auto_assign_status + batch = BatchAutoAssignmentAttempt.includes(:individual_auto_assignment_attempts) + .find_by!(user: current_user, id: corr_controller_params[:batch_auto_assignment_attempt_id]) + + num_assigned = batch.individual_auto_assignment_attempts + .where(status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.completed).count + + status_details = { + error_message: batch.error_info, + status: batch.status, + number_assigned: num_assigned, + number_attempted: batch.individual_auto_assignment_attempts.count + } + + render json: status_details, status: :ok + end + + private + + def corr_controller_params + params.permit(:batch_auto_assignment_attempt_id) + end + + def verify_correspondence_access + return true if InboundOpsTeam.singleton.user_has_access?(current_user) + + redirect_to "/unauthorized" + end + + def verify_feature_toggle + if !FeatureToggle.enabled?(:correspondence_queue) && verify_correspondence_access + redirect_to "/under_construction" + elsif !FeatureToggle.enabled?(:correspondence_queue) || !verify_correspondence_access + redirect_to "/unauthorized" + end + end +end diff --git a/app/controllers/correspondence_details_controller.rb b/app/controllers/correspondence_details_controller.rb new file mode 100644 index 00000000000..4bec6d2d5ab --- /dev/null +++ b/app/controllers/correspondence_details_controller.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +class CorrespondenceDetailsController < CorrespondenceController + include CorrespondenceControllerConcern + + def correspondence_details + set_instance_variables + + # Sort the response letters + @correspondence_response_letters = sort_response_letters( + @correspondence_details[:correspondence][:correspondenceResponseLetters] + ) + + respond_to do |format| + format.html + format.json { render json: build_json_response, status: :ok } + end + end + + def set_instance_variables + @correspondence = serialized_correspondence + + # Group related variables into a single hash + @correspondence_details = { + organizations: current_user.organizations.pluck(:name), + correspondence: @correspondence, + correspondence_documents: @correspondence[:correspondenceDocuments], + general_information: general_information, + mail_tasks: mail_tasks, + appeals_information: appeals, + inbound_ops_team_users: User.inbound_ops_team_users.select(:css_id).pluck(:css_id), + correspondence_types: CorrespondenceType.all + } + end + + def serialized_correspondence + WorkQueue::CorrespondenceSerializer + .new(correspondence) + .serializable_hash[:data][:attributes] + .merge(general_information) + .merge(mail_tasks) + .merge(appeals) + .merge(all_correspondences) + .merge(prior_mail) + end + + def build_json_response + { + correspondence: @correspondence_details[:correspondence], + general_information: @correspondence_details[:general_information], + mailTasks: @correspondence_details[:mail_tasks], + corres_docs: @correspondence_details[:correspondence_documents] + } + end + + # overriding method to allow users to access the correspondence details page + def verify_correspondence_access + true + end + + private + + def sort_response_letters(response_letters) + response_letters.sort_by do |letter| + days_left = letter[:days_left] + + sort_key = if days_left.match?(/Expired on/) + expiration_date = Date.strptime(days_left.split("Expired on ").last, "%m/%d/%Y") + [0, expiration_date, letter[:date_sent].to_date, letter[:title]] + elsif days_left.match?(/No response window/) + [2, letter[:date_sent].to_date, letter[:title]] + else + expiration_date_str = days_left.split(" (").first + expiration_date = Date.strptime(expiration_date_str, "%m/%d/%Y") + [1, expiration_date, letter[:date_sent].to_date, letter[:title]] + end + sort_key + end + end + + def appeals + case_search_results = CaseSearchResultsForCaseflowVeteranId.new( + caseflow_veteran_ids: [@correspondence.veteran_id], user: current_user + ).search_call + + { appeals_information: case_search_results.extra[:case_search_results] } + end + + def mail_tasks + { + mailTasks: @correspondence.correspondence_mail_tasks.completed.map(&:label) + } + end + + def all_correspondences + { all_correspondences: serialized_correspondences } + end + + def serialized_correspondences + serialized_data.map { |correspondence| correspondence[:attributes] } + end + + def serialized_data + serializer = WorkQueue::CorrespondenceSerializer.new(ordered_correspondences) + serializer.serializable_hash[:data] + end + + def ordered_correspondences + @correspondence.veteran.correspondences.order(va_date_of_receipt: :asc) + end + + def prior_mail + prior_mail = Correspondence.prior_mail(veteran_by_correspondence.id, correspondence.uuid).order(:va_date_of_receipt) + .select { |corr| corr.status == "Completed" || corr.status == "Pending" } + serialized_mail = prior_mail.map do |correspondence| + WorkQueue::CorrespondenceSerializer.new(correspondence).serializable_hash[:data][:attributes] + end + + { prior_mail: serialized_mail } + end +end diff --git a/app/controllers/correspondence_document_controller.rb b/app/controllers/correspondence_document_controller.rb new file mode 100644 index 00000000000..129160d4fff --- /dev/null +++ b/app/controllers/correspondence_document_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "paper_trail" + +class CorrespondenceDocumentController < ApplicationController + def update_document + document = CorrespondenceDocument.find(corr_document_params[:id]) + document.update!(corr_document_params) + render json: { correspondence: serialized_correspondence(document.correspondence) } + end + + private + + def corr_document_params + params.permit(:id, :vbms_document_type_id) + end + + def serialized_correspondence(correspondence) + WorkQueue::CorrespondenceSerializer + .new(correspondence) + .serializable_hash[:data][:attributes] + end +end diff --git a/app/controllers/correspondence_intake_controller.rb b/app/controllers/correspondence_intake_controller.rb new file mode 100644 index 00000000000..61a0799bf80 --- /dev/null +++ b/app/controllers/correspondence_intake_controller.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class CorrespondenceIntakeController < CorrespondenceController + before_action :verify_correspondence_intake_access + def intake + # If correspondence intake was started, json data from the database will + # be loaded into the page when user returns to intake + @redux_store ||= CorrespondenceIntake.find_by( + task: correspondence&.open_intake_task + )&.redux_store + @prior_mail = prior_mail.map do |correspondence| + WorkQueue::CorrespondenceSerializer.new(correspondence).serializable_hash[:data][:attributes] + end + @correspondence = WorkQueue::CorrespondenceSerializer + .new(correspondence) + .serializable_hash[:data][:attributes] + .merge(general_information) + end + + def current_step + intake = CorrespondenceIntake.find_by(task: correspondence&.open_intake_task) || + CorrespondenceIntake.new(task: correspondence&.open_intake_task) + intake.update( + current_step: corr_intake_params[:current_step], + redux_store: redux_store + ) + + if intake.valid? + intake.save! + + render(json: {}, status: :ok) && return + else + render(json: intake.errors.full_messages, status: :unprocessable_entity) && return + end + end + + def intake_update + begin + correspondence.cancel_task_tree_for_appeal_intake + upload_documents_to_claim_evidence if FeatureToggle.enabled?(:ce_api_demo_toggle) + render json: { correspondence: correspondence } + rescue StandardError => error + Rails.logger.error(error.to_s) + Raven.capture_exception(error) + render json: {}, status: :bad_request + end + end + + def process_intake + if correspondence_intake_processor.process_intake(params, current_user) + set_flash_intake_success_message + render json: {}, status: :created + else + render json: { error: "Failed to update records" }, status: :bad_request + end + end + + def cancel_intake + begin + # find the correspondence intake task even if it isn't assigned to the user + intake_task = CorrespondenceIntakeTask.open.find_by(appeal_id: correspondence.id) + intake_task.update!(status: Constants.TASK_STATUSES.cancelled) + ReviewPackageTask.find_or_create_by!( + parent_id: intake_task.parent_id, + assigned_to: intake_task.assigned_to, + status: Constants.TASK_STATUSES.assigned, + appeal_id: correspondence.id, + appeal_type: "Correspondence" + ) + render json: {}, status: :ok + rescue StandardError + render json: { error: "Failed to update records" }, status: :bad_request + end + end + + private + + def corr_intake_params + params.permit(:current_step, :correspondence_uuid) + end + + def redux_store + params.require(:redux_store) + end + + def correspondence_uuid + params.permit(:correspondence_uuid)[:correspondence_uuid] + end + + def prior_mail + Correspondence.prior_mail(veteran_by_correspondence.id, corr_intake_params[:correspondence_uuid]) + .select { |corr| corr.status == "Completed" || corr.status == "Pending" } + end + + def verify_correspondence_intake_access + active_intake_task = CorrespondenceIntakeTask.open.find_by(appeal_id: correspondence.id) + # route if no active task + route_user unless active_intake_task && user_can_work_intake(active_intake_task) + end + + # always allow supervisors and superusers to acccess intakes not assigned to them. + def user_can_work_intake(task) + (task.assigned_to == current_user) || + (current_user.inbound_ops_team_supervisor? || current_user.inbound_ops_team_superuser?) + end + + # redirect if no access + def route_user + if current_user.inbound_ops_team_user? + redirect_to "/queue/correspondence" + elsif current_user.inbound_ops_team_superuser? || current_user.inbound_ops_team_supervisor? + redirect_to "/queue/correspondence/team" + else + redirect_to "/unauthorized" + end + end +end diff --git a/app/controllers/correspondence_queue_controller.rb b/app/controllers/correspondence_queue_controller.rb new file mode 100644 index 00000000000..d4ad41d618c --- /dev/null +++ b/app/controllers/correspondence_queue_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class CorrespondenceQueueController < CorrespondenceController + def correspondence_cases + if current_user.inbound_ops_team_supervisor? + redirect_to "/queue/correspondence/team" + elsif current_user.inbound_ops_team_superuser? || current_user.inbound_ops_team_user? + intake_cancel_message(action_type) if %w[continue_later cancel_intake].include?(action_type) + respond_to do |format| + format.html {} + format.json do + render json: { correspondence_config: CorrespondenceConfig.new(assignee: current_user) } + end + end + else + redirect_to "/unauthorized" + end + end + + def correspondence_team + if current_user.inbound_ops_team_superuser? || current_user.inbound_ops_team_supervisor? + correspondence_team_response + elsif current_user.inbound_ops_team_user? + redirect_to "/queue/correspondence" + else + redirect_to "/unauthorized" + end + end + + private + + def correspondence_team_response + inbound_ops_team_user = User.find_by(css_id: params[:user].strip) if params[:user].present? + task_ids = params[:task_ids]&.split(",") if params[:task_ids].present? + tab = params[:tab] if params[:tab].present? + + respond_to do |format| + format.html do + @inbound_ops_team_users = User.inbound_ops_team_users.pluck(:css_id) + @inbound_ops_team_non_admin = User.inbound_ops_team_users.select(&:inbound_ops_team_user?).pluck(:css_id) + correspondence_team_html_response(inbound_ops_team_user, task_ids, tab) + end + format.json { correspondence_team_json_response } + end + end + + def correspondence_team_json_response + render json: { correspondence_config: CorrespondenceConfig.new(assignee: InboundOpsTeam.singleton) } + end + + def correspondence_team_html_response(inbound_ops_team_user, task_ids, tab) + if inbound_ops_team_user && task_ids.present? + # candidate for refactor using PATCH request + process_tasks_if_applicable(inbound_ops_team_user, task_ids, tab) + elsif %w[continue_later cancel_intake].include?(action_type) + intake_cancel_message(action_type) + end + end + + def action_type + params[:userAction].strip if params[:userAction].present? + end +end diff --git a/app/controllers/correspondence_response_letters_controller.rb b/app/controllers/correspondence_response_letters_controller.rb new file mode 100644 index 00000000000..ae32f0ac12b --- /dev/null +++ b/app/controllers/correspondence_response_letters_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CorrespondenceResponseLettersController < ApplicationController + def create + correspondence_response_letter = CorrespondenceResponseLetter.new(correspondence_response_letter_params) + if correspondence_response_letter.save + render json: correspondence_response_letter, status: :ok + else + render json: correspondence_response_letter.errors.full_messages, status: :unprocessable_entity + end + end + + def correspondence_response_letter_params + params.require(:correspondence_response_letter).permit(:title, :date_sent, :letter_type, :subcategory, :reason, + :response_window, :user_id) + end +end diff --git a/app/controllers/correspondence_review_package_controller.rb b/app/controllers/correspondence_review_package_controller.rb new file mode 100644 index 00000000000..814906bbe82 --- /dev/null +++ b/app/controllers/correspondence_review_package_controller.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +class CorrespondenceReviewPackageController < CorrespondenceController + def review_package + set_instance_variables + + respond_to do |format| + format.html + format.json { render json: build_json_response, status: :ok } + end + end + + def update + unless update_veteran_on_correspondence + return render(json: { error: "Please enter a valid Veteran ID" }, status: :unprocessable_entity) + end + + if update_open_review_package_tasks + # return the new JSON response to update the frontend with changes + render json: { correspondence: serialized_correspondence, status: :ok } + else + render json: { error: "Failed to update tasks" }, status: :internal_server_error + end + end + + def update_cmp + correspondence.update( + va_date_of_receipt: params["VADORDate"].in_time_zone + ) + + correspondence.tasks.map do |task| + if task.type == "ReviewPackageTask" + task.status = "in_progress" + task.save + end + end + render json: { status: 200, correspondence: correspondence } + end + + def document_type_correspondence + data = vbms_document_types + render json: { data: data } + end + + def pdf + # Hard-coding Document access until CorrespondenceDocuments are uploaded to S3Bucket + document = Document.limit(200)[pdf_params[:pdf_id].to_i] + + document_disposition = "inline" + if pdf_params[:download] + document_disposition = "attachment; filename='#{pdf_params[:type]}-#{pdf_params[:id]}.pdf'" + end + + # The line below enables document caching for a month. + expires_in 30.days, public: true + send_file( + document.serve, + type: "application/pdf", + disposition: document_disposition + ) + end + + private + + def set_instance_variables + @inbound_ops_team_users = User.inbound_ops_team_users.select(:css_id).pluck(:css_id) + @correspondence_types = CorrespondenceType.all + @has_efolder_failed_task = correspondence_has_efolder_failed_task? + @correspondence = serialized_correspondence + end + + def serialized_correspondence + WorkQueue::CorrespondenceSerializer + .new(correspondence) + .serializable_hash[:data][:attributes] + .merge(general_information) + end + + def build_json_response + { + correspondence: @correspondence, + general_information: general_information, + user_can_edit_vador: current_user.inbound_ops_team_supervisor?, + corres_docs: @correspondence[:correspondenceDocuments], + taskInstructions: task_instructions + } + end + + def task_instructions + CorrespondenceTask.package_action_tasks.open + .find_by(appeal_id: @correspondence[:id])&.instructions || "" + end + + def correspondence_has_efolder_failed_task? + correspondence.tasks.active.where(type: EfolderUploadFailedTask.name).exists? + end + + def correspondence_params + params.require(:correspondence).permit(:correspondence, :notes, :correspondence_type_id, :va_date_of_receipt) + .merge(params.require(:veteran).permit(:file_number, :first_name, :last_name)) + end + + def pdf_params + params.permit(pdf: [:pdf_id, :type, :id, :download]) + end + + def update_veteran_on_correspondence + veteran = Veteran.find_by(file_number: correspondence_params[:file_number]) + if veteran + correspondence.update!( + veteran_id: veteran.id, + notes: correspondence_params[:notes], + correspondence_type_id: correspondence_params[:correspondence_type_id], + va_date_of_receipt: correspondence_params[:va_date_of_receipt] + ) + true + else + false + end + end + + # :reek:FeatureEnvy + def update_open_review_package_tasks + begin + ActiveRecord::Base.transaction do + correspondence.tasks.open.where(type: ReviewPackageTask.name).find_each do |task| + task.update!(status: Constants.TASK_STATUSES.in_progress) + end + end + true + rescue ActiveRecord::RecordInvalid => error + Rails.logger.error "Failed to update task due to validation error: #{error.message}" + false + rescue StandardError => error + Rails.logger.error "Failed to update tasks due to an unexpected error: #{error.message}" + false + end + end + + # :reek:FeatureEnvy + def vbms_document_types + begin + data = ExternalApi::ClaimEvidenceService.document_types + rescue StandardError => error + Rails.logger.error(error.full_message) + data ||= demo_data + end + data.map { |document_type| { id: document_type["id"], name: document_type["description"] } } + end + + def demo_data + json_file_path = "./lib/fakes/constants/VBMS_DOC_TYPES.json" + JSON.parse(File.read(json_file_path)) + end +end diff --git a/app/controllers/correspondence_task_pages_controller.rb b/app/controllers/correspondence_task_pages_controller.rb new file mode 100644 index 00000000000..e210215aaec --- /dev/null +++ b/app/controllers/correspondence_task_pages_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CorrespondenceTaskPagesController < ApplicationController + include TaskPaginationConcern + + def index + render json: correspondence_pagination_json + end + + def assignee + @assignee ||= find_assignee + end + + private + + def task_pages_params + params.permit(:user_id, :organization_id) + end + + def find_assignee + if task_pages_params[:user_id] + User.find(task_pages_params[:user_id]) + elsif task_pages_params[:organization_id] + Organization.find(task_pages_params[:organization_id]) + end + end +end diff --git a/app/controllers/correspondence_tasks_controller.rb b/app/controllers/correspondence_tasks_controller.rb new file mode 100644 index 00000000000..131273d262d --- /dev/null +++ b/app/controllers/correspondence_tasks_controller.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +class CorrespondenceTasksController < TasksController + PACKAGE_ACTION_TYPES = [ + SplitPackageTask: SplitPackageTask, + MergePackageTask: MergePackageTask, + RemovePackageTask: RemovePackageTask, + ReassignPackageTask: ReassignPackageTask + ].freeze + + def create_package_action_task + review_package_task = ReviewPackageTask.find_by(appeal_id: correspondence_tasks_params[:correspondence_id]) + if review_package_task.children.open.present? + render json: + { message: "Existing package action request. Only one package action request may be made at a time" }, + status: :bad_request + else + task = task_to_create + task_params = { + parent_id: review_package_task.id, + instructions: correspondence_tasks_params[:instructions], + assigned_to: InboundOpsTeam.singleton, + appeal_id: correspondence_tasks_params[:correspondence_id], + appeal_type: "Correspondence", + status: Constants.TASK_STATUSES.assigned, + type: task.name + } + + ReviewPackageTask.create_from_params(task_params, current_user) + review_package_task.update!(assigned_to: InboundOpsTeam.singleton, status: :on_hold) + render json: { status: :ok } + end + end + + def create_correspondence_intake_task + review_package_task = ReviewPackageTask.open.find_by(appeal_id: correspondence_tasks_params[:id]) + return render json: { message: "Correspondence Root Task not found" }, status: :not_found unless review_package_task + + current_parent = review_package_task.parent + current_cit = CorrespondenceIntakeTask.open.find_by(parent_id: current_parent.id) + + if current_cit.present? + review_package_task.update!(assigned_to: current_user) + current_cit.update!(assigned_to: current_user) + render json: { status: :ok } + else + cit = CorrespondenceIntakeTask.create_from_params(current_parent, current_user) + if cit.present? + review_package_task.update!(assigned_to: current_user, status: :completed) + render json: { status: :ok } + else + render json: + { message: "No exist Correspondence Intake Task" }, + status: :bad_request + end + end + end + + def update + process_package_action_decision(correspondence_tasks_params[:decision]) + end + + def cancel + task = CorrespondenceTask.find(correspondence_tasks_params[:task_id]) + task.update!(status: Constants.TASK_STATUSES.cancelled) + end + + def complete + task = CorrespondenceTask.find(correspondence_tasks_params[:task_id]) + task.update!(status: Constants.TASK_STATUSES.completed) + end + + def change_task_type + @task = CorrespondenceTask.find(correspondence_tasks_params[:task_id]) + @task.update!( + type: change_task_type_params[:type], + instructions: change_task_type_params[:instructions] + ) + end + + private + + def correspondence_tasks_params + params.permit( + :correspondence_id, + :id, + :decision, + :task_id, + :new_assignee, + :decision_reason, + :action_type, + :type, + :correspondence_uuid, + instructions: [] + ) + end + + def change_task_type_params + change_type_params = params.require(:task).permit(:type, :instructions) + change_type_params[:instructions] = @task.flattened_instructions(change_type_params) + change_type_params + end + + def process_package_action_decision(decision) + task = CorrespondenceTask.find(correspondence_tasks_params[:task_id]) + requesting_user_name = task.assigned_by&.display_name + begin + case decision + when COPY::CORRESPONDENCE_QUEUE_PACKAGE_ACTION_DECISION_OPTIONS["APPROVE"] + if task.is_a?(ReassignPackageTask) + task.approve(current_user, User.find_by(css_id: correspondence_tasks_params[:new_assignee])) + elsif task.is_a?(RemovePackageTask) + task.approve(current_user) + end + when COPY::CORRESPONDENCE_QUEUE_PACKAGE_ACTION_DECISION_OPTIONS["REJECT"] + task.reject(current_user, correspondence_tasks_params[:decision_reason]) + end + package_action_flash(decision.upcase, requesting_user_name) + rescue StandardError + flash_error_banner(requesting_user_name) + end + end + + def package_action_flash(decision, user_name) + action = correspondence_tasks_params[:action_type].upcase + flash[:custom] = { + title: format( + COPY::CORRESPONDENCE_QUEUE_PACKAGE_ACTION_SUCCESS[action][decision]["TITLE"], + user_name + ), + message: COPY::CORRESPONDENCE_QUEUE_PACKAGE_ACTION_SUCCESS[action][decision]["MESSAGE"] + } + end + + def flash_error_banner(user_name) + operation_verb = (correspondence_tasks_params[:action_type] == "approve") ? "approved" : "rejected" + flash[:custom_error] = { + title: "Package request for #{user_name} could not be #{operation_verb}", + message: "Please try again at a later time or contact the Help Desk." + } + end + + def task_to_create + case correspondence_tasks_params[:type] + when "removePackage" + RemovePackageTask + when "mergePackage" + MergePackageTask + when "splitPackage" + SplitPackageTask + when "reassignPackage" + ReassignPackageTask + else + fail NotImplementedError "Type not implemented" + end + end +end diff --git a/app/controllers/explain_controller.rb b/app/controllers/explain_controller.rb index c1fb2b6ae2a..827cae0a9b0 100644 --- a/app/controllers/explain_controller.rb +++ b/app/controllers/explain_controller.rb @@ -36,7 +36,7 @@ def access_allowed? Rails.env.development? end - helper_method :legacy_appeal?, :appeal, :appeal_affinity, + helper_method :legacy_appeal?, :correspondence?, :appeal, :appeal_affinity, :show_pii_query_param, :fields_query_param, :sections_query_param, :treee_fields, :enabled_sections, :available_fields, @@ -51,15 +51,22 @@ def appeal_object_id end def explain_as_text - [ - "show_pii = #{show_pii_query_param}", - task_tree_as_text, - intake_as_text, - hearing_as_text, - JSON.pretty_generate(event_table_data), - JSON.pretty_generate(timeline_data), - JSON.pretty_generate(network_graph_data) - ].join("\n\n") + if correspondence? + [ + "show_pii = #{show_pii_query_param}", + appeal.tree(*treee_fields) + ].join("\n\n") + else + [ + "show_pii = #{show_pii_query_param}", + task_tree_as_text, + intake_as_text, + hearing_as_text, + JSON.pretty_generate(event_table_data), + JSON.pretty_generate(timeline_data), + JSON.pretty_generate(network_graph_data) + ].join("\n\n") + end end def available_fields @@ -127,6 +134,7 @@ def hearing_as_text def sanitized_json return "(LegacyAppeals are not yet supported)".to_json if legacy_appeal? + return "(Correspondences are not yet supported)".to_json if correspondence? SanitizedJsonExporter.new(appeal, sanitize: !show_pii_query_param, verbosity: 0).file_contents end @@ -143,11 +151,17 @@ def legacy_appeal? appeal.is_a?(LegacyAppeal) end + def correspondence? + appeal.is_a?(Correspondence) + end + def appeal @appeal ||= fetch_appeal end def appeal_affinity + return if correspondence? + @appeal_affinity ||= if legacy_appeal? VACOLS::Case.find_by(bfkey: appeal.vacols_id)&.appeal_affinity else @@ -156,7 +170,9 @@ def appeal_affinity end def fetch_appeal - if appeal_id.start_with?("ama-") + if params[:correspondence_uuid].present? + Correspondence.find_by_uuid(params[:correspondence_uuid]) + elsif appeal_id.start_with?("ama-") record_id = appeal_id.delete_prefix("ama-") Appeal.find_by_id(record_id) elsif appeal_id.start_with?("legacy-") diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb index 34f4569dbf4..76e2e092fae 100644 --- a/app/controllers/issues_controller.rb +++ b/app/controllers/issues_controller.rb @@ -75,7 +75,7 @@ def create_legacy_issue_update_task(issue) # close out any tasks that might be open open_issue_task = Task.where( assigned_to: SpecialIssueEditTeam.singleton - ).where(status: "assigned").where(appeal: appeal) + ).where(status: Constants.TASK_STATUSES.assigned).where(appeal: appeal) open_issue_task[0].delete unless open_issue_task.empty? task = IssuesUpdateTask.create!( diff --git a/app/controllers/organizations/users_controller.rb b/app/controllers/organizations/users_controller.rb index 6a125576a84..61c1beddfee 100644 --- a/app/controllers/organizations/users_controller.rb +++ b/app/controllers/organizations/users_controller.rb @@ -2,11 +2,16 @@ class Organizations::UsersController < OrganizationsController def index + @permissions = organization.organization_permissions.select( + :permission, :description, :enabled, :parent_permission_id, :default_for_admin, :id + ) + + @user_permissions = user_permissions + respond_to do |format| format.html { render template: "queue/index" } format.json do organization_users = organization.users - render json: { organization_name: organization.name, judge_team: organization.type == JudgeTeam.name, @@ -19,6 +24,26 @@ def index end end + def modify_user_permission + user_id, permission_name = user_permission_params + + org_permission = organization.organization_permissions.find_by(permission: permission_name) + target_user = organization.organizations_users.find_by(user_id: user_id) + + if org_user_permission_checker.can?( + permission_name: org_permission.permission, + organization: organization, + user: target_user.user + ) + disable_permission(user_id: user_id, org_permission: org_permission, target_user: target_user) + render json: { checked: false } + + else + enable_permission(user_id: user_id, org_permission: org_permission, target_user: target_user) + render json: { checked: true } + end + end + def create organization.add_user(user_to_modify, current_user) @@ -55,6 +80,38 @@ def verify_role_access private + def user_permissions + organization.organizations_users.sort_by(&:user_id).as_json( + only: [:user_id], + include: [ + organization_user_permissions: { include: [organization_permission: { only: [:permission, :permitted] }] } + ] + ) + end + + def disable_permission(user_id:, org_permission:, target_user:) + organization.organizations_users + .find_by(user_id: user_id).organization_user_permissions + .find_by( + organization_permission: org_permission, + organizations_user: target_user + ) + .update(permitted: false) + end + + def enable_permission(user_id:, org_permission:, target_user:) + organization.organizations_users.find_by(user_id: user_id) + .organization_user_permissions + .find_or_create_by!( + organization_permission: org_permission, + organizations_user: target_user + ).update!(permitted: true) + end + + def org_user_permission_checker + @org_user_permission_checker ||= OrganizationUserPermissionChecker.new + end + def user_to_modify @user_to_modify ||= User.find(params.require(:id)) end @@ -100,4 +157,11 @@ def json_administered_users(users) params: { organization: organization } ) end + + def user_permission_params + params.permit(:userId, :permissionName) + user_id = params[:userId] + permission_name = params[:permissionName].strip + [user_id, permission_name] + end end diff --git a/app/controllers/team_management_controller.rb b/app/controllers/team_management_controller.rb index ca378ca0b06..05a88053bfb 100644 --- a/app/controllers/team_management_controller.rb +++ b/app/controllers/team_management_controller.rb @@ -114,13 +114,16 @@ def all_teams end def other_orgs + rejected_orgs = [ + JudgeTeam, + DvcTeam, + Representative, + VhaProgramOffice, + VhaRegionalOffice, + EducationRpo + ] Organization.order(:name).reject do |org| - org.is_a?(JudgeTeam) || - org.is_a?(DvcTeam) || - org.is_a?(Representative) || - org.is_a?(VhaProgramOffice) || - org.is_a?(VhaRegionalOffice) || - org.is_a?(EducationRpo) + rejected_orgs.any? { |excluded_org| org.is_a?(excluded_org) } end end diff --git a/app/controllers/test/correspondence_controller.rb b/app/controllers/test/correspondence_controller.rb new file mode 100644 index 00000000000..d91cd31f67b --- /dev/null +++ b/app/controllers/test/correspondence_controller.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "rake" + +class Test::CorrespondenceController < ApplicationController + include RunAsyncable + + before_action :verify_access, only: [:index] + before_action :verify_feature_toggle, only: [:index] + + VALID_CE_API_VBMS_DOCUMENT_TYPE_IDS = [ + 163, 1186, 1320, 1333, 1348, 1458, 1608, 1643, 1754 + ].freeze + + def index + render_access_error unless verify_access && access_allowed? + end + + def generate_correspondence + nums = correspondence_params[:file_numbers].split(",").map(&:strip).reject(&:empty?) + result = classify_file_numbers(nums) + invalid_nums = result[:invalid] + valid_file_nums = result[:valid] + + begin + connect_corr_with_vet(valid_file_nums, correspondence_params[:count].to_i) + render json: { + invalid_file_numbers: invalid_nums, + valid_file_nums: valid_file_nums + }, status: :created + rescue StandardError => error + log_error(error) + end + end + + private + + def correspondence_params + params.permit(:file_numbers, :count) + end + + def verify_access + return true if current_user.admin? || current_user.inbound_ops_team_supervisor? || bva? + + redirect_to "/unauthorized" + end + + def bva? + Bva.singleton.user_has_access?(current_user) + end + + def access_allowed? + Rails.env.development? || + Rails.env.test? || + Rails.deploy_env?(:uat) || + Rails.deploy_env?(:demo) + end + + def render_access_error + render(Caseflow::Error::ActionForbiddenError.new( + message: COPY::ACCESS_DENIED_TITLE + ).serialize_response) + end + + def verify_feature_toggle + correspondence_queue = FeatureToggle.enabled?(:correspondence_queue) + correspondence_admin = FeatureToggle.enabled?(:correspondence_admin) + if !correspondence_queue && verify_access + redirect_to "/under_construction" + elsif !correspondence_queue || !verify_access || correspondence_admin + redirect_to "/unauthorized" + end + end + + def valid_veteran?(file_number) + if Rails.deploy_env?(:uat) + veteran = VeteranFinder.find_best_match(file_number) + veteran&.fetch_bgs_record.present? + else + veteran = Veteran.find_by(file_number: file_number) + veteran.present? + end + end + + def classify_file_numbers(file_number_arr) + valid_file_nums = [] + invalid_file_nums = [] + + file_number_arr.each do |file_number| + if valid_veteran?(file_number) + valid_file_nums << file_number + else + invalid_file_nums << file_number + end + end + + { valid: valid_file_nums, invalid: invalid_file_nums } + end + + def connect_corr_with_vet(valid_file_nums, count) + count.times do + valid_file_nums.each do |file| + veteran = Veteran.find_by_file_number(file) + ActiveRecord::Base.transaction do + correspondence = create_correspondence(veteran) + rand(1..5).times do + create_correspondence_document(veteran, correspondence) + end + if correspondence.nod? + correspondence.correspondence_documents.last.update!( + document_type: 1250, + vbms_document_type_id: 1250 + ) + end + end + end + end + + auto_assign_correspondence + end + + def create_correspondence(veteran) + Correspondence.create!( + uuid: SecureRandom.uuid, + correspondence_type_id: rand(1..8), + va_date_of_receipt: Faker::Date.between(from: 90.days.ago, to: Time.zone.yesterday), + notes: "This is a test note", + veteran: veteran, + nod: rand(2).zero? + ) + end + + def create_correspondence_document(veteran, correspondence) + doc_type = VALID_CE_API_VBMS_DOCUMENT_TYPE_IDS.sample + CorrespondenceDocument.find_or_create_by( + document_file_number: veteran.file_number, + uuid: SecureRandom.uuid, + correspondence_id: correspondence.id, + document_type: doc_type, + vbms_document_type_id: doc_type, + pages: rand(1..30) + ) + end + + def auto_assign_correspondence + batch = BatchAutoAssignmentAttempt.create!( + user: current_user, + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.started + ) + + job_args = { + current_user_id: current_user.id, + batch_auto_assignment_attempt_id: batch.id + } + + perform_later_or_now(AutoAssignCorrespondenceJob, job_args) + end +end diff --git a/app/controllers/test/users_controller.rb b/app/controllers/test/users_controller.rb index 4f28830ee7f..ebcbfb8419b 100644 --- a/app/controllers/test/users_controller.rb +++ b/app/controllers/test/users_controller.rb @@ -16,6 +16,7 @@ class Test::UsersController < ApplicationController links: { your_queue: "/queue", assignment_queue: "/queue/USER_CSS_ID/assign", # USER_CSS_ID is then updated in TestUsers file + correspondence_admin: "/test/correspondence", case_distribution_dashboard: "/acd-controls/test" } }, diff --git a/app/jobs/ama_notification_efolder_sync_job.rb b/app/jobs/ama_notification_efolder_sync_job.rb index bbf9b6db122..bb640e08332 100644 --- a/app/jobs/ama_notification_efolder_sync_job.rb +++ b/app/jobs/ama_notification_efolder_sync_job.rb @@ -40,7 +40,7 @@ def appeals_recently_outcoded Appeal .where(id: RootTask.where( appeal_type: "Appeal", - status: "completed", + status: Constants.TASK_STATUSES.completed, closed_at: 1.day.ago..Time.zone.now ) .pluck(:appeal_id) diff --git a/app/jobs/auto_assign_correspondence_job.rb b/app/jobs/auto_assign_correspondence_job.rb new file mode 100644 index 00000000000..e586351f75c --- /dev/null +++ b/app/jobs/auto_assign_correspondence_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AutoAssignCorrespondenceJob < CaseflowJob + queue_with_priority :low_priority + + def perform(current_user_id:, batch_auto_assignment_attempt_id:) + correspondence_auto_assigner.perform( + current_user_id: current_user_id, + batch_auto_assignment_attempt_id: batch_auto_assignment_attempt_id + ) + end + + private + + def correspondence_auto_assigner + @correspondence_auto_assigner ||= CorrespondenceAutoAssigner.new + end +end diff --git a/app/jobs/legacy_notification_efolder_sync_job.rb b/app/jobs/legacy_notification_efolder_sync_job.rb index 4107d2a9a45..6d810b552b0 100644 --- a/app/jobs/legacy_notification_efolder_sync_job.rb +++ b/app/jobs/legacy_notification_efolder_sync_job.rb @@ -40,7 +40,7 @@ def appeals_recently_outcoded LegacyAppeal .where(id: RootTask.where( appeal_type: "LegacyAppeal", - status: "completed", + status: Constants.TASK_STATUSES.completed, closed_at: 1.day.ago..Time.zone.now ) .pluck(:appeal_id) diff --git a/app/models/appeal.rb b/app/models/appeal.rb index 4691f672563..866d21da3a6 100644 --- a/app/models/appeal.rb +++ b/app/models/appeal.rb @@ -26,6 +26,8 @@ class Appeal < DecisionReview has_many :email_recipients, class_name: "HearingEmailRecipient" has_many :available_hearing_locations, as: :appeal, class_name: "AvailableHearingLocations" has_many :vbms_uploaded_documents, as: :appeal + has_many :correspondence_appeals + has_many :correspondences, through: :correspondence_appeals has_many :notifications, as: :notifiable # decision_documents is effectively a has_one until post decisional motions are supported @@ -963,6 +965,10 @@ def can_redistribute_appeal? return true if relevant_tasks.all?(&:closed?) end + def open_cavc_task + CavcTask.open.where(appeal_id: self.id).any? + end + def is_legacy? false end diff --git a/app/models/auto_text.rb b/app/models/auto_text.rb new file mode 100644 index 00000000000..98472757963 --- /dev/null +++ b/app/models/auto_text.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# this is a lookup table +class AutoText < ApplicationRecord +end diff --git a/app/models/batch_auto_assignment_attempt.rb b/app/models/batch_auto_assignment_attempt.rb new file mode 100644 index 00000000000..4aa51a184df --- /dev/null +++ b/app/models/batch_auto_assignment_attempt.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BatchAutoAssignmentAttempt < CaseflowRecord + include AutoAssignable + + belongs_to :user, optional: false + + has_many :individual_auto_assignment_attempts, dependent: :destroy +end diff --git a/app/models/cached_appeal.rb b/app/models/cached_appeal.rb index e17d150c976..e9b598f5690 100644 --- a/app/models/cached_appeal.rb +++ b/app/models/cached_appeal.rb @@ -4,6 +4,7 @@ class CachedAppeal < CaseflowRecord self.table_name = "cached_appeal_attributes" # For convenience when working in the Rails console + scope :correspondence, -> { where(appeal_type: "Correspondence") } scope :ama_appeal, -> { where(appeal_type: "Appeal") } scope :legacy_appeal, -> { where(appeal_type: "LegacyAppeal") } scope :docket, ->(docket) { where(docket_type: docket) } diff --git a/app/models/concerns/appealable_correspondence.rb b/app/models/concerns/appealable_correspondence.rb new file mode 100644 index 00000000000..aff419c811e --- /dev/null +++ b/app/models/concerns/appealable_correspondence.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +## +# Allows a Correspondence to respond to Appeal methods +# +# Ultimately, probably need to revisit the inheritance hierarchy here and give these models a common ancestor +# or something +module AppealableCorrespondence + extend ActiveSupport::Concern + + def active? + true + end + + def cavc? + true + end + + def open_cavc_task + true + end + + def root_task + Task.find_by(appeal_id: id, appeal_type: type, type: CorrespondenceRootTask.name) + end +end diff --git a/app/models/concerns/auto_assignable.rb b/app/models/concerns/auto_assignable.rb new file mode 100644 index 00000000000..f409a24ca1a --- /dev/null +++ b/app/models/concerns/auto_assignable.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module AutoAssignable + extend ActiveSupport::Concern + include ActiveModel::Validations + + VALID_STATUSES = [ + Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.started, + Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.completed, + Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.error + ].freeze + + included do + validates :status, inclusion: { in: VALID_STATUSES } + end +end diff --git a/app/models/concerns/issue_updater.rb b/app/models/concerns/issue_updater.rb index 3b5347083be..0115b4edacd 100644 --- a/app/models/concerns/issue_updater.rb +++ b/app/models/concerns/issue_updater.rb @@ -133,7 +133,7 @@ def create_issue_update_task(original_issue, decision_issue) # close out any tasks that might be open open_issue_task = Task.where( assigned_to: SpecialIssueEditTeam.singleton - ).where(status: "assigned").where(appeal: appeal) + ).where(status: Constants.TASK_STATUSES.assigned).where(appeal: appeal) open_issue_task[0].delete unless open_issue_task.empty? task = IssuesUpdateTask.create!( diff --git a/app/models/concerns/task_belongs_to_polymorphic_appeal_concern.rb b/app/models/concerns/task_belongs_to_polymorphic_appeal_concern.rb index 56a321d80e4..3a17d3154df 100644 --- a/app/models/concerns/task_belongs_to_polymorphic_appeal_concern.rb +++ b/app/models/concerns/task_belongs_to_polymorphic_appeal_concern.rb @@ -22,9 +22,14 @@ module TaskBelongsToPolymorphicAppealConcern -> { where(tasks: { appeal_type: "SupplementalClaim" }) }, class_name: "SupplementalClaim", foreign_key: "appeal_id", optional: true + belongs_to :correspondence, + -> { where(tasks: { appeal_type: "Correspondence" }) }, + class_name: "Correspondence", foreign_key: "appeal_id", optional: true + scope :ama, -> { where(appeal_type: "Appeal") } scope :legacy, -> { where(appeal_type: "LegacyAppeal") } scope :higher_level_review, -> { where(appeal_type: "HigherLevelReview") } scope :supplemental_claim, -> { where(appeal_type: "SupplementalClaim") } + scope :correspondence, -> { where(appeal_type: "Correspondence") } end end diff --git a/app/models/correspondence.rb b/app/models/correspondence.rb new file mode 100644 index 00000000000..5fe882ccd61 --- /dev/null +++ b/app/models/correspondence.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Correspondence is a top level object similar to Appeals. +# Serves as a collection of all data related to Correspondence workflow +class Correspondence < CaseflowRecord + validates :veteran_id, presence: true + + has_paper_trail + include PrintsTaskTree + include AppealableCorrespondence + + has_many :correspondence_documents, dependent: :destroy + has_many :correspondence_appeals, dependent: :destroy + has_many :appeals, through: :correspondence_appeals + has_many :correspondence_relations, dependent: :destroy + has_many :related_correspondences, through: :correspondence_relations, dependent: :destroy + has_many :correspondence_response_letters, dependent: :destroy + belongs_to :correspondence_type + belongs_to :veteran + + after_create :initialize_correspondence_tasks + + # root task manages checks for different child tasks + delegate :tasks_not_related_to_an_appeal, to: :root_task + delegate :closed_tasks_not_related_to_an_appeal, to: :root_task + delegate :correspondence_mail_tasks, to: :root_task + delegate :open_package_action_task, to: :root_task + delegate :review_package_task, to: :root_task + delegate :open_intake_task, to: :root_task + + def initialize_correspondence_tasks + CorrespondenceRootTaskFactory.new(self).create_root_and_sub_tasks! + end + + def status + root_task&.correspondence_status + end + + def type + "Correspondence" + end + + # Alias for cmp_packet_number + def docket_number + nil + end + + # Alias for package_document_type.name + def docket_name + nil + end + + # Cannot use has_many :tasks - Task model does not contain a correspondence_id column + def tasks + CorrespondenceTask.where(appeal_id: id, appeal_type: type) + end + + def root_task + CorrespondenceRootTask.find_by(appeal_id: id, appeal_type: type) + end + + def cancel_task_tree_for_appeal_intake + tasks.where(type: ReviewPackageTask.name).update_all( + instructions: "An appeal intake was started because this Correspondence is a 10182" + ) + tasks.update_all(status: Constants.TASK_STATUSES.cancelled) + end + + # Methods below are included to allow Correspondences to render in explain page + def veteran_full_name + veteran.name + end + + def self.prior_mail(veteran_id, uuid) + includes([:veteran, :correspondence_type]) + .where(veteran_id: veteran_id).where.not(uuid: uuid) + end +end diff --git a/app/models/correspondence_appeal.rb b/app/models/correspondence_appeal.rb new file mode 100644 index 00000000000..d4186377d06 --- /dev/null +++ b/app/models/correspondence_appeal.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class CorrespondenceAppeal < ApplicationRecord + belongs_to :correspondence + belongs_to :appeal + has_many :correspondences_appeals_tasks + has_many :tasks, through: :correspondences_appeals_tasks +end diff --git a/app/models/correspondence_auto_assignment_lever.rb b/app/models/correspondence_auto_assignment_lever.rb new file mode 100644 index 00000000000..1e1b9ec64b0 --- /dev/null +++ b/app/models/correspondence_auto_assignment_lever.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CorrespondenceAutoAssignmentLever < CaseflowRecord + has_paper_trail on: [:update, :destroy] + + class << self + def capacity_rule + find_by(name: "capacity") + end + + def max_capacity + CorrespondenceAutoAssignmentLever.capacity_rule&.value || + Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.max_assigned_tasks + end + end +end diff --git a/app/models/correspondence_config.rb b/app/models/correspondence_config.rb new file mode 100644 index 00000000000..6712a25caba --- /dev/null +++ b/app/models/correspondence_config.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# :reek:RepeatedConditional + +class CorrespondenceConfig < QueueConfig + def to_hash + { + table_title: table_title, + active_tab: default_active_tab, + tasks_per_page: 15, + use_task_pages_api: true, + tabs: assignee.correspondence_queue_tabs.map { |tab| attach_tasks_to_tab(tab) } + } + end + + private + + # :reek:FeatureEnvy + def attach_tasks_to_tab(tab) + task_pager = CorrespondenceTaskPager.new( + assignee: assignee, + tab_name: tab.name, + sort_by: tab.default_sorting_column.name, + sort_order: tab.default_sorting_direction + ) + endpoint = "task_pages?#{Constants.QUEUE_CONFIG.TAB_NAME_REQUEST_PARAM}=#{tab.name}" + base_path = if assignee_is_org? + "organizations/#{assignee.id}/#{endpoint}" + else + "correspondence/users/#{assignee.id}/#{endpoint}" + end + + tab.to_hash.merge( + tasks: serialized_tasks_for_columns(task_pager.paged_tasks, tab.column_names), + task_page_count: task_pager.task_page_count, + total_task_count: task_pager.total_task_count, + task_page_endpoint_base_path: base_path + ) + end + + def serialized_tasks_for_columns(tasks, columns) + WorkQueue::CorrespondenceTaskColumnSerializer.new( + tasks, + is_collection: true, + params: { columns: columns } + ).serializable_hash[:data] + end + + def table_title + if assignee_is_org? + Constants.QUEUE_CONFIG.CORRESPONDENCE_ORG_TABLE_TITLE + else + Constants.QUEUE_CONFIG.CORRESPONDENCE_USER_TABLE_TITLE + end + end + + def default_active_tab + if assignee_is_org? + Constants.QUEUE_CONFIG.CORRESPONDENCE_UNASSIGNED_TASKS_TAB_NAME + else + Constants.QUEUE_CONFIG.CORRESPONDENCE_ASSIGNED_TASKS_TAB_NAME + end + end + + def default_sorting_column + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name + end +end diff --git a/app/models/correspondence_document.rb b/app/models/correspondence_document.rb new file mode 100644 index 00000000000..71e8417cf35 --- /dev/null +++ b/app/models/correspondence_document.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class CorrespondenceDocument < CaseflowRecord + belongs_to :correspondence + belongs_to :vbms_document_type + + # callbacks + after_update :update_correspondence_nod + + def pdf_name + "#{uuid}.pdf" + end + + # :reek:UtilityFunction + # :nocov: + def pdf_location + File.join(Rails.root, "lib", "pdfs", "KnockKnockJokes.pdf") + end + # :nocov: + + # contentName, providerData: {contentSource:, documentTypeId:, dateVaReceivedDocument:} are required fields + def claim_evidence_upload_hash + { + contentName: pdf_name, + providerData: { + contentSource: "VISTA", + claimantFirstName: correspondence.veteran.first_name, + claimantLastName: correspondence.veteran.last_name, + claimantParticipantId: correspondence.veteran.participant_id, + claimantSsn: correspondence.veteran.ssn, + documentTypeId: vbms_document_type_id, + dateVaReceivedDocument: correspondence.va_date_of_receipt.strftime("%Y-%m-%d"), + actionable: true + } + } + end + + def update_correspondence_nod + documents = correspondence.correspondence_documents + nod = documents.any? do |doc| + doc["vbms_document_type_id"] && Caseflow::DocumentTypes::TYPES[doc["vbms_document_type_id"]]&.include?("10182") + end + correspondence.update!( + nod: nod + ) + end +end diff --git a/app/models/correspondence_intake.rb b/app/models/correspondence_intake.rb new file mode 100644 index 00000000000..7e1ee50a6af --- /dev/null +++ b/app/models/correspondence_intake.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CorrespondenceIntake < ApplicationRecord + belongs_to :task + + validates :task_id, presence: true + validate :task_type_is_correct, on: :create + + def task_type_is_correct + errors.add(:task, "Must be CorrespondenceIntakeTask") unless task&.type == CorrespondenceIntakeTask.name + end +end diff --git a/app/models/correspondence_queue_column.rb b/app/models/correspondence_queue_column.rb new file mode 100644 index 00000000000..4f16fee0bed --- /dev/null +++ b/app/models/correspondence_queue_column.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class CorrespondenceQueueColumn < QueueColumn + include ActiveModel::Model + + attr_accessor :filterable, :name + + def initialize(args) + super + @filterable ||= false + end + + FILTER_OPTIONS = { + Constants.QUEUE_CONFIG.COLUMNS.TASK_TYPE.name => :task_type_options, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name => :va_dor_options, + Constants.QUEUE_CONFIG.COLUMNS.TASK_CLOSED_DATE.name => :date_completed_options, + Constants.QUEUE_CONFIG.COLUMNS.TASK_ASSIGNEE.name => :task_type_options, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name => :package_document_type_options + }.freeze + + private + + def package_document_type_options(tasks) + tasks.joins(:appeal).group(:nod).count.each_pair.map do |option, count| + label = if option + self.class.format_option_label(Constants.QUEUE_CONFIG.PACKAGE_DOC_TYPE_FILTER_OPTIONS.NOD, count) + else + self.class.format_option_label(Constants.QUEUE_CONFIG.PACKAGE_DOC_TYPE_FILTER_OPTIONS.NON_NOD, count) + end + self.class.filter_option_hash(option.to_s, label) + end + end + + # placeholder method because the function is required + def va_dor_options(dummy) + dummy + end + + def date_completed_options(dummy) + dummy + end +end diff --git a/app/models/correspondence_queue_tab.rb b/app/models/correspondence_queue_tab.rb new file mode 100644 index 00000000000..92bf21904de --- /dev/null +++ b/app/models/correspondence_queue_tab.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class CorrespondenceQueueTab < QueueTab + def columns + column_names.map { |column_name| CorrespondenceQueueColumn.from_name(column_name) } + end + + def task_includes + [ + { appeal: [:veteran] }, + :assigned_by, + :children, + :parent + ] + end + + # :reek:UtilityFunction + def default_sorting_column + CorrespondenceQueueColumn.from_name(Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name) + end + + # If you don't create your own tab name it will default to the tab defined in QueueTab + def self.from_name(tab_name) + tab = descendants.find { |subclass| subclass.tab_name == tab_name } + fail(Caseflow::Error::InvalidTaskTableTab, tab_name: tab_name) unless tab + + tab + end + + def column_names + self.class.column_names + end +end diff --git a/app/models/correspondence_relation.rb b/app/models/correspondence_relation.rb new file mode 100644 index 00000000000..a1fa33c9688 --- /dev/null +++ b/app/models/correspondence_relation.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CorrespondenceRelation < ApplicationRecord + belongs_to :correspondence + belongs_to :related_correspondence, class_name: "Correspondence" + + # Makes the relationship bi-directional - both Correspondences are aware of the relationship + after_create :create_inverse, unless: :inverse_exists? + after_destroy :destroy_inverses, if: :inverse_exists? + + validates :correspondence_id, presence: true + validates :related_correspondence_id, presence: true + validates :correspondence_id, numericality: true + validates :related_correspondence_id, numericality: true + + def create_inverse + self.class.create(inverse_match_options) + end + + def destroy_inverses + inverses.destroy_all + end + + def inverse_exists? + self.class.exists?(inverse_match_options) + end + + def inverses + self.class.where(inverse_match_options) + end + + def inverse_match_options + { related_correspondence_id: correspondence_id, correspondence_id: related_correspondence_id } + end +end diff --git a/app/models/correspondence_response_letter.rb b/app/models/correspondence_response_letter.rb new file mode 100644 index 00000000000..6d29100ff86 --- /dev/null +++ b/app/models/correspondence_response_letter.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CorrespondenceResponseLetter < CaseflowRecord + belongs_to :correspondence +end diff --git a/app/models/correspondence_task_pager.rb b/app/models/correspondence_task_pager.rb new file mode 100644 index 00000000000..53a6eb494c6 --- /dev/null +++ b/app/models/correspondence_task_pager.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CorrespondenceTaskPager < TaskPager + def filtered_tasks + CorrespondenceTaskFilter.new(filter_params: filters, tasks: tasks_for_tab).filtered_tasks + end + + def queue_tab + @queue_tab ||= CorrespondenceQueueTab.from_name(tab_name).new(assignee: assignee) + end + + def paged_tasks + @paged_tasks ||= begin + tasks = sorted_tasks(filtered_tasks) + pagination_enabled ? tasks.page(page).per(TASKS_PER_PAGE) : tasks + end + end +end diff --git a/app/models/correspondence_type.rb b/app/models/correspondence_type.rb new file mode 100644 index 00000000000..48512797729 --- /dev/null +++ b/app/models/correspondence_type.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CorrespondenceType < ApplicationRecord + has_many :correspondences +end diff --git a/app/models/correspondences_appeals_task.rb b/app/models/correspondences_appeals_task.rb new file mode 100644 index 00000000000..70b5c584739 --- /dev/null +++ b/app/models/correspondences_appeals_task.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class CorrespondencesAppealsTask < ApplicationRecord + belongs_to :correspondence_appeal + belongs_to :task +end diff --git a/app/models/individual_auto_assignment_attempt.rb b/app/models/individual_auto_assignment_attempt.rb new file mode 100644 index 00000000000..11dfb2752c1 --- /dev/null +++ b/app/models/individual_auto_assignment_attempt.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class IndividualAutoAssignmentAttempt < CaseflowRecord + include AutoAssignable + + belongs_to :user, optional: false + belongs_to :correspondence, optional: false + belongs_to :batch_auto_assignment_attempt, optional: false + + validates :nod, inclusion: [true, false] +end diff --git a/app/models/legacy_tasks/attorney_legacy_task.rb b/app/models/legacy_tasks/attorney_legacy_task.rb index 28276dbc28b..fe188382c1d 100644 --- a/app/models/legacy_tasks/attorney_legacy_task.rb +++ b/app/models/legacy_tasks/attorney_legacy_task.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class AttorneyLegacyTask < LegacyTask + # :reek:ControlParameter def available_actions(current_user, role) return [] if role != "attorney" || current_user != assigned_to @@ -9,11 +10,9 @@ def available_actions(current_user, role) # so we use the absence of this value to indicate that there is no case assignment and return no actions. return [] unless task_id - actions = [Constants.TASK_ACTIONS.REVIEW_LEGACY_DECISION.to_h, - Constants.TASK_ACTIONS.SUBMIT_OMO_REQUEST_FOR_REVIEW.to_h, - Constants.TASK_ACTIONS.ADD_ADMIN_ACTION.to_h] - - actions + [Constants.TASK_ACTIONS.REVIEW_LEGACY_DECISION.to_h, + Constants.TASK_ACTIONS.SUBMIT_OMO_REQUEST_FOR_REVIEW.to_h, + Constants.TASK_ACTIONS.ADD_ADMIN_ACTION.to_h] end def timeline_title diff --git a/app/models/organization.rb b/app/models/organization.rb index 20808069ad5..925b4986bb3 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -6,6 +6,7 @@ class Organization < CaseflowRecord has_many :organizations_users, dependent: :destroy has_many :users, through: :organizations_users has_many :membership_requests + has_many :organization_permissions has_many :non_admin_users, -> { non_admin }, class_name: "OrganizationsUser" require_dependency "dvc_team" @@ -142,6 +143,16 @@ def queue_tabs ] end + def correspondence_queue_tabs + [ + correspondence_unassigned_tasks_tab, + correspondence_action_required_tasks_tab, + correspondence_pending_tasks_tab, + correspondence_assigned_tasks_tab, + correspondence_completed_tasks_tab + ] + end + def unassigned_tasks_tab ::OrganizationUnassignedTasksTab.new( assignee: self, @@ -162,6 +173,26 @@ def completed_tasks_tab ::OrganizationCompletedTasksTab.new(assignee: self, show_regional_office_column: show_regional_office_in_queue?) end + def correspondence_unassigned_tasks_tab + ::OrganizationCorrespondenceUnassignedTasksTab.new(assignee: self) + end + + def correspondence_action_required_tasks_tab + ::OrganizationCorrespondenceActionRequiredTasksTab.new(assignee: self) + end + + def correspondence_assigned_tasks_tab + ::OrganizationCorrespondenceAssignedTasksTab.new(assignee: self) + end + + def correspondence_pending_tasks_tab + ::OrganizationCorrespondencePendingTasksTab.new(assignee: self) + end + + def correspondence_completed_tasks_tab + ::OrganizationCorrespondenceCompletedTasksTab.new(assignee: self) + end + def serialize { accepts_priority_pushed_cases: accepts_priority_pushed_cases, diff --git a/app/models/organization_permission.rb b/app/models/organization_permission.rb new file mode 100644 index 00000000000..de22aaf74f6 --- /dev/null +++ b/app/models/organization_permission.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class OrganizationPermission < CaseflowRecord + belongs_to :organization, optional: false + + belongs_to :parent_permission, class_name: "OrganizationPermission", optional: true + has_many :child_permissions, class_name: "OrganizationPermission", foreign_key: "parent_permission_id", + dependent: :destroy + + has_many :organization_user_permissions, dependent: :destroy + + validates :description, presence: true + validates :enabled, inclusion: [true, false] + + validate :valid_permission + + def valid_permission + errors.add(:permission, "Invalid permission") unless + self.class.valid_permission_names.include?(permission) + end + + class << self + def valid_permission_names + Constants.ORGANIZATION_PERMISSIONS.to_h.values + end + + def auto_assign(organization) + find_by(organization: organization, permission: Constants.ORGANIZATION_PERMISSIONS.auto_assign) + end + + def receive_nod_mail(organization) + find_by(organization: organization, permission: Constants.ORGANIZATION_PERMISSIONS.receive_nod_mail) + end + end +end diff --git a/app/models/organization_user_permission.rb b/app/models/organization_user_permission.rb new file mode 100644 index 00000000000..fefad5ea344 --- /dev/null +++ b/app/models/organization_user_permission.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class OrganizationUserPermission < CaseflowRecord + belongs_to :organizations_user, optional: false + belongs_to :organization_permission, optional: false + + validates :permitted, inclusion: [true, false] +end diff --git a/app/models/organizations/inbound_ops_team.rb b/app/models/organizations/inbound_ops_team.rb new file mode 100644 index 00000000000..4cc21b3b0f7 --- /dev/null +++ b/app/models/organizations/inbound_ops_team.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class InboundOpsTeam < Organization + class << self + def singleton + InboundOpsTeam.first || + InboundOpsTeam.create(name: "Inbound Ops Team", url: "inbound-ops-team") + end + + def super_users + super_users = [] + + OrganizationsUser.includes(:user).where(organization: InboundOpsTeam.singleton).find_each do |org_user| + user = org_user.user + + if user.inbound_ops_team_superuser? + super_users.push(user) + end + end + + super_users + end + end + + # :reek:UtilityFunction + def selectable_in_queue? + false + end + + def can_receive_task?(task) + task.can_be_received_by?(self) + end +end diff --git a/app/models/organizations_user.rb b/app/models/organizations_user.rb index 189d98de647..99d9d6eb38d 100644 --- a/app/models/organizations_user.rb +++ b/app/models/organizations_user.rb @@ -4,8 +4,9 @@ class OrganizationsUser < CaseflowRecord belongs_to :organization belongs_to :user - scope :non_admin, -> { where(admin: false) } + has_many :organization_user_permissions, dependent: :destroy + scope :non_admin, -> { where(admin: false) } scope :admin, -> { where(admin: true) } class << self diff --git a/app/models/queue_column.rb b/app/models/queue_column.rb index b848c345925..162d0dae631 100644 --- a/app/models/queue_column.rb +++ b/app/models/queue_column.rb @@ -41,7 +41,8 @@ def to_hash(tasks) }.freeze def filter_options(tasks) - filter_option_func = FILTER_OPTIONS[name] + filter_option_func = self.class::FILTER_OPTIONS[name] + if filter_option_func send(filter_option_func, tasks) else @@ -68,6 +69,14 @@ def self.filter_option_hash(value, label) private + def va_dor_options + nil + end + + def date_completed_options + nil + end + def case_type_options(tasks) options = tasks.with_cached_appeals.group(:case_type).count.each_pair.map do |option, count| label = self.class.format_option_label(option, count) diff --git a/app/models/queue_tabs/correspondence_assigned_tasks_tab.rb b/app/models/queue_tabs/correspondence_assigned_tasks_tab.rb new file mode 100644 index 00000000000..139c3206f1c --- /dev/null +++ b/app/models/queue_tabs/correspondence_assigned_tasks_tab.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CorrespondenceAssignedTasksTab < CorrespondenceQueueTab + validate :assignee_is_user + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_ASSIGNED_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_ASSIGNED_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_ASSIGNED_TASKS_DESCRIPTION + end + + def tasks + CorrespondenceTask.includes(*task_includes).user_assigned_tasks(assignee) + end + + # :reek:UtilityFunction + def self.column_names + [ + Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name, + Constants.QUEUE_CONFIG.COLUMNS.DAYS_WAITING_CORRESPONDENCE.name, + Constants.QUEUE_CONFIG.COLUMNS.NOTES.name + ] + end +end diff --git a/app/models/queue_tabs/correspondence_completed_tasks_tab.rb b/app/models/queue_tabs/correspondence_completed_tasks_tab.rb new file mode 100644 index 00000000000..fd8df8c1e67 --- /dev/null +++ b/app/models/queue_tabs/correspondence_completed_tasks_tab.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class CorrespondenceCompletedTasksTab < CorrespondenceQueueTab + validate :assignee_is_user + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_COMPLETED_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_COMPLETED_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_COMPLETED_TASKS_DESCRIPTION + end + + def tasks + # root task ids for all the assignee's tasks + potential_root_tasks = assignee.tasks.select(:parent_id) + .where(status: Constants.TASK_STATUSES.completed).distinct.pluck(:parent_id) + + CorrespondenceTask.includes(*task_includes).where( + id: potential_root_tasks + ).completed_root_tasks + end + + # :reek:UtilityFunction + def self.column_names + [ + Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name, + Constants.QUEUE_CONFIG.COLUMNS.CORRESPONDENCE_TASK_CLOSED_DATE.name, + Constants.QUEUE_CONFIG.COLUMNS.NOTES.name + ] + end +end diff --git a/app/models/queue_tabs/correspondence_in_progress_tasks_tab.rb b/app/models/queue_tabs/correspondence_in_progress_tasks_tab.rb new file mode 100644 index 00000000000..07774224068 --- /dev/null +++ b/app/models/queue_tabs/correspondence_in_progress_tasks_tab.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CorrespondenceInProgressTasksTab < CorrespondenceQueueTab + validate :assignee_is_user + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_IN_PROGRESS_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_IN_PROGRESS_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_IN_PROGRESS_TASKS_DESCRIPTION + end + + def tasks + CorrespondenceTask.includes(*task_includes).user_in_progress_tasks(assignee) + end + + # :reek:UtilityFunction + def self.column_names + [ + Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name, + Constants.QUEUE_CONFIG.COLUMNS.TASK_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.DAYS_WAITING_CORRESPONDENCE.name, + Constants.QUEUE_CONFIG.COLUMNS.NOTES.name + ] + end +end diff --git a/app/models/queue_tabs/organization_completed_tasks_tab.rb b/app/models/queue_tabs/organization_completed_tasks_tab.rb index f189af5cada..385a3961f1c 100644 --- a/app/models/queue_tabs/organization_completed_tasks_tab.rb +++ b/app/models/queue_tabs/organization_completed_tasks_tab.rb @@ -3,6 +3,7 @@ class OrganizationCompletedTasksTab < QueueTab validate :assignee_is_organization + # :reek:UtilityFunction def label COPY::QUEUE_PAGE_COMPLETE_TAB_TITLE end @@ -11,6 +12,7 @@ def self.tab_name Constants.QUEUE_CONFIG.COMPLETED_TASKS_TAB_NAME end + # :reek:UtilityFunction def description COPY::QUEUE_PAGE_COMPLETE_LAST_SEVEN_DAYS_TASKS_DESCRIPTION end @@ -19,6 +21,7 @@ def tasks recently_completed_tasks end + # :reek:UtilityFunction # rubocop:disable Metrics/AbcSize def column_names [ diff --git a/app/models/queue_tabs/organization_correspondence_action_required_tasks_tab.rb b/app/models/queue_tabs/organization_correspondence_action_required_tasks_tab.rb new file mode 100644 index 00000000000..7404ae0ad80 --- /dev/null +++ b/app/models/queue_tabs/organization_correspondence_action_required_tasks_tab.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class OrganizationCorrespondenceActionRequiredTasksTab < CorrespondenceQueueTab + validate :assignee_is_organization + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_ACTION_REQUIRED_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_ACTION_REQUIRED_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_ACTION_REQUIRED_TASKS_DESCRIPTION + end + + def tasks + tasks = CorrespondenceTask.includes(*task_includes).action_required_tasks + + return tasks if RequestStore[:current_user].inbound_ops_team_supervisor? + + tasks.where.not(type: RemovePackageTask.name) + end + + # :reek:UtilityFunction + def self.column_names + [ + Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name, + Constants.QUEUE_CONFIG.COLUMNS.DAYS_WAITING_CORRESPONDENCE.name, + Constants.QUEUE_CONFIG.COLUMNS.TASK_ASSIGNED_BY.name, + Constants.QUEUE_CONFIG.COLUMNS.ACTION_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.NOTES.name + ] + end +end diff --git a/app/models/queue_tabs/organization_correspondence_assigned_tasks_tab.rb b/app/models/queue_tabs/organization_correspondence_assigned_tasks_tab.rb new file mode 100644 index 00000000000..c6a55974a36 --- /dev/null +++ b/app/models/queue_tabs/organization_correspondence_assigned_tasks_tab.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class OrganizationCorrespondenceAssignedTasksTab < CorrespondenceQueueTab + validate :assignee_is_organization + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_TEAM_ASSIGNED_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_TEAM_ASSIGNED_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_TEAM_ASSIGNED_TASKS_DESCRIPTION + end + + def tasks + CorrespondenceTask.includes(*task_includes).assigned_tasks + end + + # :reek:UtilityFunction + def self.column_names + columns = Constants.QUEUE_CONFIG.COLUMNS + [ + columns.CHECKBOX_COLUMN.name, + columns.VETERAN_DETAILS.name, + columns.PACKAGE_DOCUMENT_TYPE.name, + columns.VA_DATE_OF_RECEIPT.name, + columns.DAYS_WAITING_CORRESPONDENCE.name, + columns.TASK_TYPE.name, + columns.TASK_ASSIGNEE.name, + columns.NOTES.name + ] + end +end diff --git a/app/models/queue_tabs/organization_correspondence_completed_tasks_tab.rb b/app/models/queue_tabs/organization_correspondence_completed_tasks_tab.rb new file mode 100644 index 00000000000..24b1c0a8935 --- /dev/null +++ b/app/models/queue_tabs/organization_correspondence_completed_tasks_tab.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class OrganizationCorrespondenceCompletedTasksTab < CorrespondenceQueueTab + validate :assignee_is_organization + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_TEAM_COMPLETED_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_TEAM_COMPLETED_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_TEAM_COMPLETED_TASKS_DESCRIPTION + end + + def tasks + CorrespondenceTask.includes(*task_includes).completed_root_tasks + end + + # :reek:UtilityFunction + def self.column_names + [ + Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name, + Constants.QUEUE_CONFIG.COLUMNS.CORRESPONDENCE_TASK_CLOSED_DATE.name, + Constants.QUEUE_CONFIG.COLUMNS.NOTES.name + ] + end +end diff --git a/app/models/queue_tabs/organization_correspondence_pending_tasks_tab.rb b/app/models/queue_tabs/organization_correspondence_pending_tasks_tab.rb new file mode 100644 index 00000000000..b82aa791396 --- /dev/null +++ b/app/models/queue_tabs/organization_correspondence_pending_tasks_tab.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class OrganizationCorrespondencePendingTasksTab < CorrespondenceQueueTab + validate :assignee_is_organization + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_PENDING_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_PENDING_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_PENDING_TASKS_DESCRIPTION + end + + # :reek:UtilityFunction + def tasks + CorrespondenceTask.includes(*task_includes).pending_tasks + end + + # :reek:UtilityFunction + def self.column_names + [ + Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name, + Constants.QUEUE_CONFIG.COLUMNS.DAYS_WAITING_CORRESPONDENCE.name, + Constants.QUEUE_CONFIG.COLUMNS.TASK_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.TASK_ASSIGNEE.name + ] + end +end diff --git a/app/models/queue_tabs/organization_correspondence_unassigned_tasks_tab.rb b/app/models/queue_tabs/organization_correspondence_unassigned_tasks_tab.rb new file mode 100644 index 00000000000..c62bfe84d19 --- /dev/null +++ b/app/models/queue_tabs/organization_correspondence_unassigned_tasks_tab.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class OrganizationCorrespondenceUnassignedTasksTab < CorrespondenceQueueTab + validate :assignee_is_organization + + # :reek:UtilityFunction + def label + Constants.QUEUE_CONFIG.CORRESPONDENCE_UNASSIGNED_TASKS_LABEL + end + + def self.tab_name + Constants.QUEUE_CONFIG.CORRESPONDENCE_UNASSIGNED_TASKS_TAB_NAME + end + + # :reek:UtilityFunction + def description + Constants.QUEUE_CONFIG.CORRESPONDENCE_UNASSIGNED_TASKS_DESCRIPTION + end + + def tasks + CorrespondenceTask.includes(*task_includes).unassigned_tasks + end + + # :reek:UtilityFunction + def self.column_names + user = RequestStore.store[:current_user] + columns = [ + Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name, + Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name, + Constants.QUEUE_CONFIG.COLUMNS.DAYS_WAITING_CORRESPONDENCE.name, + Constants.QUEUE_CONFIG.COLUMNS.NOTES.name + ] + columns.insert(0, Constants.QUEUE_CONFIG.COLUMNS.CHECKBOX_COLUMN.name) unless user.inbound_ops_team_superuser? + columns + end +end diff --git a/app/models/queues/attorney_queue.rb b/app/models/queues/attorney_queue.rb index aacf2d7203e..168f03e315c 100644 --- a/app/models/queues/attorney_queue.rb +++ b/app/models/queues/attorney_queue.rb @@ -18,8 +18,7 @@ def tasks record.status = Constants.TASK_STATUSES.on_hold end end - - caseflow_tasks = user.tasks.includes(*task_includes).incomplete_or_recently_completed + caseflow_tasks = user.tasks.not_correspondence.includes(*task_includes).incomplete_or_recently_completed (colocated_tasks_for_attorney_tasks + caseflow_tasks).flatten end diff --git a/app/models/queues/generic_queue.rb b/app/models/queues/generic_queue.rb index 42fae3704e7..2eb2d801c91 100644 --- a/app/models/queues/generic_queue.rb +++ b/app/models/queues/generic_queue.rb @@ -17,6 +17,7 @@ def tasks def relevant_tasks Task.incomplete_or_recently_completed.visible_in_queue_table_view .where(assigned_to: user) + .not_correspondence .includes(*task_includes) .order(created_at: :asc) .limit(limit) diff --git a/app/models/queues/judge_queue.rb b/app/models/queues/judge_queue.rb index 7e713bc5a0c..2aed3781ffb 100644 --- a/app/models/queues/judge_queue.rb +++ b/app/models/queues/judge_queue.rb @@ -2,6 +2,6 @@ class JudgeQueue < GenericQueue def tasks - super.active.where(type: JudgeAssignTask.name) + super.active.not_correspondence.where(type: JudgeAssignTask.name) end end diff --git a/app/models/request_issues_update.rb b/app/models/request_issues_update.rb index 70ba1f7eede..4fbf0d219c7 100644 --- a/app/models/request_issues_update.rb +++ b/app/models/request_issues_update.rb @@ -406,7 +406,7 @@ def create_issue_update_task(change_type, before_issue, after_issue = nil) # close out any tasks that might be open open_issue_task = Task.where( assigned_to: SpecialIssueEditTeam.singleton - ).where(status: "assigned").where(appeal: before_issue.decision_review) + ).where(status: Constants.TASK_STATUSES.assigned).where(appeal: before_issue.decision_review) open_issue_task[0].delete unless open_issue_task.empty? task = IssuesUpdateTask.create!( diff --git a/app/models/serializers/work_queue/administered_user_serializer.rb b/app/models/serializers/work_queue/administered_user_serializer.rb index 61b86097292..423078de3da 100644 --- a/app/models/serializers/work_queue/administered_user_serializer.rb +++ b/app/models/serializers/work_queue/administered_user_serializer.rb @@ -11,4 +11,13 @@ class WorkQueue::AdministeredUserSerializer < WorkQueue::UserSerializer params[:organization].dvc&.eql?(object) end end + attribute :user_permission do |object, params| + object&.organization_permissions(params[:organization]) + end + attribute :user_admin_permission do |object, params| + object&.organization_admin_permissions(params[:organization]) + end + attribute :description do |object, params| + object&.organization_admin_permissions(params[:organization]) + end end diff --git a/app/models/serializers/work_queue/appeal_search_serializer.rb b/app/models/serializers/work_queue/appeal_search_serializer.rb index 90be5a6f498..a1554d5686e 100644 --- a/app/models/serializers/work_queue/appeal_search_serializer.rb +++ b/app/models/serializers/work_queue/appeal_search_serializer.rb @@ -139,6 +139,16 @@ class WorkQueue::AppealSearchSerializer object.veteran ? object.veteran.id : nil end + attribute :docket_switch do |object| + if object.docket_switch + WorkQueue::DocketSwitchSerializer.new(object.docket_switch).serializable_hash[:data][:attributes] + end + end + + attribute :evidence_submission_task do |object| + object.tasks.find_by(type: "EvidenceSubmissionWindowTask", status: Constants.TASK_STATUSES.assigned) + end + attribute :readable_hearing_request_type, &:readable_current_hearing_request_type attribute :readable_original_hearing_request_type, &:readable_original_hearing_request_type diff --git a/app/models/serializers/work_queue/appeal_serializer.rb b/app/models/serializers/work_queue/appeal_serializer.rb index 1b6cdb0b63d..a8d611ceb5e 100644 --- a/app/models/serializers/work_queue/appeal_serializer.rb +++ b/app/models/serializers/work_queue/appeal_serializer.rb @@ -333,5 +333,9 @@ class WorkQueue::AppealSerializer ]).count end + attribute :evidence_submission_task do |object| + object.tasks.find_by(type: "EvidenceSubmissionWindowTask", status: Constants.TASK_STATUSES.assigned) + end + attribute :has_completed_sct_assign_task, &:completed_specialty_case_team_assign_task? end diff --git a/app/models/serializers/work_queue/correspondence_appeals_serializer.rb b/app/models/serializers/work_queue/correspondence_appeals_serializer.rb new file mode 100644 index 00000000000..5b72edb2657 --- /dev/null +++ b/app/models/serializers/work_queue/correspondence_appeals_serializer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class WorkQueue::CorrespondenceAppealsSerializer + include FastJsonapi::ObjectSerializer + + set_key_transform :camel_lower + + attribute :id + attribute :correspondences_appeals_tasks + attribute :docket_number do |object| + object.appeal.docket_number + end + + attribute :veteran_name do |object| + object.appeal.veteran + end + + attribute :stream_type do |object| + object.appeal.stream_type + end + + attribute :appeal_uuid do |object| + object.appeal.uuid + end + + attribute :appeal_type do |object| + object.appeal.docket_type + end + + attribute :number_of_issues do |object| + object.appeal.issues.length + end + + attribute :task_added_data do |object| + tasks = [] + object.correspondences_appeals_tasks.each do |cor_app_task| + assigned_to = cor_app_task.task.assigned_to + assigned_to_text = assigned_to.is_a?(Organization) ? assigned_to.name : assigned_to.css_id + task_data = { + assigned_at: cor_app_task.task.assigned_at, + assigned_to: assigned_to_text, + assigned_to_type: cor_app_task.task.assigned_to_type, + instructions: cor_app_task.task.instructions, + type: cor_app_task.task.label + } + tasks << task_data + end + tasks + end + + attribute :status do |object| + object.correspondence.status + end + + attribute :assigned_to do |object| + object.tasks[0]&.assigned_to + end + + attribute :correspondence do |object| + object + end +end diff --git a/app/models/serializers/work_queue/correspondence_document_serializer.rb b/app/models/serializers/work_queue/correspondence_document_serializer.rb new file mode 100644 index 00000000000..98655a419ad --- /dev/null +++ b/app/models/serializers/work_queue/correspondence_document_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class WorkQueue::CorrespondenceDocumentSerializer + include FastJsonapi::ObjectSerializer + + attribute :id + attribute :correspondence_id + attribute :document_file_number + attribute :pages + attribute :vbms_document_type_id + attribute :uuid + attribute :document_type + attribute :document_title do |object| + doc_id = object.attributes["vbms_document_type_id"] + Caseflow::DocumentTypes::TYPES[doc_id] + end +end diff --git a/app/models/serializers/work_queue/correspondence_response_letter_serializer.rb b/app/models/serializers/work_queue/correspondence_response_letter_serializer.rb new file mode 100644 index 00000000000..ecdc755b5d6 --- /dev/null +++ b/app/models/serializers/work_queue/correspondence_response_letter_serializer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class WorkQueue::CorrespondenceResponseLetterSerializer + include FastJsonapi::ObjectSerializer + + attribute :id + attribute :correspondence_id + attribute :letter_type + attribute :title + attribute :subcategory + attribute :reason + attribute :date_sent + attribute :response_window + attribute :user_id + + # Days left calculation as an attribute + attribute :days_left do |object| + date_sent = object.date_sent.to_date + response_window = object.response_window + + if response_window.nil? + "No response window required" + else + expiration_date = date_sent + response_window.days + days_remaining = (expiration_date - Time.zone.today).to_i + + if days_remaining > 0 + day_label = (days_remaining == 1) ? "day" : "days" + "#{expiration_date.strftime('%m/%d/%Y')} (#{days_remaining} #{day_label} left)" + else + "Expired on #{expiration_date.strftime('%m/%d/%Y')}" + end + end + end +end diff --git a/app/models/serializers/work_queue/correspondence_serializer.rb b/app/models/serializers/work_queue/correspondence_serializer.rb new file mode 100644 index 00000000000..269b8718351 --- /dev/null +++ b/app/models/serializers/work_queue/correspondence_serializer.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +class WorkQueue::CorrespondenceSerializer + include FastJsonapi::ObjectSerializer + + set_key_transform :camel_lower + + attribute :uuid + attribute :id + attribute :notes + attribute :va_date_of_receipt + attribute :nod + attribute :status + attribute :type + attribute :veteran_id + attribute :correspondence_documents do |object| + object.correspondence_documents.map do |document| + WorkQueue::CorrespondenceDocumentSerializer.new(document).serializable_hash[:data][:attributes] + end + end + + attribute :correspondence_type do |object| + object.correspondence_type&.name + end + + attribute :tasks_unrelated_to_appeal do |object| + filtered_tasks = object.tasks_not_related_to_an_appeal + + tasks = [] + + unless filtered_tasks.empty? + filtered_tasks.each do |task| + tasks << + { + label: task.label, + assignedOn: task.assigned_at.strftime("%m/%d/%Y"), + assignedTo: (task.assigned_to_type == "Organization") ? task.assigned_to.name : task.assigned_to.css_id, + type: task.assigned_to_type, + instructions: task.instructions, + availableActions: task.available_actions_unwrapper(RequestStore[:current_user]), + uniqueId: task.id, + status: task.status + } + end + end + tasks + end + + attribute :closed_tasks_unrelated_to_appeal do |object| + filtered_tasks = object.closed_tasks_not_related_to_an_appeal + tasks = [] + + unless filtered_tasks.empty? + filtered_tasks.each do |task| + tasks << + { + label: task.label, + assignedOn: task.assigned_at.strftime("%m/%d/%Y"), + assignedTo: (task.assigned_to_type == "Organization") ? task.assigned_to.name : task.assigned_to.css_id, + type: task.assigned_to_type, + instructions: task.instructions, + availableActions: task.available_actions_unwrapper(RequestStore[:current_user]), + uniqueId: task.id, + status: task.status + } + end + end + tasks + end + + attribute :correspondence_appeals do |object| + appeals = [] + object.correspondence_appeals.map do |appeal| + appeals << WorkQueue::CorrespondenceAppealsSerializer.new(appeal).serializable_hash[:data][:attributes] + end + appeals + end + + attribute :veteran_full_name do |object| + [object.veteran_full_name&.first_name, object.veteran_full_name&.last_name].join(" ") + end + + attribute :veteran_file_number do |object| + object.veteran&.file_number + end + + attribute :correspondence_appeal_ids do |object| + object.appeal_ids.map(&:to_s) + end + + attribute :correspondence_response_letters do |object| + object.correspondence_response_letters.map do |response_letter| + WorkQueue::CorrespondenceResponseLetterSerializer.new(response_letter).serializable_hash[:data][:attributes] + end + end + + attribute :related_correspondence_ids, &:related_correspondence_ids +end diff --git a/app/models/serializers/work_queue/correspondence_task_column_serializer.rb b/app/models/serializers/work_queue/correspondence_task_column_serializer.rb new file mode 100644 index 00000000000..19f8237c5a8 --- /dev/null +++ b/app/models/serializers/work_queue/correspondence_task_column_serializer.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +class WorkQueue::CorrespondenceTaskColumnSerializer + include FastJsonapi::ObjectSerializer + + def self.serialize_attribute?(params, columns) + (params[:columns] & columns).any? + end + + attribute :unique_id do |object| + object.id.to_s + end + + attribute :instructions + + attribute :nod + + attribute :veteran_details do |object| + vet = Veteran.find(object.correspondence.veteran_id) + "#{vet.first_name} #{vet.last_name} (#{vet.file_number})" + end + + attribute :notes do |object, params| + columns = [Constants.QUEUE_CONFIG.COLUMNS.NOTES.name] + if serialize_attribute?(params, columns) + object.correspondence.notes + end + end + + attribute :closed_at do |object, params| + columns = [Constants.QUEUE_CONFIG.COLUMNS.CORRESPONDENCE_TASK_CLOSED_DATE.name] + + if serialize_attribute?(params, columns) + object.completed_by_date + end + end + + attribute :days_waiting do |object, params| + columns = [Constants.QUEUE_CONFIG.COLUMNS.DAYS_WAITING_CORRESPONDENCE.name] + + if serialize_attribute?(params, columns) + object.days_waiting + end + end + + attribute :va_date_of_receipt do |object| + object.correspondence.va_date_of_receipt + end + + attribute :label do |object, params| + columns = [ + Constants.QUEUE_CONFIG.COLUMNS.TASK_TYPE.name, + Constants.QUEUE_CONFIG.COLUMNS.ACTION_TYPE.name + ] + + if serialize_attribute?(params, columns) + object.label + end + end + + attribute :assigned_at + + attribute :task_url + + attribute :parent_task_url do |object| + if object.is_a?(ReassignPackageTask) || object.is_a?(RemovePackageTask) + { parent_task_url: object.parent.task_url } + else + { parent_task_url: "" } + end + end + + attribute :assigned_to do |object, params| + columns = [ + Constants.QUEUE_CONFIG.COLUMNS.TASK_ASSIGNEE.name + ] + assignee = object.assigned_to + + if serialize_attribute?(params, columns) + { + name: assignee.is_a?(Organization) ? assignee.name : assignee.css_id + } + else + { + name: nil + } + end + end + + attribute :assigned_by do |object, params| + columns = [ + Constants.QUEUE_CONFIG.COLUMNS.TASK_ASSIGNED_BY.name + ] + + if serialize_attribute?(params, columns) + { + first_name: object.assigned_by_display_name.first, + last_name: object.assigned_by_display_name.last + } + else + { + first_name: nil, + last_name: nil + } + end + end +end diff --git a/app/models/serializers/work_queue/correspondence_task_serializer.rb b/app/models/serializers/work_queue/correspondence_task_serializer.rb new file mode 100644 index 00000000000..be65dfaeeb6 --- /dev/null +++ b/app/models/serializers/work_queue/correspondence_task_serializer.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class WorkQueue::CorrespondenceTaskSerializer + include FastJsonapi::ObjectSerializer + + attribute :id + attribute :appeal_id + attribute :appeal_type + attribute :assigned_at + attribute :assigned_by_id + attribute :assigned_to_id + attribute :assigned_to_type + attribute :cancellation_reason + attribute :cancelled_by_id + attribute :closed_at + attribute :completed_by_id + attribute :created_at + attribute :instructions + attribute :parent_id + attribute :placed_on_hold_at + attribute :started_at + attribute :status + attribute :updated_at + attribute :type + # other location for task actions. Will be needed for correspondence related tasks + attribute :available_actions do |object, params| + object.available_actions_unwrapper(params[:user]) + end +end diff --git a/app/models/serializers/work_queue/task_column_serializer.rb b/app/models/serializers/work_queue/task_column_serializer.rb index da5cc6bee81..a006371f454 100644 --- a/app/models/serializers/work_queue/task_column_serializer.rb +++ b/app/models/serializers/work_queue/task_column_serializer.rb @@ -174,8 +174,8 @@ def self.serialize_attribute?(params, columns) if serialize_attribute?(params, columns) { css_id: assignee.try(:css_id), - is_organization: assignee.is_a?(Organization), name: assignee.is_a?(Organization) ? assignee.name : assignee.css_id, + is_organization: assignee.is_a?(Organization), type: assignee.class.name, id: assignee.id } diff --git a/app/models/task.rb b/app/models/task.rb index f74de948c52..edac8061ba7 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -26,6 +26,8 @@ class Task < CaseflowRecord has_many :attorney_case_reviews, dependent: :destroy has_many :task_timers, dependent: :destroy + has_one :correspondences_appeals_task + has_one :correspondence_appeal, through: :correspondences_appeals_task has_one :cached_appeal, ->(task) { where(appeal_type: task.appeal_type) }, foreign_key: :appeal_id validates :assigned_to, :appeal, :type, :status, presence: true @@ -53,7 +55,8 @@ class Task < CaseflowRecord Constants.TASK_STATUSES.in_progress.to_sym => Constants.TASK_STATUSES.in_progress, Constants.TASK_STATUSES.on_hold.to_sym => Constants.TASK_STATUSES.on_hold, Constants.TASK_STATUSES.completed.to_sym => Constants.TASK_STATUSES.completed, - Constants.TASK_STATUSES.cancelled.to_sym => Constants.TASK_STATUSES.cancelled + Constants.TASK_STATUSES.cancelled.to_sym => Constants.TASK_STATUSES.cancelled, + Constants.TASK_STATUSES.unassigned.to_sym => Constants.TASK_STATUSES.unassigned } enum cancellation_reason: { @@ -113,6 +116,8 @@ class << self; undef_method :open; end scope :with_assignees, -> { joins(Task.joins_with_assignees_clause) } + scope :not_correspondence, -> { where.not(appeal_type: "Correspondence") } + scope :with_assigners, -> { joins(Task.joins_with_assigners_clause) } scope :with_cached_appeals, -> { joins(Task.joins_with_cached_appeals_clause) } @@ -155,7 +160,7 @@ def active_statuses end def open_statuses - active_statuses.concat([Constants.TASK_STATUSES.on_hold]) + active_statuses.concat([Constants.TASK_STATUSES.on_hold, Constants.TASK_STATUSES.unassigned]) end def create_many_from_params(params_array, current_user) @@ -187,13 +192,17 @@ def verify_user_can_create!(user, parent) end if !parent&.actions_allowable?(user) || !can_create - user_description = user ? "User #{user.id}" : "nil User" - parent_description = parent ? " from #{parent.class.name} #{parent.id}" : "" - message = "#{user_description} cannot assign #{name}#{parent_description}." - fail Caseflow::Error::ActionForbiddenError, message: message + fail Caseflow::Error::ActionForbiddenError, message: user_can_create_error_message(user, parent) end end + def user_can_create_error_message(user, parent) + user_description = user.present? ? "User #{user.id}" : "nil User" + parent_description = parent.present? ? "from #{parent.class.name} #{parent.id}" : "" + + "#{user_description} cannot assign #{name} #{parent_description}." + end + def child_task_assignee(_parent, params) Object.const_get(params[:assigned_to_type]).find(params[:assigned_to_id]) end @@ -621,6 +630,12 @@ def verify_user_can_update!(user) end end + def can_be_received_by?(team) + return false if assigned_to?(team) + + false if parent_assigned_to?(team) + end + # rubocop:disable Metrics/AbcSize def reassign(reassign_params, current_user) # We do not validate the number of tasks in this scenario because when a @@ -836,6 +851,14 @@ def create_and_auto_assign_child_task(options = {}) end end + def assigned_to?(team) + assigned_to == team + end + + def parent_assigned_to?(team) + assigned_to.is_a?(User) && parent && parent.assigned_to == team + end + def automatically_assign_org_task? assigned_to.is_a?(Organization) && assigned_to.automatically_assign_to_member? end @@ -933,7 +956,8 @@ def set_cancelled_by_id in_progress: :started_at, on_hold: :placed_on_hold_at, completed: :closed_at, - cancelled: :closed_at + cancelled: :closed_at, + unassigned: :assigned_at }.freeze def set_timestamp diff --git a/app/models/task_pager.rb b/app/models/task_pager.rb index dccce8a2d60..d5565bc993c 100644 --- a/app/models/task_pager.rb +++ b/app/models/task_pager.rb @@ -25,7 +25,7 @@ def initialize(args) def paged_tasks @paged_tasks ||= begin - tasks = sorted_tasks(filtered_tasks) + tasks = sorted_tasks(filtered_tasks).not_correspondence limit = queue_tab.custom_task_limit || TASKS_PER_PAGE pagination_enabled ? tasks.page(page).per(limit) : tasks end diff --git a/app/models/task_sorter.rb b/app/models/task_sorter.rb index 13c364866cd..6ae085350f8 100644 --- a/app/models/task_sorter.rb +++ b/app/models/task_sorter.rb @@ -21,13 +21,35 @@ def initialize(args) fail(Caseflow::Error::MissingRequiredProperty, message: errors.full_messages.join(", ")) unless valid? end + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity def sorted_tasks return tasks unless tasks.any? - # Always join to the CachedAppeal and users tables because we sometimes need it, joining does not slow down the - # application, and conditional logic to only join sometimes adds unnecessary complexity. - tasks.with_assignees.with_assigners.with_cached_appeals.order(order_clause) + # used for when the logic on the sorting needs to be reversed + reverse_sort_order = sort_order == "asc" ? "desc" : "asc" + + # The following cases are used for Correspondence Queue tables + case column.name + when Constants.QUEUE_CONFIG.COLUMNS.VETERAN_DETAILS.name + tasks.joins(appeal: :veteran).order(last_name: sort_order.to_sym, first_name: sort_order.to_sym) + when Constants.QUEUE_CONFIG.COLUMNS.NOTES.name + tasks.joins(:appeal).order(notes: sort_order.to_sym) + when Constants.QUEUE_CONFIG.COLUMNS.VA_DATE_OF_RECEIPT.name + tasks.joins(:appeal).order(va_date_of_receipt: sort_order.to_sym) + when Constants.QUEUE_CONFIG.COLUMNS.DAYS_WAITING_CORRESPONDENCE.name + tasks.joins(:appeal).order(assigned_at: reverse_sort_order.to_sym) + when Constants.QUEUE_CONFIG.COLUMNS.CORRESPONDENCE_TASK_CLOSED_DATE.name + tasks.joins(:appeal).order(closed_at: sort_order.to_sym) + when Constants.QUEUE_CONFIG.COLUMNS.PACKAGE_DOCUMENT_TYPE.name + # Reverse sort since nod is a bool column. However the UI text needs to be sorted by display value. + tasks.joins(:appeal).order(nod: reverse_sort_order.to_sym) + else + # Always join to the CachedAppeal and users tables because we sometimes need it, joining does not slow down the + # application, and conditional logic to only join sometimes adds unnecessary complexity. + tasks.with_assignees.with_assigners.with_cached_appeals.order(order_clause) + end end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity private diff --git a/app/models/tasks/address_change_correspondence_mail_task.rb b/app/models/tasks/address_change_correspondence_mail_task.rb new file mode 100644 index 00000000000..d331d742aee --- /dev/null +++ b/app/models/tasks/address_change_correspondence_mail_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddressChangeCorrespondenceMailTask < CorrespondenceMailTask + def self.label + COPY::ADDRESS_CHANGE_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/associated_with_claims_folder_mail_task.rb b/app/models/tasks/associated_with_claims_folder_mail_task.rb new file mode 100644 index 00000000000..42a4b973dba --- /dev/null +++ b/app/models/tasks/associated_with_claims_folder_mail_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AssociatedWithClaimsFolderMailTask < CorrespondenceMailTask + def self.label + COPY::ASSOCIATED_WITH_CLAIMS_FOLDER_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/cavc_correspondence_mail_task.rb b/app/models/tasks/cavc_correspondence_mail_task.rb index eed371eb471..cd56d7da7de 100644 --- a/app/models/tasks/cavc_correspondence_mail_task.rb +++ b/app/models/tasks/cavc_correspondence_mail_task.rb @@ -49,7 +49,7 @@ def appeal_at_cavc_lit_support end def open_cavc_task - CavcTask.open.where(appeal_id: appeal.id).any? + appeal.open_cavc_task end def organization_task_actions @@ -77,4 +77,12 @@ def user_task_assigned_to_cavc_lit_support def assigned_to_cavc_lit_team_member CavcLitigationSupport.singleton.users.include?(assigned_to) end + + def status_is_valid_on_create + unless [Constants.TASK_STATUSES.assigned, Constants.TASK_STATUSES.completed].include?(status) + fail Caseflow::Error::InvalidStatusOnTaskCreate, task_type: type + end + + true + end end diff --git a/app/models/tasks/congressional_interest_mail_task.rb b/app/models/tasks/congressional_interest_mail_task.rb index 63d72048f78..82982841298 100644 --- a/app/models/tasks/congressional_interest_mail_task.rb +++ b/app/models/tasks/congressional_interest_mail_task.rb @@ -12,4 +12,24 @@ def self.label def self.default_assignee(_parent) LitigationSupport.singleton end + + def self.available_actions(user) + return [] unless user + + options = [ + Constants.TASK_ACTIONS.CHANGE_CORR_TASK_TYPE.to_h, + Constants.TASK_ACTIONS.ASSIGN_CORR_TASK_TO_TEAM.to_h, + Constants.TASK_ACTIONS.MARK_TASK_COMPLETE.to_h, + Constants.TASK_ACTIONS.RETURN_TO_INBOUND_OPS.to_h, + Constants.TASK_ACTIONS.CANCEL_CORR_TASK.to_h + ] + + if user.assigned_to.name == User.name + options.insert(2, Constants.TASK_ACTIONS.ASSIGN_CORR_TASK_TO_PERSON.to_h) + else + options.insert(2, Constants.TASK_ACTIONS.REASSIGN_CORR_TASK_TO_PERSON.to_h) + end + + options + end end diff --git a/app/models/tasks/correspondence/reassign_package_task.rb b/app/models/tasks/correspondence/reassign_package_task.rb new file mode 100644 index 00000000000..d4d582727fb --- /dev/null +++ b/app/models/tasks/correspondence/reassign_package_task.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class ReassignPackageTask < CorrespondenceTask + before_create :verify_no_other_open_package_action_task_on_correspondence + + # :reek:UtilityFunction + def task_url + Constants.CORRESPONDENCE_TASK_URL.REASSIGN_PACKAGE_TASK_MODAL_URL + end + + def approve(current_user, new_assignee) + update!( + completed_by: current_user, + assigned_to_id: current_user, + assigned_to: current_user, + closed_at: Time.zone.now, + status: Constants.TASK_STATUSES.completed + ) + parent.update!( + status: Constants.TASK_STATUSES.completed, + closed_at: Time.zone.now, + completed_by: current_user + ) + ReviewPackageTask.create!( + assigned_to: new_assignee, + status: Constants.TASK_STATUSES.assigned, + appeal_id: appeal_id, + appeal_type: Correspondence.name + ) + end + + def reject(current_user, reason) + update!( + completed_by_id: current_user.id, + closed_at: Time.zone.now, + status: Constants.TASK_STATUSES.completed, + instructions: instructions.push(reason) + ) + parent.update!( + assigned_to_type: "User", + assigned_to: assigned_by, + status: Constants.TASK_STATUSES.in_progress + ) + end +end diff --git a/app/models/tasks/correspondence_intake_task.rb b/app/models/tasks/correspondence_intake_task.rb new file mode 100644 index 00000000000..43d471eeaa5 --- /dev/null +++ b/app/models/tasks/correspondence_intake_task.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CorrespondenceIntakeTask < CorrespondenceTask + class << self + def create_from_params(parent_task, user) + params = { + instructions: [], + assigned_to: user, + appeal_id: parent_task.appeal_id, + appeal_type: Correspondence.name, + status: Constants.TASK_STATUSES.in_progress, + type: name + } + # verify the user can create correspondences + verify_correspondence_access(user) + fail Caseflow::Error::ChildTaskAssignedToSameUser if parent_of_same_type_has_same_assignee(parent_task, params) + + current_params = modify_params_for_create(params) + CorrespondenceIntakeTask.create!( + appeal_type: Correspondence.name, + appeal_id: current_params[:appeal_id], + assigned_by_id: child_assigned_by_id(parent_task, user), + parent_id: parent_task.id, + assigned_to: current_params[:assigned_to], + instructions: current_params[:instructions], + status: current_params[:status] + ) + end + end + + def task_url + closed? ? "/under_construction" : Constants.CORRESPONDENCE_TASK_URL.INTAKE_TASK_URL.sub("uuid", correspondence.uuid) + end +end diff --git a/app/models/tasks/correspondence_root_task.rb b/app/models/tasks/correspondence_root_task.rb new file mode 100644 index 00000000000..8c0859a688f --- /dev/null +++ b/app/models/tasks/correspondence_root_task.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class CorrespondenceRootTask < CorrespondenceTask + def correspondence_status + status = if action_required? + Constants.CORRESPONDENCE_STATUSES.action_required + elsif unassigned? + Constants.CORRESPONDENCE_STATUSES.unassigned + elsif assigned? + Constants.CORRESPONDENCE_STATUSES.assigned + elsif pending? + Constants.CORRESPONDENCE_STATUSES.pending + elsif completed? + Constants.CORRESPONDENCE_STATUSES.completed + else + "" + end + status + end + + def review_package_task + children.open.find_by(type: ReviewPackageTask.name) + end + + def open_intake_task + children.open.find_by(type: CorrespondenceIntakeTask.name) + end + + def open_package_action_task + CorrespondenceTask.action_required_tasks.find_by(appeal_id: appeal_id, appeal_type: appeal_type) + end + + def tasks_not_related_to_an_appeal + CorrespondenceTask.tasks_not_related_to_an_appeal.open.where(appeal_id: appeal_id, appeal_type: appeal_type) + end + + def closed_tasks_not_related_to_an_appeal + CorrespondenceTask.tasks_not_related_to_an_appeal.closed.where(appeal_id: appeal_id, appeal_type: appeal_type) + end + + # correspondence_mail_tasks are completed upon creation, so no open check + def correspondence_mail_tasks + CorrespondenceTask.correspondence_mail_tasks.where(appeal_id: appeal_id, appeal_type: appeal_type) + end + + # a correspondence root task is considered closed if it has a closed at + # date OR all children tasks are completed. + def completed_by_date + return closed_at unless closed_at.nil? + + if children&.all?(&:completed?) + children.maximum(:closed_at) + end + end + + private + + # logic for handling correspondence statuses + # unassigned if review package task is unassigned + def unassigned? + review_package_task&.status == Constants.TASK_STATUSES.unassigned + end + + # assigned if open (assigned or on hold status) review package task or intake task + def assigned? + !unassigned? && (!review_package_task.blank? || !open_intake_task.blank?) + end + + # action required if the correspondence has a package action task with a status of 'assigned' + def action_required? + !open_package_action_task.blank? + end + + def pending? + !tasks_not_related_to_an_appeal.blank? + end + + # completed if root task is closed or no open children tasks + def completed? + status == Constants.TASK_STATUSES.completed || children.open.blank? + end +end diff --git a/app/models/tasks/correspondence_task.rb b/app/models/tasks/correspondence_task.rb new file mode 100644 index 00000000000..861cc5b9891 --- /dev/null +++ b/app/models/tasks/correspondence_task.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +class CorrespondenceTask < Task + belongs_to :correspondence, foreign_type: "Correspondence", foreign_key: "appeal_id" + self.abstract_class = true + + before_create :verify_org_task_unique + belongs_to :appeal, class_name: "Correspondence" + validate :status_is_valid_on_create, on: :create + validate :assignee_status_is_valid_on_create, on: :create + + scope :package_action_tasks, -> { where(type: package_action_task_names) } + scope :tasks_not_related_to_an_appeal, -> { where(type: tasks_not_related_to_an_appeal_names) } + scope :correspondence_mail_tasks, -> { where(type: correspondence_mail_task_names) } + scope :efolder_parent_tasks, -> { where(id: where(type: EfolderUploadFailedTask.name).active.pluck(:parent_id)) } + + # scopes to handle task queue logic + # Correspondence Cases queries + scope :unassigned_tasks, -> { where(type: ReviewPackageTask.name, status: Constants.TASK_STATUSES.unassigned) } + # due to 'on_hold' tasks also getting action_required_tasks, join efolder parent task + scope :assigned_tasks, lambda { + where(type: active_task_names).active.or(efolder_parent_tasks) + } + scope :action_required_tasks, -> { where(assigned_to: InboundOpsTeam.singleton).package_action_tasks.active } + scope :pending_tasks, -> { tasks_not_related_to_an_appeal.open } + # a correspondence is completed if the root task is completed or there are no active child tasks + # since active child tasks set the root task status to 'on_hold', the assumption is if a root task isn't on hold or + # cancelled, the correspondence is completed. + # This assumption is used to lower the N+1 query checking all the child task statuses. + scope :completed_root_tasks, lambda { + where(type: CorrespondenceRootTask.name).where.not( + status: Constants.TASK_STATUSES.on_hold + ).where.not(status: Constants.TASK_STATUSES.cancelled) + } + + # Your Correspondence queries + scope :user_assigned_tasks, lambda { |assignee| + where(type: active_task_names).open.where("assigned_to_id=?", assignee&.id) + } + + scope :user_in_progress_tasks, lambda { |assignee| + where("assigned_to_id=?", assignee&.id) + .where.not(type: EfolderUploadFailedTask.name) + .where(status: [Constants.TASK_STATUSES.in_progress, Constants.TASK_STATUSES.on_hold]) + } + + delegate :nod, to: :correspondence + + class << self + def create_from_params(params, user) + # verify the user can create correspondence tasks + verify_correspondence_access(user) + + parent_task = Task.find(params[:parent_id]) + fail Caseflow::Error::ChildTaskAssignedToSameUser if parent_of_same_type_has_same_assignee(parent_task, params) + + verify_user_can_create!(user, parent_task) + + params = modify_params_for_create(params) + child = create_child_task(parent_task, user, params) + parent_task.update!(status: params[:status]) if params[:status] + child + end + + private + + # block users from creating correspondence tasks if they are not members of Inbound Ops Team + # ignore check if there is no current user on correspondence creation + def verify_correspondence_access(user) + fail Caseflow::Error::ActionForbiddenError, message: "User does not belong to Inbound Ops Team" unless + InboundOpsTeam.singleton.user_has_access?(user) || user&.system_user? + end + end + + def self.active_task_names + [ + CorrespondenceIntakeTask.name, + ReviewPackageTask.name + ].freeze + end + + def self.package_action_task_names + [ + ReassignPackageTask.name, + RemovePackageTask.name, + SplitPackageTask.name, + MergePackageTask.name + ].freeze + end + + def self.tasks_not_related_to_an_appeal_names + [ + CavcCorrespondenceCorrespondenceTask.name, + CongressionalInterestCorrespondenceTask.name, + DeathCertificateCorrespondenceTask.name, + FoiaRequestCorrespondenceTask.name, + OtherMotionCorrespondenceTask.name, + PowerOfAttorneyRelatedCorrespondenceTask.name, + PrivacyActRequestCorrespondenceTask.name, + PrivacyComplaintCorrespondenceTask.name, + StatusInquiryCorrespondenceTask.name + ].freeze + end + + def self.correspondence_mail_task_names + [ + AssociatedWithClaimsFolderMailTask.name, + AddressChangeCorrespondenceMailTask.name, + EvidenceOrArgumentCorrespondenceMailTask.name, + VacolsUpdatedMailTask.name + ].freeze + end + + def verify_org_task_unique + if Task.where( + appeal_id: appeal_id, + appeal_type: appeal_type, + type: type + ).open.any? + fail( + Caseflow::Error::DuplicateOrgTask, + task_type: self.class.name, + assignee_type: assigned_to.class.name + ) + end + end + + def verify_no_other_open_package_action_task_on_correspondence + return true unless package_action_task? + + if CorrespondenceTask.package_action_tasks.open.where(appeal_id: appeal_id).any? + fail Caseflow::Error::MultipleOpenTasksOfSameTypeError, task_type: "package action task" + end + end + + def remove_package + root_task = CorrespondenceRootTask.find_by!( + appeal_id: @correspondence.id, + assigned_to: InboundOpsTeam.singleton, + appeal_type: "Correspondence", + type: "CorrespondenceRootTask" + ) + root_task.cancel_task_and_child_subtasks + end + + def self.create_child_task(parent_task, current_user, params) + Task.create!( + type: params[:type], + appeal_type: "Correspondence", + appeal: parent_task.appeal, + assigned_by_id: child_assigned_by_id(parent_task, current_user), + parent_id: parent_task.id, + assigned_to: params[:assigned_to] || child_task_assignee(parent_task, params), + instructions: params[:instructions] + ) + end + + def correspondence + Correspondence.find(appeal_id) + end + + def completed_by_date + closed_at + end + + def task_url + # route to the Correspondence Details Page. + if !FeatureToggle.enabled?(:correspondence_queue) + "/under_construction" + else + Constants.CORRESPONDENCE_TASK_URL.CORRESPONDENCE_TASK_DETAIL_URL.sub("uuid", correspondence.uuid) + end + end + + private + + # rubocop:disable Metrics/CyclomaticComplexity + def status_is_valid_on_create + case type + when "ReviewPackageTask" + return Constants.TASK_STATUSES.on_hold if status != Constants.TASK_STATUSES.on_hold + when "CorrespondenceIntakeTask", "EfolderUploadFailedTask" + return Constants.TASK_STATUSES.in_progress if status != Constants.TASK_STATUSES.in_progress + when "CorrespondenceRootTask", "HearingPostponementRequestMailTask" + return Constants.TASK_STATUSES.completed if status != Constants.TASK_STATUSES.completed + else + fail Caseflow::Error::InvalidStatusOnTaskCreate, task_type: type unless status == Constants.TASK_STATUSES.assigned + end + true + end + # rubocop:enable Metrics/CyclomaticComplexity + + def assignee_status_is_valid_on_create + if parent&.child_must_have_active_assignee? && assigned_to.is_a?(User) && !assigned_to.active? + fail Caseflow::Error::InvalidAssigneeStatusOnTaskCreate, assignee: assigned_to + end + + true + end + + def package_action_task? + self.class.package_action_task_names.include?(self.class.name) + end +end diff --git a/app/models/tasks/correspondence_task_filter.rb b/app/models/tasks/correspondence_task_filter.rb new file mode 100644 index 00000000000..d99c4c2cd13 --- /dev/null +++ b/app/models/tasks/correspondence_task_filter.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class CorrespondenceTaskFilter < TaskFilter + def filtered_tasks + result = tasks.all + + filter_params.each do |param| + case param + when /col=vaDor/ + value_hash = Rack::Utils.parse_nested_query(param).deep_symbolize_keys + result = result.merge(filter_by_va_dor(value_hash[:val])) + when /col=packageDocTypeColumn/ + value_hash = Rack::Utils.parse_nested_query(param).deep_symbolize_keys + result = result.merge(filter_by_nod(value_hash[:val])) + when /col=taskColumn/ + task_column_params = param.sub("col=taskColumn&val=", "").split("|") + result = result.merge(filter_by_task(task_column_params)) + when /col=correspondenceCompletedDateColumn/ + value_hash = Rack::Utils.parse_nested_query(param).deep_symbolize_keys + result = result.merge(filter_by_date_completed(value_hash[:val])) + end + end + + result + end + + private + + def filter_by_nod(nod_value) + # If both NOD and Non-NOD checkboxes are selected in the filter dropdown, + # nod_value will be "true|false" or "false|true", + # so we return all the tasks if the string contains pipe character. + return tasks if nod_value.include?("|") + + tasks.joins(:appeal).where("correspondences.nod = ?", nod_value) + end + + def filter_by_date(date_info) + date_type, first_date, second_date = date_info.split(",") + + case date_type + when "0" + tasks.where("closed_at > ? AND closed_at < ?", Time.zone.parse(first_date), Time.zone.parse(second_date)) + when "1" + tasks.where("closed_at < ?", Time.zone.parse(first_date)) + when "2" + tasks.where("closed_at > ?", Time.zone.parse(first_date)) + when "3" + tasks.where("DATE(closed_at) = (?)", Time.zone.parse(first_date)) + end + end + + def filter_by_va_dor(date_info) + date_type, first_date, second_date = date_info.split(",") + + case date_type + when "0" + filter_between_dates(first_date, second_date) + when "1" + filter_before_date(first_date) + when "2" + filter_after_date(first_date) + when "3" + filter_on_date(first_date) + end + end + + def filter_by_date_completed(date_info) + date_type, first_date, second_date = date_info.split(",") + + case date_type + when "0" + date_completed_filter_between_dates(first_date, second_date) + when "1" + date_completed_filter_before_date(first_date) + when "2" + date_completed_filter_after_date(first_date) + when "3" + date_completed_filter_on_date(first_date) + end + end + + def filter_by_task(task_types) + # used to store the results of each task query + collection = nil + task_types.each do |task_type| + task_type_match = tasks.where(type: task_type) + collection = collection.nil? ? tasks.merge(task_type_match) : collection.or(tasks.merge(task_type_match)) + end + collection + end + + def filter_between_dates(start_date, end_date) + tasks.joins(:appeal) + .where("correspondences.va_date_of_receipt > ? AND correspondences.va_date_of_receipt < ?", + Time.zone.parse(start_date), + Time.zone.parse(end_date)) + end + + def filter_before_date(date) + tasks.joins(:appeal).where("correspondences.va_date_of_receipt < ?", Time.zone.parse(date)) + end + + def filter_after_date(date) + tasks.joins(:appeal).where("correspondences.va_date_of_receipt > ?", Time.zone.parse(date)) + end + + def filter_on_date(date) + tasks.joins(:appeal).where("DATE(correspondences.va_date_of_receipt) = (?)", Time.zone.parse(date)) + end + + def date_completed_filter_between_dates(start_date, end_date) + tasks.where("closed_at > ? AND closed_at < ?", + Time.zone.parse(start_date), + Time.zone.parse(end_date)) + end + + def date_completed_filter_before_date(date) + tasks.where("closed_at < ?", Time.zone.parse(date)) + end + + def date_completed_filter_after_date(date) + tasks.where("closed_at > ?", Time.zone.parse(date)) + end + + def date_completed_filter_on_date(date) + tasks.where("DATE(closed_at) = (?)", Time.zone.parse(date)) + end +end diff --git a/app/models/tasks/efolder_upload_failed_task.rb b/app/models/tasks/efolder_upload_failed_task.rb new file mode 100644 index 00000000000..251b69364b5 --- /dev/null +++ b/app/models/tasks/efolder_upload_failed_task.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class EfolderUploadFailedTask < CorrespondenceTask + def task_url + if parent.type == ReviewPackageTask.name + Constants.CORRESPONDENCE_TASK_URL.REVIEW_PACKAGE_TASK_URL.sub("uuid", correspondence.uuid) + elsif parent.type == CorrespondenceIntakeTask.name + Constants.CORRESPONDENCE_TASK_URL.INTAKE_TASK_URL.sub("uuid", correspondence.uuid) + end + end +end diff --git a/app/models/tasks/evidence_or_argument_correspondence_mail_task.rb b/app/models/tasks/evidence_or_argument_correspondence_mail_task.rb new file mode 100644 index 00000000000..9cceaf565b6 --- /dev/null +++ b/app/models/tasks/evidence_or_argument_correspondence_mail_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class EvidenceOrArgumentCorrespondenceMailTask < CorrespondenceMailTask + def self.label + COPY::EVIDENCE_OR_ARGUMENT_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/mail_task.rb b/app/models/tasks/mail_task.rb index 88ef64e573c..89f0bf1fe22 100644 --- a/app/models/tasks/mail_task.rb +++ b/app/models/tasks/mail_task.rb @@ -60,6 +60,12 @@ def parent_if_blocking_task(parent_task) parent_task end + def verify_user_can_create!(user, parent) + return true if InboundOpsTeam.singleton.user_has_access?(user) + + super(user, parent) + end + def create_from_params(params, user) parent_task = Task.find(params[:parent_id]) diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/cavc_correspondence_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/cavc_correspondence_correspondence_task.rb new file mode 100644 index 00000000000..92359ff3532 --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/cavc_correspondence_correspondence_task.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CavcCorrespondenceCorrespondenceTask < CorrespondenceMailTask + def label + COPY::FOIA_REQUEST_MAIL_TASK_LABEL + end + + def task_url + Constants.CORRESPONDENCE_TASK_URL.CORRESPONDENCE_TASK_DETAIL_URL.sub("uuid", correspondence.uuid) + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/congressional_interest_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/congressional_interest_correspondence_task.rb new file mode 100644 index 00000000000..19680da8a3f --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/congressional_interest_correspondence_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CongressionalInterestCorrespondenceTask < CorrespondenceMailTask + def label + COPY::CONGRESSIONAL_INTEREST_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/correspondence_mail_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/correspondence_mail_task.rb new file mode 100644 index 00000000000..2ba8521870a --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/correspondence_mail_task.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Abstract base class for "tasks not related to an appeal" added to a correspondence during Correspondence Intake. +class CorrespondenceMailTask < CorrespondenceTask + class << self + # allows inbound ops team users to create tasks in intake + def verify_user_can_create!(user, parent) + return true if InboundOpsTeam.singleton.user_has_access?(user) + + super(user, parent) + end + end + + self.abstract_class = true + + def self.create_child_task(parent, current_user, params) + Task.create!( + type: name, + appeal: parent.appeal, + appeal_type: Correspondence.name, + assigned_by_id: child_assigned_by_id(parent, current_user), + parent_id: parent.id, + assigned_to: params[:assigned_to] || child_task_assignee(parent, params), + instructions: params[:instructions] + ) + end + + # rubocop: disable Metrics/AbcSize + def self.available_actions(user) + return [] unless user + + options = [ + Constants.TASK_ACTIONS.CHANGE_CORR_TASK_TYPE.to_h, + Constants.TASK_ACTIONS.ASSIGN_CORR_TASK_TO_TEAM.to_h, + Constants.TASK_ACTIONS.MARK_TASK_COMPLETE.to_h, + Constants.TASK_ACTIONS.RETURN_TO_INBOUND_OPS.to_h, + Constants.TASK_ACTIONS.CANCEL_CORR_TASK.to_h, + Constants.TASK_ACTIONS.CANCEL_CORRESPONDENCE_TASK.to_h, + Constants.TASK_ACTIONS.COMPLETE_CORRESPONDENCE_TASK.to_h + ] + + if user.is_a? Organization + options.insert(2, Constants.TASK_ACTIONS.ASSIGN_CORR_TASK_TO_PERSON.to_h) + else + options.insert(2, Constants.TASK_ACTIONS.REASSIGN_CORR_TASK_TO_PERSON.to_h) + end + + options + end + # rubocop: enable Metrics/AbcSize +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/death_certificate_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/death_certificate_correspondence_task.rb new file mode 100644 index 00000000000..4ac65ba3292 --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/death_certificate_correspondence_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class DeathCertificateCorrespondenceTask < CorrespondenceMailTask + def label + COPY::DEATH_CERTIFICATE_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/foia_request_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/foia_request_correspondence_task.rb new file mode 100644 index 00000000000..cac5199f8d5 --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/foia_request_correspondence_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class FoiaRequestCorrespondenceTask < CorrespondenceMailTask + def label + COPY::FOIA_REQUEST_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/other_motion_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/other_motion_correspondence_task.rb new file mode 100644 index 00000000000..f5f19bed7dd --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/other_motion_correspondence_task.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class OtherMotionCorrespondenceTask < CorrespondenceMailTask + def label + COPY::OTHER_MOTION_MAIL_TASK_LABEL + end + + # if you have a UNIQUE action for the specific task, put it here. + def available_actions(user) + return [] unless user + + options = [ + Constants.TASK_ACTIONS.CANCEL_CORRESPONDENCE_TASK.to_h, + Constants.TASK_ACTIONS.COMPLETE_CORRESPONDENCE_TASK.to_h, + Constants.TASK_ACTIONS.CHANGE_TASK_TYPE.to_h + ] + + options + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/power_of_attorney_related_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/power_of_attorney_related_correspondence_task.rb new file mode 100644 index 00000000000..c1710b9ae47 --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/power_of_attorney_related_correspondence_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PowerOfAttorneyRelatedCorrespondenceTask < CorrespondenceMailTask + def label + COPY::POWER_OF_ATTORNEY_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/privacy_act_request_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/privacy_act_request_correspondence_task.rb new file mode 100644 index 00000000000..0472f93a1d0 --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/privacy_act_request_correspondence_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PrivacyActRequestCorrespondenceTask < CorrespondenceMailTask + def label + COPY::PRIVACY_ACT_REQUEST_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/privacy_complaint_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/privacy_complaint_correspondence_task.rb new file mode 100644 index 00000000000..7c828442392 --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/privacy_complaint_correspondence_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PrivacyComplaintCorrespondenceTask < CorrespondenceMailTask + def label + COPY::PRIVACY_COMPLAINT_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/mail_tasks_not_related_to_appeal/status_inquiry_correspondence_task.rb b/app/models/tasks/mail_tasks_not_related_to_appeal/status_inquiry_correspondence_task.rb new file mode 100644 index 00000000000..b8a6f905783 --- /dev/null +++ b/app/models/tasks/mail_tasks_not_related_to_appeal/status_inquiry_correspondence_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class StatusInquiryCorrespondenceTask < CorrespondenceMailTask + def label + COPY::STATUS_INQUIRY_MAIL_TASK_LABEL + end +end diff --git a/app/models/tasks/merge_package_task.rb b/app/models/tasks/merge_package_task.rb new file mode 100644 index 00000000000..ce74c538bba --- /dev/null +++ b/app/models/tasks/merge_package_task.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MergePackageTask < CorrespondenceTask + before_create :verify_no_other_open_package_action_task_on_correspondence + + def task_url + "/under_construction" + end +end diff --git a/app/models/tasks/remove_package_task.rb b/app/models/tasks/remove_package_task.rb new file mode 100644 index 00000000000..f8845748e59 --- /dev/null +++ b/app/models/tasks/remove_package_task.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class RemovePackageTask < CorrespondenceTask + before_create :verify_no_other_open_package_action_task_on_correspondence + + # :reek:UtilityFunction + def task_url + Constants.CORRESPONDENCE_TASK_URL.REMOVE_PACKAGE_TASK_MODAL_URL + end + + def approve(user) + update!( + completed_by_id: user.id, + status: Constants.TASK_STATUSES.cancelled + ) + end + + def reject(user, reason) + update!( + completed_by_id: user.id, + closed_at: Time.zone.now, + status: Constants.TASK_STATUSES.completed, + instructions: instructions.push(reason) + ) + parent.update!( + status: Constants.TASK_STATUSES.in_progress + ) + end +end diff --git a/app/models/tasks/review_package_task.rb b/app/models/tasks/review_package_task.rb new file mode 100644 index 00000000000..1baf932e453 --- /dev/null +++ b/app/models/tasks/review_package_task.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ReviewPackageTask < CorrespondenceTask + class << self + def create_from_params(params, user) + parent_task = ReviewPackageTask.find(params[:parent_id]) + # verify the user can create correspondence tasks + verify_correspondence_access(user) + fail Caseflow::Error::ChildTaskAssignedToSameUser if parent_of_same_type_has_same_assignee(parent_task, params) + + params = modify_params_for_create(params) + child = create_child_task(parent_task, user, params) + parent_task.update!(status: params[:status]) if params[:status] + child + end + end + + def when_child_task_created(_child_task) + true + end + + def task_url + if closed? + "/under_construction" + else + Constants.CORRESPONDENCE_TASK_URL.REVIEW_PACKAGE_TASK_URL.sub("uuid", correspondence.uuid) + end + end +end diff --git a/app/models/tasks/split_package_task.rb b/app/models/tasks/split_package_task.rb new file mode 100644 index 00000000000..f2487154ac7 --- /dev/null +++ b/app/models/tasks/split_package_task.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SplitPackageTask < CorrespondenceTask + before_create :verify_no_other_open_package_action_task_on_correspondence + + def task_url + "/under_construction" + end +end diff --git a/app/models/tasks/vacols_updated_mail_task.rb b/app/models/tasks/vacols_updated_mail_task.rb new file mode 100644 index 00000000000..cf0c6672d5f --- /dev/null +++ b/app/models/tasks/vacols_updated_mail_task.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class VacolsUpdatedMailTask < CorrespondenceMailTask + def self.label + COPY::VACOLS_UPDATED_MAIL_TASK_LABEL + end +end diff --git a/app/models/user.rb b/app/models/user.rb index c2e562a9b55..484d768d9ca 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# :reek:RepeatedConditional class User < CaseflowRecord # rubocop:disable Metrics/ClassLength include BgsService @@ -11,6 +12,7 @@ class User < CaseflowRecord # rubocop:disable Metrics/ClassLength has_many :annotations has_many :tasks, as: :assigned_to has_many :organizations_users, dependent: :destroy + has_many :organization_user_permissions, through: :organizations_users, dependent: :destroy has_many :organizations, through: :organizations_users has_many :membership_requests, foreign_key: :requestor_id has_many :decided_membership_requests, class_name: "MembershipRequest", foreign_key: :decider_id @@ -21,6 +23,7 @@ class User < CaseflowRecord # rubocop:disable Metrics/ClassLength # Alternative: where("roles @> ARRAY[?]::varchar[]", role) scope :with_role, ->(role) { where("? = ANY(roles)", role) } + scope :inbound_ops_team_users, -> { joins(:organizations).where(organizations: { type: InboundOpsTeam.name }) } BOARD_STATION_ID = "101" LAST_LOGIN_PRECISION = 5.minutes @@ -95,6 +98,63 @@ def hearings_user? can_any_of_these_roles?(["Build HearSched", "Edit HearSched", "RO ViewHearSched", "VSO", "Hearing Prep"]) end + def inbound_ops_team_superuser? + return false unless correspondence_queue_enabled? + + member_of_organization?(InboundOpsTeam.singleton) && + OrganizationUserPermissionChecker.new.can?( + permission_name: Constants.ORGANIZATION_PERMISSIONS.superuser, + organization: InboundOpsTeam.singleton, + user: self + ) + end + + # check for user that is not an admin of the inbound ops team + def inbound_ops_team_user? + return false unless correspondence_queue_enabled? + + organizations.include?(InboundOpsTeam.singleton) && + !inbound_ops_team_supervisor? + end + + def inbound_ops_team_supervisor? + return false unless correspondence_queue_enabled? + + administered_teams.include?(InboundOpsTeam.singleton) + end + + # :reek:UtilityFunction + def organization_permissions(org) + # get organization user from the org relationship + org_user = OrganizationsUser.find_by(organization_id: org.id) + # get user permission using the org_user + + # use org_user > org_user_permission > org_permission to get + # organization permissions assigned to the user. + OrganizationUserPermission.where(organizations_user: org_user) + .includes(:organization_permission, :organizations_user) + .where(organizations_user_id: org_user.id, permitted: true) + .pluck(:permission, :description).map do |permission, description| + { + permission: permission, + desciption: description + } + end + end + + def organization_admin_permissions(org) + return [] unless org.user_is_admin?(self) + + # if admin, directly grab admin permissions from the org_permission table + OrganizationPermission.where(organization: org, default_for_admin: true) + .pluck(:permission, :description).map do |permission, description| + { + permission: permission, + desciption: description + } + end + end + def can_assign_hearing_schedule? can_any_of_these_roles?(["Edit HearSched", "Build HearSched"]) end @@ -399,6 +459,9 @@ def selectable_organizations } end + # handle correspondence queue tables + handle_correspondence_queues(orgs) if member_of_organization?(InboundOpsTeam.singleton) + orgs end # rubocop:enable Metrics/MethodLength @@ -444,6 +507,14 @@ def queue_tabs end end + def correspondence_queue_tabs + [ + correspondence_assigned_tasks_tab, + correspondence_in_progress_tasks_tab, + correspondence_completed_tasks_tab + ] + end + def self.default_active_tab Constants.QUEUE_CONFIG.INDIVIDUALLY_ASSIGNED_TASKS_TAB_NAME end @@ -460,6 +531,18 @@ def completed_tasks_tab ::CompletedTasksTab.new(assignee: self, show_regional_office_column: show_regional_office_in_queue?) end + def correspondence_assigned_tasks_tab + ::CorrespondenceAssignedTasksTab.new(assignee: self) + end + + def correspondence_in_progress_tasks_tab + ::CorrespondenceInProgressTasksTab.new(assignee: self) + end + + def correspondence_completed_tasks_tab + ::CorrespondenceCompletedTasksTab.new(assignee: self) + end + def can_edit_unrecognized_poa? allowed_orgs = [LitigationSupport, ClerkOfTheBoard, BoardProductOwners, BvaIntake].map(&:singleton) colocated_in_vacols? || allowed_orgs.any? { |org| org.user_has_access?(self) } @@ -503,12 +586,37 @@ def show_reader_link_column? false end + def system_user? + self == User.system_user + end + private + def correspondence_queue_enabled? + FeatureToggle.enabled?(:correspondence_queue) + end + def inactive_judge_team JudgeTeam.unscoped.inactive.find_by(id: organizations_users.admin.pluck(:organization_id)) end + def handle_correspondence_queues(orgs) + if inbound_ops_team_superuser? || inbound_ops_team_user? + orgs << { + name: COPY::CASE_LIST_TABLE_QUEUE_DROPDOWN_OWN_CORRESPONDENCE_LABEL, + url: "/queue/correspondence" + } + end + + if inbound_ops_team_superuser? || inbound_ops_team_supervisor? + orgs << { + name: COPY::CASE_LIST_TABLE_QUEUE_DROPDOWN_CORRESPONDENCE_CASES, + url: "/queue/correspondence/team" + } + end + orgs + end + def user_reactivation # We do not automatically re-add organization membership for reactivated users inactive_judge_team&.active! diff --git a/app/models/vbms_document_type.rb b/app/models/vbms_document_type.rb new file mode 100644 index 00000000000..d2326567286 --- /dev/null +++ b/app/models/vbms_document_type.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class VbmsDocumentType < ApplicationRecord + has_many :correspondence_documents +end diff --git a/app/models/veteran.rb b/app/models/veteran.rb index a23c83dfb5b..b7bb1bda08b 100644 --- a/app/models/veteran.rb +++ b/app/models/veteran.rb @@ -8,6 +8,7 @@ class Veteran < CaseflowRecord include AssociatedBgsRecord + has_many :correspondences has_many :available_hearing_locations, foreign_key: :veteran_file_number, primary_key: :file_number, class_name: "AvailableHearingLocations" diff --git a/app/queries/appeals_updated_since_query.rb b/app/queries/appeals_updated_since_query.rb index 66dc9f410d5..cfa2e902542 100644 --- a/app/queries/appeals_updated_since_query.rb +++ b/app/queries/appeals_updated_since_query.rb @@ -28,6 +28,8 @@ def call request_decision_issues request_issues_updates vbms_uploaded_documents + correspondence_appeals + correspondences issue_modification_requests ].freeze diff --git a/app/repositories/task_action_repository.rb b/app/repositories/task_action_repository.rb index e5171aaf9f0..03d17665842 100644 --- a/app/repositories/task_action_repository.rb +++ b/app/repositories/task_action_repository.rb @@ -2,6 +2,34 @@ class TaskActionRepository # rubocop:disable Metrics/ClassLength class << self + def change_corr_task_type + # stubbed + end + + def assign_corr_task_to_team + # stubbed + end + + def assign_corr_task_to_person + # stubbed + end + + def reassign_corr_task_to_person + # stubbed + end + + def mark_corr_task_complete + # stubbed + end + + def return_to_inbound_ops + # stubbed + end + + def cancel_corr_task + # stubbed + end + def assign_to_organization_data(task, _user = nil) organizations = Organization.assignable(task).map do |organization| { @@ -52,6 +80,19 @@ def cancel_task_data(task, _user = nil) } end + # this is used to build the modal and handle redirect after modal is closed + def cancel_correspondence_task_data(task, _user = nil) + return_to_name = task_assigner_name(task) + + { + modal_title: COPY::CANCEL_TASK_MODAL_TITLE, + modal_body: format_cancel_body(task, COPY::CANCEL_TASK_MODAL_DETAIL, return_to_name), + message_title: format(COPY::CANCEL_TASK_CONFIRMATION, task.correspondence&.veteran_full_name), + message_detail: format(COPY::MARK_TASK_COMPLETE_CONFIRMATION_DETAIL, return_to_name), + redirect_after: "/queue/correspondence/:correspondence_uuid/" + } + end + def cancel_initial_letter_task_data(task, _user = nil) return_to_name = task.is_a?(AttorneyTask) ? task.parent.assigned_to.full_name : task_assigner_name(task) { @@ -376,6 +417,18 @@ def complete_data(task, _user = nil) params end + def complete_correspondence_task_data(task, _user = nil) + return_to_name = task_assigner_name(task) + + { + modal_title: COPY::MARK_TASK_COMPLETE_TITLE, + modal_body: COPY::CORRESPONDENCE_OTHER_MOTION_MODAL_DETAIL, + message_title: format(COPY::CORRESPONDENCE_COMPLETE_TASK_CONFIRMATION, task.correspondence&.veteran_full_name), + message_detail: format(COPY::CORRESPONDENCE_COMPLETE_TASK_CONFIRMATION_DETAIL, return_to_name), + redirect_after: "/queue/correspondence/:correspondence_uuid/" + } + end + def proceed_final_notification_letter_data(task, _user = nil) params = { modal_body: COMPLETE_TASK_MODAL_BODY_HASH[task.type.to_sym], diff --git a/app/serializers/attorney_serializer.rb b/app/serializers/attorney_serializer.rb index fea9e3dcd3e..d54abc8cd22 100644 --- a/app/serializers/attorney_serializer.rb +++ b/app/serializers/attorney_serializer.rb @@ -6,6 +6,6 @@ class AttorneySerializer attribute :css_id attribute :full_name attribute :active_task_count do |object| - object.tasks.active.size + QueueRepository.tasks_for_user(object.css_id).count + object.tasks.not_correspondence.active.size + QueueRepository.tasks_for_user(object.css_id).count end end diff --git a/app/services/appeal_active_task_cancellation.rb b/app/services/appeal_active_task_cancellation.rb index 7d9c46defc1..0a306e939d3 100644 --- a/app/services/appeal_active_task_cancellation.rb +++ b/app/services/appeal_active_task_cancellation.rb @@ -29,7 +29,7 @@ def cancel_active_tasks_except_those_needed_for_distribution end def assign_distribution_task - tasks.find_by(type: "DistributionTask").update!(status: "assigned") + tasks.find_by(type: "DistributionTask").update!(status: Constants.TASK_STATUSES.assigned) end def all_tasks_except_those_needed_for_distribution diff --git a/app/services/auto_assignable_user_finder.rb b/app/services/auto_assignable_user_finder.rb new file mode 100644 index 00000000000..a878b7b3012 --- /dev/null +++ b/app/services/auto_assignable_user_finder.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +# For correspondence auto assignment, determines whether any assignable users exist as well as returns the first +# assignable user for a given correspondence + +# :reek:FeatureEnvy +class AutoAssignableUserFinder + AssignableUser = Struct.new(:user_obj, :last_assigned_date, :num_assigned, :nod?, keyword_init: true) + + attr_reader :unassignable_reasons + + def initialize(current_user) + self.current_user = current_user + self.unassignable_reasons = [] + end + + def assignable_users_exist? + return false if FeatureToggle.enabled?(:auto_assign_banner_max_queue) + + assignable_users.count.positive? + end + + def get_first_assignable_user(correspondence:) + run_auto_assign_algorithm(correspondence, assignable_users) + end + + def can_user_work_this_correspondence?(user:, correspondence:) + return false if user_is_at_max_capacity?(user) + + assignable = user_to_assignable_user(user) + + run_auto_assign_algorithm(correspondence, [assignable]).present? + end + + private + + attr_accessor :current_user + attr_writer :unassignable_reasons + + def run_auto_assign_algorithm(correspondence, users) + nod_error = Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.NOD_ERROR + sensitivity_error = Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.SENSITIVITY_ERROR + users.each do |user| + if correspondence.nod && !user.nod? + unassignable_reasons << nod_error + next + end + + user_obj = user.user_obj + + if sensitivity_levels_compatible?(user: user_obj, veteran: correspondence.veteran) + return user_obj + else + unassignable_reasons << sensitivity_error + end + end + + nil + end + + def user_is_at_max_capacity?(user) + capacity_error = Constants.CORRESPONDENCE_AUTO_ASSIGN_ERROR.CAPACITY_ERROR + if num_assigned_user_tasks(user) >= CorrespondenceAutoAssignmentLever.max_capacity + unassignable_reasons << capacity_error + true + else + false + end + end + + def assignable_users + users = [] + + find_users.each do |user| + next if user_is_at_max_capacity?(user) + + assignable = user_to_assignable_user(user) + + users.push(assignable) + end + + sorted_assignable_users(users) + end + + def user_to_assignable_user(user) + nod_eligible = permission_checker.can?( + permission_name: Constants.ORGANIZATION_PERMISSIONS.receive_nod_mail, + organization: InboundOpsTeam.singleton, + user: user + ) + + AssignableUser.new( + user_obj: user, + last_assigned_date: user_review_package_tasks(user).maximum(:assigned_at), + num_assigned: num_assigned_user_tasks(user), + nod?: nod_eligible + ) + end + + # :reek:UncommunicativeVariableName + def sorted_assignable_users(users) + users.sort do |a, b| + if a.num_assigned == b.num_assigned + a.last_assigned_date <=> b.last_assigned_date + else + a.num_assigned <=> b.num_assigned + end + end + end + + def num_assigned_user_tasks(user) + count = user_review_package_tasks(user).count + + super_users = InboundOpsTeam.super_users + if super_users.include?(user) + count += ((MergePackageTask.count + ReassignPackageTask.count + SplitPackageTask.count) / super_users.count).round + end + + count + end + + def user_review_package_tasks(user) + user.tasks.where( + type: ReviewPackageTask.name, + status: [ + Constants.TASK_STATUSES.assigned, + Constants.TASK_STATUSES.in_progress, + Constants.TASK_STATUSES.on_hold + ] + ) + end + + def find_users + # Do NOT use manual caching here!!! + # Other processes may update data, so always use the DB as the source of truth and let Rails handle any caching + InboundOpsTeam.singleton.users.includes(:tasks, :organizations, :organization_user_permissions) + .where( + organization_user_permissions: { + organization_permission: OrganizationPermission.auto_assign(InboundOpsTeam.singleton), + permitted: true + } + ) + .references(:tasks, :organizations, :organization_user_permissions) + end + + def sensitivity_levels_compatible?(user:, veteran:) + begin + sensitivity_checker.sensitivity_level_for_user(user) >= + sensitivity_checker.sensitivity_level_for_veteran(veteran) + rescue StandardError => error + error_uuid = SecureRandom.uuid + Raven.capture_exception(error, extra: { error_uuid: error_uuid }) + + false + end + end + + def sensitivity_checker + return @sensitivity_checker if @sensitivity_checker.present? + + # Set for use by BGSService + RequestStore.store[:current_user] ||= current_user + + @sensitivity_checker = BGSService.new + end + + def permission_checker + @permission_checker ||= OrganizationUserPermissionChecker.new + end +end diff --git a/app/services/correspondence_auto_assign_logger.rb b/app/services/correspondence_auto_assign_logger.rb new file mode 100644 index 00000000000..a7be12b5d93 --- /dev/null +++ b/app/services/correspondence_auto_assign_logger.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# For correspondence auto assignment, creates records of assignment attempts. +# A log for the entire auto assignment run is stored to BatchAutoAssignmentAttempt. +# Logs for reach assignment attempt are stroed to IndividualAutoAssignmentAttempt. + +# :reek:FeatureEnvy +class CorrespondenceAutoAssignLogger + def initialize(current_user, batch) + self.current_user = current_user + self.batch = batch + end + + class << self + def fail_run_validation(batch_auto_assignment_attempt_id:, msg:) + failed_batch = BatchAutoAssignmentAttempt.find(batch_auto_assignment_attempt_id) + + return if failed_batch.blank? + + failed_batch.update!( + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.error, + error_info: { message: msg }, + errored_at: Time.current + ) + end + end + + def begin + batch.update!( + started_at: Time.current, + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.started, + num_nod_packages_assigned: 0, + num_nod_packages_unassigned: 0, + num_packages_assigned: 0, + num_packages_unassigned: 0 + ) + end + + def end + batch.assign_attributes( + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.completed, + completed_at: Time.current + ) + + save_run_statistics + end + + def error(msg:) + batch.assign_attributes( + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.error, + error_info: { message: msg }, + errored_at: Time.current + ) + + save_run_statistics + end + + def assigned(task:, started_at:, assigned_to:) + correspondence = task.correspondence + + attempt = individual_auto_assignment_attempt + attempt.assign_attributes( + correspondence: correspondence, + completed_at: Time.current, + nod: correspondence.nod, + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.completed, + started_at: started_at + ) + + if correspondence.nod + batch.num_nod_packages_assigned += 1 + else + batch.num_packages_assigned += 1 + end + + save_attempt_statistics( + attempt: attempt, + task: task, + result: "Correspondence #{correspondence.id} assigned to User ID #{assigned_to.id}" + ) + end + + def no_eligible_assignees(task:, started_at:, unassignable_reason:) + correspondence = task.correspondence + + attempt = individual_auto_assignment_attempt + attempt.assign_attributes( + correspondence: correspondence, + errored_at: Time.current, + nod: correspondence.nod, + status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.error, + started_at: started_at + ) + + if correspondence.nod + batch.num_nod_packages_unassigned += 1 + else + batch.num_packages_unassigned += 1 + end + + save_attempt_statistics( + attempt: attempt, + task: task, + result: "No eligible assignees: #{unassignable_reason}" + ) + end + + private + + attr_accessor :batch, :current_user + + def individual_auto_assignment_attempt + IndividualAutoAssignmentAttempt.new( + batch_auto_assignment_attempt: batch, + user: current_user + ) + end + + def save_run_statistics + stats = { + seconds_elapsed: seconds_elapsed(record: batch, status: batch.status) + } + + batch.statistics = stats + batch.save! + end + + def save_attempt_statistics(attempt:, task:, result:) + stats = { + result: result, + seconds_elapsed: seconds_elapsed(record: attempt, status: attempt.status), + review_package_task_id: task.id + } + + attempt.statistics = stats + attempt.save! + end + + # :reek:ControlParameter + def seconds_elapsed(record:, status:) + if status == Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.completed + record.completed_at - record.started_at + else + record.errored_at - record.started_at + end + end +end diff --git a/app/services/correspondence_auto_assign_run_verifier.rb b/app/services/correspondence_auto_assign_run_verifier.rb new file mode 100644 index 00000000000..27d94a9426d --- /dev/null +++ b/app/services/correspondence_auto_assign_run_verifier.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# For correspondence auto assignment, determines whether or not an auto assign job can be run. +# In order to prevent race conditions and inconsistent data, only one correspondence auto assignment can be run +# at a time. + +class CorrespondenceAutoAssignRunVerifier + attr_accessor :verified_batch, :verified_user, :err_msg + + def can_run_auto_assign?(current_user_id:, batch_auto_assignment_attempt_id:) + verify_feature_toggles && + verify_id_params(current_user_id, batch_auto_assignment_attempt_id) && + verify_no_other_jobs_running + end + + private + + def verify_feature_toggles + if FeatureToggle.enabled?(:auto_assign_banner_failure) + self.err_msg = "#{COPY::BAAA_ERROR_MESSAGE} (failure triggered via feature toggle)" + return false + end + + true + end + + def verify_id_params(current_user_id, batch_auto_assignment_attempt_id) + self.verified_user = User.find_by(id: current_user_id) + if verified_user.blank? + self.err_msg = "User does not exist" + return false + end + + self.verified_batch = BatchAutoAssignmentAttempt.find_by(user: verified_user, id: batch_auto_assignment_attempt_id) + if verified_batch.blank? + self.err_msg = "BatchAutoAssignmentAttempt does not exist" + return false + end + + true + end + + def verify_no_other_jobs_running + if assignment_already_running? + self.err_msg = "Auto assignment already in progress" + return false + end + + true + end + + def assignment_already_running? + last_assignment = IndividualAutoAssignmentAttempt.last + + min_minutes_elapsed_individual_attempt = + Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.timing.min_minutes_elapsed_individual_attempt + + # Safe to move forward if we haven't seen any assignment attempts for the past X minutes + return true if last_assignment&.created_at.present? && + ((Time.current - last_assignment.created_at) / 60) < min_minutes_elapsed_individual_attempt + + min_minutes_elapsed_batch_attempt = + Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.timing.min_minutes_elapsed_batch_attempt + + # Safe to move forward if the last batch was started more than Y minutes ago + BatchAutoAssignmentAttempt + .where.not(id: verified_batch.id) + .where(status: Constants.CORRESPONDENCE_AUTO_ASSIGNMENT.statuses.started) + .exists?(["created_at >= ?", min_minutes_elapsed_batch_attempt.minutes.ago]) + end +end diff --git a/app/services/correspondence_auto_assigner.rb b/app/services/correspondence_auto_assigner.rb new file mode 100644 index 00000000000..ff9f292aca3 --- /dev/null +++ b/app/services/correspondence_auto_assigner.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# Assigns Correspondence ReviewPackageTasks to eligible InboundOpsTeam users. +# (Assigning the Correspondence's ReviewPackageTask is considered assigning the Correspondence.) + +class CorrespondenceAutoAssigner + # rubocop:disable Metrics/MethodLength + def perform(current_user_id:, batch_auto_assignment_attempt_id:) + # Don't catch these exceptions here so that if we're being called by a job + # the job will auto-retry with exponential back-off + validate_run!(current_user_id, batch_auto_assignment_attempt_id) + + logger.begin + + if !unassigned_review_package_tasks.count.positive? + logger.error(msg: COPY::BAAA_NO_UNASSIGNED_CORRESPONDENCE) + return + end + + if !assignable_user_finder.assignable_users_exist? + logger.error(msg: COPY::BAAA_USERS_MAX_QUEUE_REACHED) + return + end + + begin + unassigned_review_package_tasks.each do |task| + # validate a user is able to work the task + assignee = find_auto_assign_user(task) + + # guard clause if no assignee + break if assignee.blank? + + assign(task, assignee) + end + + logger.end + rescue StandardError => error + error_uuid = SecureRandom.uuid + Raven.capture_exception(error, extra: { error_uuid: error_uuid }) + logger.error(msg: "#{COPY::BAAA_ERROR_MESSAGE} (Error code: #{error_uuid})") + end + end + # rubocop:enable Metrics/MethodLength + + private + + attr_accessor :batch, :current_user + + def find_auto_assign_user(task) + started_at = Time.current + correspondence = task.correspondence + assignee = assignable_user_finder.get_first_assignable_user(correspondence: correspondence) + + # return last error message for the most recent auto assign error. + if assignee.blank? + logger.no_eligible_assignees( + task: task, + started_at: started_at, + unassignable_reason: assignable_user_finder.unassignable_reasons.last + ) + end + assignee + end + + def assign(task, assignee) + started_at = Time.current + assign_task_to_user(task, assignee) + logger.assigned(task: task, started_at: started_at, assigned_to: assignee) + end + + def assign_task_to_user(task, user) + task.update!( + assigned_to: user, + assigned_at: Time.current, + assigned_by: current_user, + assigned_to_type: User.name, + status: Constants.TASK_STATUSES.assigned + ) + end + + def unassigned_review_package_tasks + return [] if FeatureToggle.enabled?(:auto_assign_banner_no_rpt) + + ReviewPackageTask + .where(status: Constants.TASK_STATUSES.unassigned) + .preload(:appeal, :assigned_by, :assigned_to, :parent) + .includes(:correspondence, correspondence: :veteran) + .references(:correspondence, correspondence: :veteran) + .order(va_date_of_receipt: :asc) + end + + def validate_run!(current_user_id, batch_auto_assignment_attempt_id) + if run_verifier.can_run_auto_assign?( + current_user_id: current_user_id, + batch_auto_assignment_attempt_id: batch_auto_assignment_attempt_id + ) + self.batch = run_verifier.verified_batch + self.current_user = run_verifier.verified_user + else + CorrespondenceAutoAssignLogger.fail_run_validation( + batch_auto_assignment_attempt_id: batch_auto_assignment_attempt_id, + msg: run_verifier.err_msg + ) + fail run_verifier.err_msg + end + end + + def logger + @logger ||= CorrespondenceAutoAssignLogger.new(current_user, batch) + end + + def assignable_user_finder + @assignable_user_finder ||= AutoAssignableUserFinder.new(current_user) + end + + def run_verifier + @run_verifier ||= CorrespondenceAutoAssignRunVerifier.new + end +end diff --git a/app/services/correspondence_documents_efolder_uploader.rb b/app/services/correspondence_documents_efolder_uploader.rb new file mode 100644 index 00000000000..06fe411c267 --- /dev/null +++ b/app/services/correspondence_documents_efolder_uploader.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class CorrespondenceDocumentsEfolderUploader + def upload_documents_to_claim_evidence(correspondence, current_user, parent_task) + if Rails.env.production? + do_upload(correspondence) + + true + elsif FeatureToggle.enabled?(:ce_api_demo_toggle) + if Rails.env.test? + do_upload(correspondence) + end + + true + else + fail "Mock failure for upload in non-prod env" + end + rescue StandardError => error + Rails.logger.error(error.full_message) + create_efolder_upload_failed_task(correspondence, current_user, parent_task) + + false + end + + private + + def corresondence_veteran(correspondence) + Veteran.find_by(id: correspondence.veteran_id) + end + + def create_efolder_upload_failed_task(correspondence, current_user, parent_task) + return if EfolderUploadFailedTask.where(appeal_id: correspondence.id).count > 0 + + euft = EfolderUploadFailedTask.find_or_create_by( + appeal_id: correspondence.id, + appeal_type: Correspondence.name, + type: EfolderUploadFailedTask.name, + assigned_to: current_user, + parent_id: parent_task.id + ) + + euft.update!(status: Constants.TASK_STATUSES.in_progress) + end + + # :reek:FeatureEnvy + def do_upload(correspondence) + correspondence.correspondence_documents.each do |doc| + ExternalApi::ClaimEvidenceService.upload_document( + doc.pdf_location, + corresondence_veteran(correspondence).file_number, + doc.claim_evidence_upload_hash + ) + end + end +end diff --git a/app/services/correspondence_intake_processor.rb b/app/services/correspondence_intake_processor.rb new file mode 100644 index 00000000000..1b0ad6ec393 --- /dev/null +++ b/app/services/correspondence_intake_processor.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +# :reek:DataClump +# :reek:FeatureEnvy +class CorrespondenceIntakeProcessor + def process_intake(intake_params, current_user) + correspondence = Correspondence.find_by(uuid: intake_params[:correspondence_uuid]) + + fail "Correspondence not found" if correspondence.blank? + + parent_task = CorrespondenceIntakeTask.find_by(appeal_id: correspondence.id) + + return false if !correspondence_documents_efolder_uploader.upload_documents_to_claim_evidence( + correspondence, + current_user, + parent_task + ) + + do_upload_success_actions(parent_task, intake_params, correspondence, current_user) + end + + private + + # :reek:LongParameterList + def do_upload_success_actions(parent_task, intake_params, correspondence, current_user) + ActiveRecord::Base.transaction do + parent_task.update!(status: Constants.TASK_STATUSES.completed) + + create_correspondence_relations(intake_params, correspondence.id) + create_response_letter(intake_params, correspondence.id) + add_tasks_to_related_appeals(intake_params, current_user, correspondence.id) + complete_waived_evidence_submission_tasks(intake_params) + create_tasks_not_related_to_appeals(intake_params, correspondence, current_user) + create_mail_tasks(intake_params, correspondence, current_user) + end + + true + rescue StandardError => error + Rails.logger.error(error.full_message) + + false + end + + def create_correspondence_relations(intake_params, correspondence_id) + intake_params[:related_correspondence_uuids]&.map do |uuid| + CorrespondenceRelation.create!( + correspondence_id: correspondence_id, + related_correspondence_id: Correspondence.find_by(uuid: uuid)&.id + ) + end + end + + def create_response_letter(intake_params, correspondence_id) + current_user = RequestStore.store[:current_user] ||= User.system_user + + intake_params[:response_letters]&.map do |data| + current_value = nil + if data[:responseWindows] == "Custom" + current_value = data[:customValue] + end + + if data[:responseWindows] == "65 days" + current_value = 65 + end + + CorrespondenceResponseLetter.create!( + correspondence_id: correspondence_id, + date_sent: data[:date], + title: data[:title], + subcategory: data[:subType], + reason: data[:reason], + response_window: current_value, + letter_type: data[:type], + user_id: current_user.id + ) + end + end + + def link_appeals_to_correspondence(intake_params, correspondence_id) + intake_params[:related_appeal_ids]&.map do |appeal_id| + CorrespondenceAppeal.find_or_create_by(correspondence_id: correspondence_id, appeal_id: appeal_id) + end + end + + def create_appeal_related_tasks(data, current_user, correspondence_id) + appeal = Appeal.find(data[:appeal_id]) + # find the CorrespondenceAppeal created in link_appeals_to_correspondence + cor_appeal = CorrespondenceAppeal.find_by( + correspondence_id: correspondence_id, + appeal_id: appeal.id + ) + task = task_class_for_task_related(data).create_from_params( + { + appeal: appeal, + parent_id: appeal.root_task&.id, + assigned_to: class_for_assigned_to(data[:assigned_to]).singleton, + instructions: data[:content] + }, current_user + ) + # create join table to CorrespondenceAppealTask for tracking + CorrespondencesAppealsTask.find_or_create_by( + correspondence_appeal: cor_appeal, + task: task + ) + end + + def add_tasks_to_related_appeals(intake_params, current_user, correspondence_id) + link_appeals_to_correspondence(intake_params, correspondence_id) + intake_params[:tasks_related_to_appeal]&.map do |data| + create_appeal_related_tasks(data, current_user, correspondence_id) + end + end + + def complete_waived_evidence_submission_tasks(intake_params) + intake_params[:waived_evidence_submission_window_tasks]&.map do |task| + evidence_submission_window_task = EvidenceSubmissionWindowTask.find(task[:task_id]) + instructions = evidence_submission_window_task.instructions + evidence_submission_window_task.when_timer_ends + evidence_submission_window_task.update!(instructions: (instructions << task[:waive_reason])) + end + end + + def create_tasks_not_related_to_appeals(intake_params, correspondence, current_user) + unrelated_task_data = intake_params[:tasks_not_related_to_appeal] + + return if unrelated_task_data.blank? || !unrelated_task_data.length + + unrelated_task_data.map do |data| + task_class_for_task_unrelated(data).create_from_params( + { + parent_id: correspondence.root_task.id, + assigned_to: class_for_assigned_to(data[:assigned_to]).singleton, + instructions: data[:content] + }, current_user + ) + end + end + + def create_mail_tasks(intake_params, correspondence, current_user) + mail_task_data = intake_params[:mail_tasks] + + return if mail_task_data.blank? || !mail_task_data.length + + mail_task_data.map do |mail_task_type| + task = mail_task_class_for_type(mail_task_type).create_from_params( + { + parent_id: correspondence.root_task.id, + assigned_to_id: current_user.id, + assigned_to_type: User.name + }, current_user + ) + + task.update!(status: Constants.TASK_STATUSES.completed) + end + end + + def task_class_for_task_related(data) + task_type = data[:klass] + TASKS_RELATED_TO_APPEAL_TASK_TYPES[task_type]&.constantize + end + + def task_class_for_task_unrelated(data) + task_type = data[:klass] + CorrespondenceTask.tasks_not_related_to_an_appeal_names + .find { |name| name == task_type }&.constantize + end + + def correspondence_documents_efolder_uploader + @correspondence_documents_efolder_uploader ||= CorrespondenceDocumentsEfolderUploader.new + end + + def mail_task_class_for_type(task_type) + mail_task_types = { + AssociatedWithClaimsFolderMailTask.label => AssociatedWithClaimsFolderMailTask.name, + AddressChangeCorrespondenceMailTask.label => AddressChangeCorrespondenceMailTask.name, + EvidenceOrArgumentCorrespondenceMailTask.label => EvidenceOrArgumentCorrespondenceMailTask.name, + VacolsUpdatedMailTask.label => VacolsUpdatedMailTask.name + }.with_indifferent_access + + mail_task_types[task_type]&.constantize + end + + TASKS_RELATED_TO_APPEAL_TASK_TYPES = { + CavcCorrespondenceMailTask.name => CavcCorrespondenceMailTask.name, + ClearAndUnmistakeableErrorMailTask.name => ClearAndUnmistakeableErrorMailTask.name, + AddressChangeMailTask.name => AddressChangeMailTask.name, + CongressionalInterestMailTask.name => CongressionalInterestMailTask.name, + ControlledCorrespondenceMailTask.name => ControlledCorrespondenceMailTask.name, + DeathCertificateMailTask.name => DeathCertificateMailTask.name, + DocketSwitchMailTask.name => DocketSwitchMailTask.name, + EvidenceOrArgumentMailTask.name => EvidenceOrArgumentMailTask.name, + ExtensionRequestMailTask.name => ExtensionRequestMailTask.name, + FoiaRequestMailTask.name => FoiaRequestMailTask.name, + HearingPostponementRequestMailTask.name => HearingPostponementRequestMailTask.name, + HearingRelatedMailTask.name => HearingRelatedMailTask.name, + HearingWithdrawalRequestMailTask.name => HearingWithdrawalRequestMailTask.name, + ReconsiderationMotionMailTask.name => ReconsiderationMotionMailTask.name, + AodMotionMailTask.name => AodMotionMailTask.name, + OtherMotionMailTask.name => OtherMotionMailTask.name, + PowerOfAttorneyRelatedMailTask.name => PowerOfAttorneyRelatedMailTask.name, + PrivacyActRequestMailTask.name => PrivacyActRequestMailTask.name, + PrivacyComplaintMailTask.name => PrivacyComplaintMailTask.name, + ReturnedUndeliverableCorrespondenceMailTask.name => ReturnedUndeliverableCorrespondenceMailTask.name, + StatusInquiryMailTask.name => StatusInquiryMailTask.name, + AppealWithdrawalMailTask.name => AppealWithdrawalMailTask.name + }.with_indifferent_access + + def class_for_assigned_to(assigned_to) + available_assignees = { + AodTeam.name => AodTeam.name, + BvaDispatch.name => BvaDispatch.name, + CaseReview.name => CaseReview.name, + CavcLitigationSupport.name => CavcLitigationSupport.name, + ClerkOfTheBoard.name => ClerkOfTheBoard.name, + Colocated.name => Colocated.name, + HearingAdmin.name => HearingAdmin.name, + LitigationSupport.name => LitigationSupport.name, + PrivacyTeam.name => PrivacyTeam.name + }.with_indifferent_access + + available_assignees[assigned_to]&.constantize + end +end diff --git a/app/services/external_api/bgs_service.rb b/app/services/external_api/bgs_service.rb index 9ece1d0f259..d07ac013f93 100644 --- a/app/services/external_api/bgs_service.rb +++ b/app/services/external_api/bgs_service.rb @@ -24,6 +24,54 @@ def initialize(client: init_client) @people_by_ssn = {} end + def sensitivity_level_for_user(user) + fail "Invalid user" if !user.instance_of?(User) + + participant_id = get_participant_id_for_user(user) + + Rails.cache.fetch("sensitivity_level_for_user_id_#{user.id}", expires_in: 1.hour) do + DBService.release_db_connections + + MetricsService.record( + "BGS: sensitivity level for user #{user.id}", + service: :bgs, + name: "security.find_person_scrty_log_by_ptcpnt_id" + ) do + response = client.security.find_person_scrty_log_by_ptcpnt_id(participant_id) + + response.key?(:scrty_level_type_cd) ? Integer(response[:scrty_level_type_cd]) : 0 + rescue BGS::ShareError + 0 + end + end + end + + # :reek:FeatureEnvy + def sensitivity_level_for_veteran(veteran) + fail "Invalid veteran" if !veteran.instance_of?(Veteran) + + participant_id = veteran.participant_id + + Rails.cache.fetch("sensitivity_level_for_veteran_id_#{veteran.id}", expires_in: 1.hour) do + DBService.release_db_connections + + MetricsService.record( + "BGS: sensitivity level for veteran #{veteran.id}", + service: :bgs, + name: "security.find_sensitivity_level_by_participant_id" + ) do + response = client.security.find_sensitivity_level_by_participant_id(participant_id) + + # guard clause for no response + return 0 if response.blank? + + response&.key?(:scrty_level_type_cd) ? Integer(response[:scrty_level_type_cd]) : 0 + rescue BGS::ShareError + 0 + end + end + end + # :nocov: def get_end_products(vbms_id) diff --git a/app/services/organization_user_permission_checker.rb b/app/services/organization_user_permission_checker.rb new file mode 100644 index 00000000000..e041396adf5 --- /dev/null +++ b/app/services/organization_user_permission_checker.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class OrganizationUserPermissionChecker + def can_do_all?(permissions:, organization:, user:) + permissions.each do |permission| + return false if !can?( + permission_name: permission, + organization: organization, + user: user + ) + end + + true + end + + # Checks if a user possesses the permission and has the permitted flag enabled + # permission_name - a string pulled off a OrganizationPermission.permission attribute + # organization - an organization object + # user - an OrganizationUser object + def can?(permission_name:, organization:, user:) + org_permission = organization_permission(organization, permission_name) + return false if org_permission.blank? + + org_user = organization_user(organization, user) + return false if org_user.blank? + + OrganizationUserPermission.find_by( + organization_permission: org_permission, + organizations_user: org_user, + permitted: true + ).present? + end + + private + + def organization_permission(organization, permission_name) + OrganizationPermission.find_by( + organization: organization, + permission: permission_name, + enabled: true + ) + end + + def organization_user(organization, user) + OrganizationsUser.find_by( + organization: organization, + user: user + ) + end +end diff --git a/app/views/appeals/edit.html.erb b/app/views/appeals/edit.html.erb index 917a6cf17c5..5b0e54f2e38 100644 --- a/app/views/appeals/edit.html.erb +++ b/app/views/appeals/edit.html.erb @@ -26,6 +26,7 @@ correctClaimReviews: FeatureToggle.enabled?(:correct_claim_reviews, user: current_user), covidTimelinessExemption: FeatureToggle.enabled?(:covid_timeliness_exemption, user: current_user), split_appeal_workflow: FeatureToggle.enabled?(:split_appeal_workflow, user: current_user), + correspondence_queue: FeatureToggle.enabled?(:correspondence_queue, user: current_user), cc_appeal_workflow: FeatureToggle.enabled?(:cc_appeal_workflow, user: current_user), specialtyCaseTeamDistribution: FeatureToggle.enabled?(:specialty_case_team_distribution, user: current_user), mstIdentification: FeatureToggle.enabled?(:mst_identification, user: current_user), diff --git a/app/views/application/under_construction.html.erb b/app/views/application/under_construction.html.erb new file mode 100644 index 00000000000..5407297a3fb --- /dev/null +++ b/app/views/application/under_construction.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do certification_header("Under Construction") end %> + +<% content_for :full_page_content do %> + <%= react_component("UnderConstruction", props: { + userDisplayName: current_user ? current_user.display_name : "", + dropdownUrls: dropdown_urls, + feedbackUrl: feedback_url, + buildDate: build_date, + dependenciesFaked: ApplicationController.dependencies_faked?, + }) %> +<% end %> diff --git a/app/views/correspondence/correspondence_cases.html.erb b/app/views/correspondence/correspondence_cases.html.erb new file mode 100644 index 00000000000..97f89d0387e --- /dev/null +++ b/app/views/correspondence/correspondence_cases.html.erb @@ -0,0 +1,24 @@ +<% content_for :full_page_content do %> + <%= react_component("Queue", props: { + userDisplayName: current_user.display_name, + userId: current_user.id, + userRole: (current_user.vacols_roles.first || "").capitalize, + userCssId: current_user.css_id, + isInboundOpsTeamUsers: current_user.inbound_ops_team_user?, + isInboundOpsSupervisor: current_user.inbound_ops_team_supervisor?, + isInboundOpsSuperuser: current_user.inbound_ops_team_superuser?, + organizations: current_user.selectable_organizations.map {|o| o.slice(:name, :url)}, + caseSearchHomePage: case_search_home_page, + dropdownUrls: dropdown_urls, + applicationUrls: application_urls, + feedbackUrl: feedback_url, + responseType: @response_type, + responseHeader: @response_header, + responseMessage: @response_message, + configUrl: '/queue/correspondence?json', + flash: flash, + featureToggles: { + correspondence_queue: FeatureToggle.enabled?(:correspondence_queue, user: current_user) + } + }) %> +<% end %> diff --git a/app/views/correspondence/correspondence_details.html.erb b/app/views/correspondence/correspondence_details.html.erb new file mode 100644 index 00000000000..d8ae2e2587a --- /dev/null +++ b/app/views/correspondence/correspondence_details.html.erb @@ -0,0 +1,28 @@ +<% content_for :full_page_content do %> + <%= react_component("Queue", props: { + userDisplayName: current_user.display_name, + userId: current_user.id, + userRole: (current_user.vacols_roles.first || "").capitalize, + userCssId: current_user.css_id, + organizations: current_user.selectable_organizations.map {|o| o.slice(:name, :url)}, + caseSearchHomePage: case_search_home_page, + dropdownUrls: dropdown_urls, + applicationUrls: application_urls, + feedbackUrl: feedback_url, + correspondence: @correspondence_details[:correspondence], + flash: flash, + isInboundOpsUser: current_user.inbound_ops_team_user?, + isInboundOpsSupervisor: current_user.inbound_ops_team_supervisor?, + isInboundOpsSuperuser: current_user.inbound_ops_team_superuser?, + inboundOpsTeamUsers: @correspondence_details[:inbound_ops_team_users], + correspondenceAppeals: @correspondence_appeals, + responseType: @response_type, + responseHeader: @response_header, + responseMessage: @response_message, + organizations: @correspondence_details[:organizations], + correspondenceResponseLetters: @correspondence_response_letters, + featureToggles: { + correspondence_queue: FeatureToggle.enabled?(:correspondence_queue, user: current_user) + } + }) %> +<% end %> diff --git a/app/views/correspondence/correspondence_team.html.erb b/app/views/correspondence/correspondence_team.html.erb new file mode 100644 index 00000000000..24cf161a375 --- /dev/null +++ b/app/views/correspondence/correspondence_team.html.erb @@ -0,0 +1,26 @@ +<% content_for :full_page_content do %> + <%= react_component("Queue", props: { + userDisplayName: current_user.display_name, + userId: current_user.id, + userRole: (current_user.vacols_roles.first || "").capitalize, + userCssId: current_user.css_id, + organizations: current_user.selectable_organizations.map {|o| o.slice(:name, :url)}, + caseSearchHomePage: case_search_home_page, + dropdownUrls: dropdown_urls, + applicationUrls: application_urls, + feedbackUrl: feedback_url, + configUrl: '/queue/correspondence/team?json', + flash: flash, + isInboundOpsUser: current_user.inbound_ops_team_user?, + isInboundOpsSupervisor: current_user.inbound_ops_team_supervisor?, + isInboundOpsSuperuser: current_user.inbound_ops_team_superuser?, + inboundOpsTeamUsers: @inbound_ops_team_users, + inboundOpsTeamNonAdmin: @inbound_ops_team_non_admin, + responseType: @response_type, + responseHeader: @response_header, + responseMessage: @response_message, + featureToggles: { + correspondence_queue: FeatureToggle.enabled?(:correspondence_queue, user: current_user) + } + }) %> +<% end %> diff --git a/app/views/correspondence/intake.html.erb b/app/views/correspondence/intake.html.erb new file mode 100644 index 00000000000..0bb9ee5b0ad --- /dev/null +++ b/app/views/correspondence/intake.html.erb @@ -0,0 +1,20 @@ +<% content_for :full_page_content do %> + <%= react_component("Queue", props: { + userDisplayName: current_user.display_name, + userId: current_user.id, + userRole: (current_user.vacols_roles.first || "").capitalize, + userCssId: current_user.css_id, + dropdownUrls: dropdown_urls, + applicationUrls: application_urls, + feedbackUrl: feedback_url, + organizations: current_user.selectable_organizations.map {|o| o.slice(:name, :url)}, + featureToggles: { + correspondence_queue: FeatureToggle.enabled?(:correspondence_queue, user: current_user) + }, + priorMail: @prior_mail, + correspondence: @correspondence, + isInboundOpsSupervisor: current_user.inbound_ops_team_supervisor?, + reduxStore: @redux_store, + autoTexts: @auto_texts, + }) %> +<% end %> diff --git a/app/views/correspondence/review_package.html.erb b/app/views/correspondence/review_package.html.erb new file mode 100644 index 00000000000..f928607ba51 --- /dev/null +++ b/app/views/correspondence/review_package.html.erb @@ -0,0 +1,20 @@ +<% content_for :full_page_content do %> + <%= react_component("Queue", props: { + userDisplayName: current_user.display_name, + userId: current_user.id, + userRole: (current_user.vacols_roles.first || "").capitalize, + userCssId: current_user.css_id, + isInboundOpsSuperuser: current_user.inbound_ops_team_superuser?, + userIsInboundOpsSupervisor: current_user.inbound_ops_team_supervisor?, + organizations: current_user.selectable_organizations.map {|o| o.slice(:name, :url)}, + caseSearchHomePage: case_search_home_page, + dropdownUrls: dropdown_urls, + inboundOpsTeamUsers: @inbound_ops_team_users, + correspondence: @correspondence, + correspondenceTypes: @correspondence_types, + hasEfolderFailedTask: @has_efolder_failed_task, + featureToggles: { + correspondence_queue: FeatureToggle.enabled?(:correspondence_queue, user: current_user) + } + }) %> +<% end %> diff --git a/app/views/explain/show.html.erb b/app/views/explain/show.html.erb index 6c14341c578..8bb65bcf1d2 100644 --- a/app/views/explain/show.html.erb +++ b/app/views/explain/show.html.erb @@ -82,6 +82,188 @@ +<% elsif appeal.class.name == "Correspondence" %> +
+ Correspondence <%=appeal["uuid"]%>+ + |
+
+
+
+ show_pii = <%= show_pii_query_param %> .
+ To toggle PII, click + <%= link_to('toggle show_pii', {action: 'show', show_pii: !show_pii_query_param, + fields: fields_query_param.to_s, + sections: sections_query_param.to_s}) %>. + + |
+