diff --git a/modules/claims_api/app/controllers/claims_api/v2/application_controller.rb b/modules/claims_api/app/controllers/claims_api/v2/application_controller.rb index 311664df41dc..f5ee8d91656e 100644 --- a/modules/claims_api/app/controllers/claims_api/v2/application_controller.rb +++ b/modules/claims_api/app/controllers/claims_api/v2/application_controller.rb @@ -15,6 +15,7 @@ module ClaimsApi module V2 class ApplicationController < ::ApplicationController include ClaimsApi::Error::ErrorHandler + include ClaimsApi::SlackNotifier include ClaimsApi::TokenValidation include ClaimsApi::CcgTokenValidation include ClaimsApi::TargetVeteran diff --git a/modules/claims_api/app/controllers/claims_api/v2/veterans/power_of_attorney/request_controller.rb b/modules/claims_api/app/controllers/claims_api/v2/veterans/power_of_attorney/request_controller.rb index 262fdeaff198..26de893107f4 100644 --- a/modules/claims_api/app/controllers/claims_api/v2/veterans/power_of_attorney/request_controller.rb +++ b/modules/claims_api/app/controllers/claims_api/v2/veterans/power_of_attorney/request_controller.rb @@ -10,11 +10,10 @@ module ClaimsApi module V2 module Veterans class PowerOfAttorney::RequestController < ClaimsApi::V2::Veterans::PowerOfAttorney::BaseController + include ClaimsApi::V2::PowerOfAttorneyRequests::IndexValidation + include ClaimsApi::V2::PowerOfAttorneyRequests::CreateValidation + FORM_NUMBER = 'POA_REQUEST' - MAX_PAGE_SIZE = 100 - MAX_PAGE_NUMBER = 100 - DEFAULT_PAGE_SIZE = 10 - DEFAULT_PAGE_NUMBER = 1 # POST /power-of-attorney-requests def index @@ -202,11 +201,21 @@ def process_poa_decision(decision:, proc_id:, representative_id:, poa_code:, met @json_body, type = result validate_mapped_data!(veteran.participant_id, type, poa_code) - # build headers + build_decision_headers(veteran, claimant) + save_and_submit_poa_record(poa_code:, representative_id:, type:) + rescue => e + log_decision_failure(proc_id, e) + raise + end + # rubocop:enable Metrics/ParameterLists + + def build_decision_headers(veteran, claimant) @claimant_icn = claimant.icn.presence || claimant.mpi.icn if claimant build_auth_headers(veteran) + end + + def save_and_submit_poa_record(poa_code:, representative_id:, type:) attrs = decide_request_attributes(poa_code:, decide_form_attributes: form_attributes) - # save record power_of_attorney = ClaimsApi::PowerOfAttorney.create!(attrs) claims_v2_logging('process_poa_decision', @@ -214,13 +223,17 @@ def process_poa_decision(decision:, proc_id:, representative_id:, poa_code:, met ClaimsApi::V2::PoaFormBuilderJob.perform_async(power_of_attorney.id, type, 'post', representative_id) - power_of_attorney # return to the decide method for the response - rescue => e + power_of_attorney + end + + def log_decision_failure(proc_id, error) claims_v2_logging('process_poa_decision', - message: "Failed to save power of attorney record. Error: #{e}") - raise e + level: :error, + message: "Failed to save power of attorney record. Error: #{error}") + request_slack_alert('POA Decide Request', + "Failed to process POA decision for id: #{params[:id]}, " \ + "procId: #{proc_id} in #{Rails.env}: #{error.message}") end - # rubocop:enable Metrics/ParameterLists def validate_mapped_data!(veteran_participant_id, type, poa_code) claims_v2_logging('process_poa_decision', @@ -235,14 +248,21 @@ def validate_mapped_data!(veteran_participant_id, type, poa_code) validate_json_schema(type.upcase) # otherwise we raise the errors from the custom validations if no JSON # errors exist - log_and_raise_decision_error_message! if @claims_api_forms_validation_errors - rescue JsonSchema::JsonApiMissingAttribute - log_and_raise_decision_error_message! + if @claims_api_forms_validation_errors + log_and_raise_decision_error_message!(@claims_api_forms_validation_errors) + end + rescue JsonSchema::JsonApiMissingAttribute => e + log_and_raise_decision_error_message!(e.to_json_api) end - def log_and_raise_decision_error_message! + def log_and_raise_decision_error_message!(errors) claims_v2_logging('process_poa_decision', - message: 'Encountered issues validating the mapped data') + level: :error, + message: "Encountered issues validating the mapped data for POA id: #{params[:id]}: " \ + "#{errors}") + request_slack_alert('POA Decide Request', + "Validation errors in mapped data for POA id: #{params[:id]} in #{Rails.env}: " \ + "#{errors}") raise ::Common::Exceptions::UnprocessableEntity.new( detail: 'An error occurred while processing this decision. Please try again later.' @@ -297,180 +317,8 @@ def find_poa_request!(lighthouse_id) request end - def validate_country_code - vet_cc = form_attributes.dig('veteran', 'address', 'countryCode') - claimant_cc = form_attributes.dig('claimant', 'address', 'countryCode') - - if ClaimsApi::BRD::COUNTRY_CODES[vet_cc.to_s.upcase].blank? - raise ::Common::Exceptions::UnprocessableEntity.new( - detail: 'The country provided is not valid.' - ) - end - - if claimant_cc.present? && ClaimsApi::BRD::COUNTRY_CODES[claimant_cc.to_s.upcase].blank? - raise ::Common::Exceptions::UnprocessableEntity.new( - detail: 'The country provided is not valid.' - ) - end - end - - def validate_phone_country_code - %w[veteran claimant].each do |key| - phone = form_attributes.dig(key, 'phone') - next if phone.blank? - - validate_phone_details(phone, key) - end - end - - def validate_phone_details(phone, key) - return if phone['phoneNumber'].blank? - - validate_phone_and_country_code_combination_not_valid!(phone, key) - validate_domestic_country_code_on_international_number!(phone, key) - end - - def validate_phone_and_country_code_combination_not_valid!(phone_data, key) - phone_number = phone_data['phoneNumber']&.gsub(/\D/, '') - country_code = phone_data['countryCode'] - - if phone_number.length > 7 && country_code.blank? - raise ::Common::Exceptions::UnprocessableEntity.new( - detail: "The #{key}'s international phone number requires a countryCode." - ) - end - end - - def validate_domestic_country_code_on_international_number!(phone_data, key) - phone_number = phone_data['phoneNumber']&.gsub(/\D/, '') - country_code = phone_data['countryCode']&.gsub(/\D/, '') - - if phone_number.length > 7 && country_code == '1' - raise ::Common::Exceptions::UnprocessableEntity.new( - detail: "The #{key}'s countryCode is for a domestic phone number." - ) - end - end - - def validate_accredited_representative(poa_code) - @representative = ::Veteran::Service::Representative.where('? = ANY(poa_codes)', - poa_code).order(created_at: :desc).first - # there must be a representative to appoint. This representative can be an accredited attorney, claims agent, - # or representative. - if @representative.nil? - raise ::Common::Exceptions::ResourceNotFound.new( - detail: "Could not find an Accredited Representative with poa code: #{poa_code}" - ) - end - end - - def validate_accredited_organization(poa_code) - # organization is not required. An attorney or claims agent appointment request would not have an accredited - # organization to associate with. - @organization = ::Veteran::Service::Organization.find_by(poa: poa_code) - end - - def build_bgs_attributes(form_attributes) - bgs_form_attributes = form_attributes.deep_merge(veteran_data) - bgs_form_attributes.deep_merge!(claimant_data) if user_profile&.status == :ok - bgs_form_attributes.deep_merge!(representative_data) - bgs_form_attributes.deep_merge!(organization_data) if @organization - - bgs_form_attributes - end - - def validate_filter!(filter) - return nil if filter.blank? - - valid_filters = %w[status state city country] - - invalid_filters = filter.keys - valid_filters - - if invalid_filters.any? - raise ::Common::Exceptions::UnprocessableEntity.new( - detail: "Invalid filter(s): #{invalid_filters.join(', ')}" - ) - end - - validate_statuses!(filter['status']) - end - - def validate_statuses!(statuses) - return nil if statuses.blank? - - unless statuses.is_a?(Array) - raise ::Common::Exceptions::UnprocessableEntity.new( - detail: 'filter status must be an array' - ) - end - - valid_statuses = ManageRepresentativeService::ALL_STATUSES - - if statuses.any? { |status| valid_statuses.exclude?(status.upcase) } - raise ::Common::Exceptions::UnprocessableEntity.new( - detail: "Status(es) must be one of: #{valid_statuses.join(', ')}" - ) - end - end - - def validate_page_size_and_number_params - return if use_defaults? - - valid_page_param?('size') if params[:page][:size] - valid_page_param?('number') if params[:page][:number] - - @page_size_param = params[:page][:size] ? params[:page][:size].to_i : DEFAULT_PAGE_SIZE - @page_number_param = params[:page][:number] ? params[:page][:number].to_i : DEFAULT_PAGE_NUMBER - - verify_under_max_values! - end - - def use_defaults? - if params[:page].blank? - @page_size_param = DEFAULT_PAGE_SIZE - @page_number_param = DEFAULT_PAGE_NUMBER - - true - end - end - - def verify_under_max_values! - if @page_size_param && @page_size_param > MAX_PAGE_SIZE - raise_param_exceeded_warning = true - include_page_size_msg = true - end - if @page_number_param && @page_number_param > MAX_PAGE_NUMBER - raise_param_exceeded_warning = true - include_page_number_msg = true - end - if raise_param_exceeded_warning.present? - build_params_error_msg(include_page_size_msg, - include_page_number_msg) - end - end - - def valid_page_param?(key) - param_val = params[:page][:"#{key}"] - return true if param_val.is_a?(String) && param_val.match?(/^\d+?$/) && param_val - - raise ::Common::Exceptions::BadRequest.new( - detail: "The page[#{key}] param value #{params[:page][:"#{key}"]} is invalid" - ) - end - - def build_params_error_msg(include_page_size_msg, include_page_number_msg) - if include_page_size_msg.present? && include_page_number_msg.present? - msg = "Both the maximum page size param value of #{MAX_PAGE_SIZE} has been exceeded and " \ - "the maximum page number param value of #{MAX_PAGE_NUMBER} has been exceeded." - elsif include_page_size_msg.present? - msg = "The maximum page size param value of #{MAX_PAGE_SIZE} has been exceeded." - elsif include_page_number_msg.present? - msg = "The maximum page number param value of #{MAX_PAGE_NUMBER} has been exceeded." - end - - raise ::Common::Exceptions::BadRequest.new( - detail: msg - ) + def normalize(item) + item.to_s.strip.downcase end def verify_poa_codes_data(poa_codes) @@ -486,8 +334,13 @@ def page_number_to_index(number) number - 1 end - def normalize(item) - item.to_s.strip.downcase + def build_bgs_attributes(form_attributes) + bgs_form_attributes = form_attributes.deep_merge(veteran_data) + bgs_form_attributes.deep_merge!(claimant_data) if user_profile&.status == :ok + bgs_form_attributes.deep_merge!(representative_data) + bgs_form_attributes.deep_merge!(organization_data) if @organization + + bgs_form_attributes end def veteran_data diff --git a/modules/claims_api/app/controllers/concerns/claims_api/slack_notifier.rb b/modules/claims_api/app/controllers/concerns/claims_api/slack_notifier.rb new file mode 100644 index 000000000000..1086999462e9 --- /dev/null +++ b/modules/claims_api/app/controllers/concerns/claims_api/slack_notifier.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ClaimsApi + module SlackNotifier + extend ActiveSupport::Concern + + private + + def request_slack_alert(source, message) + webhook_url = Settings.claims_api.slack.webhook_url.to_s + return if webhook_url.blank? + + slack_client = SlackNotify::Client.new(webhook_url:, + channel: '#api-benefits-claims-alerts', + username: "Failed #{source}") + slack_client.notify(message) + rescue => e + ClaimsApi::Logger.log('request_slack_alert', + level: :error, + message: "Failed to send Slack alert: #{e.message}") + end + end +end diff --git a/modules/claims_api/app/controllers/concerns/claims_api/v2/power_of_attorney_requests/create_validation.rb b/modules/claims_api/app/controllers/concerns/claims_api/v2/power_of_attorney_requests/create_validation.rb new file mode 100644 index 000000000000..3fbf5f63ab62 --- /dev/null +++ b/modules/claims_api/app/controllers/concerns/claims_api/v2/power_of_attorney_requests/create_validation.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'brd/brd' + +module ClaimsApi + module V2 + module PowerOfAttorneyRequests + module CreateValidation + extend ActiveSupport::Concern + + private + + def validate_country_code + vet_cc = form_attributes.dig('veteran', 'address', 'countryCode') + claimant_cc = form_attributes.dig('claimant', 'address', 'countryCode') + + if ClaimsApi::BRD::COUNTRY_CODES[vet_cc.to_s.upcase].blank? + raise ::Common::Exceptions::UnprocessableEntity.new( + detail: 'The country provided is not valid.' + ) + end + + if claimant_cc.present? && ClaimsApi::BRD::COUNTRY_CODES[claimant_cc.to_s.upcase].blank? + raise ::Common::Exceptions::UnprocessableEntity.new( + detail: 'The country provided is not valid.' + ) + end + end + + def validate_phone_country_code + %w[veteran claimant].each do |key| + phone = form_attributes.dig(key, 'phone') + next if phone.blank? + + validate_phone_details(phone, key) + end + end + + def validate_phone_details(phone, key) + return if phone['phoneNumber'].blank? + + validate_phone_and_country_code_combination_not_valid!(phone, key) + validate_domestic_country_code_on_international_number!(phone, key) + end + + def validate_phone_and_country_code_combination_not_valid!(phone_data, key) + phone_number = phone_data['phoneNumber']&.gsub(/\D/, '') + country_code = phone_data['countryCode'] + + if phone_number.length > 7 && country_code.blank? + raise ::Common::Exceptions::UnprocessableEntity.new( + detail: "The #{key}'s international phone number requires a countryCode." + ) + end + end + + def validate_domestic_country_code_on_international_number!(phone_data, key) + phone_number = phone_data['phoneNumber']&.gsub(/\D/, '') + country_code = phone_data['countryCode']&.gsub(/\D/, '') + + if phone_number.length > 7 && country_code == '1' + raise ::Common::Exceptions::UnprocessableEntity.new( + detail: "The #{key}'s countryCode is for a domestic phone number." + ) + end + end + + def validate_accredited_representative(poa_code) + @representative = ::Veteran::Service::Representative.where('? = ANY(poa_codes)', + poa_code).order(created_at: :desc).first + # there must be a representative to appoint. This representative can be an accredited attorney, claims agent, + # or representative. + if @representative.nil? + raise ::Common::Exceptions::ResourceNotFound.new( + detail: "Could not find an Accredited Representative with poa code: #{poa_code}" + ) + end + end + + def validate_accredited_organization(poa_code) + # organization is not required. An attorney or claims agent appointment request would not have an accredited + # organization to associate with. + @organization = ::Veteran::Service::Organization.find_by(poa: poa_code) + end + end + end + end +end diff --git a/modules/claims_api/app/controllers/concerns/claims_api/v2/power_of_attorney_requests/index_validation.rb b/modules/claims_api/app/controllers/concerns/claims_api/v2/power_of_attorney_requests/index_validation.rb new file mode 100644 index 000000000000..8e77c352e4f8 --- /dev/null +++ b/modules/claims_api/app/controllers/concerns/claims_api/v2/power_of_attorney_requests/index_validation.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module ClaimsApi + module V2 + module PowerOfAttorneyRequests + module IndexValidation + extend ActiveSupport::Concern + + MAX_PAGE_SIZE = 100 + MAX_PAGE_NUMBER = 100 + DEFAULT_PAGE_SIZE = 10 + DEFAULT_PAGE_NUMBER = 1 + + private + + def validate_filter!(filter) + return nil if filter.blank? + + valid_filters = %w[status state city country] + invalid_filters = filter.keys - valid_filters + + if invalid_filters.any? + raise ::Common::Exceptions::UnprocessableEntity.new( + detail: "Invalid filter(s): #{invalid_filters.join(', ')}" + ) + end + + validate_statuses!(filter['status']) + end + + def validate_statuses!(statuses) + return nil if statuses.blank? + + unless statuses.is_a?(Array) + raise ::Common::Exceptions::UnprocessableEntity.new( + detail: 'filter status must be an array' + ) + end + + valid_statuses = ManageRepresentativeService::ALL_STATUSES + if statuses.any? { |status| valid_statuses.exclude?(status.upcase) } + raise ::Common::Exceptions::UnprocessableEntity.new( + detail: "Status(es) must be one of: #{valid_statuses.join(', ')}" + ) + end + end + + def validate_page_size_and_number_params + return if use_defaults? + + page = params[:page] + + valid_page_param?('size') if page[:size] + valid_page_param?('number') if page[:number] + + @page_size_param = page[:size] ? page[:size].to_i : DEFAULT_PAGE_SIZE + @page_number_param = page[:number] ? page[:number].to_i : DEFAULT_PAGE_NUMBER + + verify_under_max_values! + end + + def use_defaults? + if params[:page].blank? + @page_size_param = DEFAULT_PAGE_SIZE + @page_number_param = DEFAULT_PAGE_NUMBER + + true + end + end + + def verify_under_max_values! + if @page_size_param && @page_size_param > MAX_PAGE_SIZE + raise_param_exceeded_warning = true + include_page_size_msg = true + end + if @page_number_param && @page_number_param > MAX_PAGE_NUMBER + raise_param_exceeded_warning = true + include_page_number_msg = true + end + if raise_param_exceeded_warning.present? + build_params_error_msg(include_page_size_msg, + include_page_number_msg) + end + end + + def valid_page_param?(key) + param_val = params[:page][:"#{key}"] + return true if param_val.is_a?(String) && param_val.match?(/^\d+?$/) && param_val + + raise ::Common::Exceptions::BadRequest.new( + detail: "The page[#{key}] param value #{params[:page][:"#{key}"]} is invalid" + ) + end + + def build_params_error_msg(include_page_size_msg, include_page_number_msg) + if include_page_size_msg.present? && include_page_number_msg.present? + msg = "Both the maximum page size param value of #{MAX_PAGE_SIZE} has been exceeded and " \ + "the maximum page number param value of #{MAX_PAGE_NUMBER} has been exceeded." + elsif include_page_size_msg.present? + msg = "The maximum page size param value of #{MAX_PAGE_SIZE} has been exceeded." + elsif include_page_number_msg.present? + msg = "The maximum page number param value of #{MAX_PAGE_NUMBER} has been exceeded." + end + + raise ::Common::Exceptions::BadRequest.new( + detail: msg + ) + end + end + end + end +end diff --git a/modules/claims_api/spec/controllers/v2/veterans/power_of_attorney/request_controller_spec.rb b/modules/claims_api/spec/controllers/v2/veterans/power_of_attorney/request_controller_spec.rb index cf6cd87791d1..2d40a371919c 100644 --- a/modules/claims_api/spec/controllers/v2/veterans/power_of_attorney/request_controller_spec.rb +++ b/modules/claims_api/spec/controllers/v2/veterans/power_of_attorney/request_controller_spec.rb @@ -1199,4 +1199,119 @@ def create_request_with(veteran_id:, form_attributes:, auth_header:) params: { data: { attributes: form_attributes } }.to_json, headers: auth_header.merge('Content-Type' => 'application/json') end + + describe '#request_slack_alert' do + let(:slack_client) { instance_double(SlackNotify::Client) } + + before do + allow(SlackNotify::Client).to receive(:new).and_return(slack_client) + allow(slack_client).to receive(:notify) + end + + it 'sends a Slack notification with the correct parameters' do + subject.send(:request_slack_alert, 'POA Decide Request', 'test message') + + expect(SlackNotify::Client).to have_received(:new).with( + webhook_url: Settings.claims_api.slack.webhook_url.to_s, + channel: '#api-benefits-claims-alerts', + username: 'Failed POA Decide Request' + ) + expect(slack_client).to have_received(:notify).with('test message') + end + + context 'when webhook_url is blank' do + before do + allow(Settings.claims_api.slack).to receive(:webhook_url).and_return(nil) + end + + it 'does not attempt to send a Slack notification' do + subject.send(:request_slack_alert, 'POA Decide Request', 'test message') + + expect(SlackNotify::Client).not_to have_received(:new) + end + end + + context 'when Slack notification fails' do + before do + allow(slack_client).to receive(:notify).and_raise(StandardError.new('Slack down')) + end + + it 'logs the failure and does not raise' do + expect(ClaimsApi::Logger).to receive(:log).with( + 'request_slack_alert', + level: :error, + message: 'Failed to send Slack alert: Slack down' + ) + + expect { subject.send(:request_slack_alert, 'POA Decide Request', 'test') }.not_to raise_error + end + end + end + + describe '#log_and_raise_decision_error_message!' do + let(:slack_client) { instance_double(SlackNotify::Client) } + + before do + allow(SlackNotify::Client).to receive(:new).and_return(slack_client) + allow(slack_client).to receive(:notify) + allow_any_instance_of(described_class).to receive(:claims_v2_logging) + allow_any_instance_of(described_class).to receive(:params).and_return({ id: 'abc-123' }) + end + + it 'sends a Slack alert including the POA request id and validation errors before raising' do + errors = ['field X is invalid'] + + expect(slack_client).to receive(:notify).with( + a_string_including('id: abc-123').and(a_string_including('field X is invalid')) + ) + + expect { subject.send(:log_and_raise_decision_error_message!, errors) }.to raise_error( + Common::Exceptions::UnprocessableEntity + ) + end + + it 'logs at error level with the POA request id and validation errors' do + errors = ['field X is invalid'] + + expect_any_instance_of(described_class).to receive(:claims_v2_logging).with( + 'process_poa_decision', + level: :error, + message: a_string_including('id: abc-123').and(a_string_including('field X is invalid')) + ) + + expect { subject.send(:log_and_raise_decision_error_message!, errors) }.to raise_error( + Common::Exceptions::UnprocessableEntity + ) + end + end + + describe '#process_poa_decision rescue' do + let(:slack_client) { instance_double(SlackNotify::Client) } + let(:params) do + { + decision: 'accepted', proc_id: '12345', representative_id: '999', + poa_code: '083', metadata: {}, veteran: nil, claimant: nil + } + end + + before do + allow(SlackNotify::Client).to receive(:new).and_return(slack_client) + allow(slack_client).to receive(:notify) + allow_any_instance_of(described_class).to receive(:claims_v2_logging) + allow_any_instance_of(described_class).to receive(:params).and_return({ id: 'lh-id-456' }) + allow_any_instance_of( + ClaimsApi::PowerOfAttorneyRequestService::DecisionHandler + ).to receive(:call).and_raise(StandardError.new('BGS exploded')) + end + + it 'sends a Slack alert with the lighthouse id, proc id, and error message' do + expect(slack_client).to receive(:notify).with( + a_string_including('id: lh-id-456') + .and(a_string_including('procId: 12345')) + .and(a_string_including('BGS exploded')) + ) + + expect { subject.send(:process_poa_decision, **params) }.to raise_error(StandardError, 'BGS exploded') + end + end end