diff --git a/config/features.yml b/config/features.yml index e72a4997aed7..cf8b55d3e86d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -169,6 +169,10 @@ features: actor_type: user description: Enables IVC CHAMPVA form status entries in My VA submission statuses/cards enable_in_development: true + ivc_champva_poll_pega_status_job: + actor_type: user + description: Enables CHAMPVA PEGA status polling job execution + enable_in_development: true benefits_documents_use_lighthouse: actor_type: user description: Use lighthouse instead of EVSS to upload benefits documents. diff --git a/config/settings.yml b/config/settings.yml index 119f4c7f5dcb..b1044187a673 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -678,6 +678,7 @@ ivc_champva: pega_api: api_key: <%= ENV['ivc_champva__pega_api__api_key'] %> base_path: <%= ENV['ivc_champva__pega_api__base_path'] %> + status_path: <%= ENV['ivc_champva__pega_api__status_path'] %> prefill: true ivc_champva_llm_processor_api: api_key: <%= ENV['ivc_champva_llm_processor_api__api_key'] %> diff --git a/config/settings/development.yml b/config/settings/development.yml index 6ac611834d79..06568fe382c2 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -675,8 +675,9 @@ intent_to_file: prefill: true ivc_champva: pega_api: - api_key: fake_api_key - base_path: fake_base_path + api_key: <%= ENV['ivc_champva__pega_api__api_key'] || 'fake_api_key' %> + base_path: <%= ENV['ivc_champva__pega_api__base_path'] || 'fake_base_path' %> + status_path: <%= ENV['ivc_champva__pega_api__status_path'] || 'fake_status_path' %> prefill: true ivc_champva_llm_processor_api: api_key: fake_llm_api_key diff --git a/config/settings/test.yml b/config/settings/test.yml index 354ebe5b1d93..87d71bd5f48d 100644 --- a/config/settings/test.yml +++ b/config/settings/test.yml @@ -676,6 +676,7 @@ ivc_champva: pega_api: api_key: fake_api_key base_path: fake_base_path + status_path: fake_status_path prefill: true ivc_champva_llm_processor_api: api_key: fake_llm_api_key diff --git a/lib/periodic_jobs.rb b/lib/periodic_jobs.rb index 3c6057f818fa..fa9efbb2f2f0 100644 --- a/lib/periodic_jobs.rb +++ b/lib/periodic_jobs.rb @@ -252,6 +252,9 @@ # Every 15min job that sends missing Pega statuses to DataDog mgr.register('*/15 * * * *', 'IvcChampva::MissingFormStatusJob') + # Daily job that polls the Pega reporting API to update pega_status on non-complete forms + mgr.register('0 2 * * *', 'IvcChampva::PollPegaStatusJob') + # Daily job that sends notification emails to Pega of missing form statuses mgr.register('0 0 * * *', 'IvcChampva::NotifyPegaMissingFormStatusJob') diff --git a/modules/ivc_champva/app/jobs/ivc_champva/poll_pega_status_job.rb b/modules/ivc_champva/app/jobs/ivc_champva/poll_pega_status_job.rb new file mode 100644 index 000000000000..c37c9a1d14c0 --- /dev/null +++ b/modules/ivc_champva/app/jobs/ivc_champva/poll_pega_status_job.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'sidekiq' +require 'pega_api/client' + +# Daily cron job that polls the Pega status API for every IvcChampvaForm that has +# not yet reached a terminal/complete state. It writes the latest Pega status and +# case_id back to the DB record so the frontend can reflect the current stage of the +# veteran's application. +# +# Flow per batch (grouped by form_uuid): +# 1. Query ivc_champva_forms for records where pega_status is NULL or non-terminal +# 2. For each distinct form_uuid, GET the Pega status endpoint with Uuid header = form_uuid +# 3. Match each returned case object to a DB record by case_id +# (falls back to the first report when case_id is not yet assigned) +# 4. Write pega_status + case_id back to the record via update_columns +module IvcChampva + class PollPegaStatusJob + include Sidekiq::Job + sidekiq_options retry: 3 + + FEATURE_TOGGLE = :ivc_champva_poll_pega_status_job + STATUS_KEYS = ['Determination Type', 'Deternimation Type'].freeze + + # Pega terminal/determination statuses — once a form reaches one of these we stop + # polling because the application has been fully adjudicated. These match the + # COMPLETE_STATUSES values in ClaimBuilder and the STATUS_MAP in IvcChampvaFormatter. + COMPLETE_STATUSES = [ + 'eligiblity denied/additional information needed', + 'eligible - issued a card', + 'duplicate application', + 'eligible - reissued a card', + 'additional documentation requested', + 'processed - eligiblity determination unknown' + ].freeze + + def perform + return unless Flipper.enabled?(FEATURE_TOGGLE) + + form_uuids = pending_form_uuids + log_start(form_uuids.size) + return if form_uuids.empty? + + results = process_batches(form_uuids) + log_complete(results) + rescue => e + log_error(e) + end + + private + + # ────────────────────────────────────────────── + # Query + # ────────────────────────────────────────────── + + def pending_forms_scope + # WHERE NOT IN (...) silently drops NULL rows in SQL, so we explicitly include + # them — nil means the Pega webhook has never fired for this submission. + IvcChampvaForm + .where('pega_status IS NULL OR pega_status NOT IN (?)', COMPLETE_STATUSES) + end + + def pending_form_uuids + pending_forms_scope + .where.not(form_uuid: nil) + .distinct + .order(:form_uuid) + .pluck(:form_uuid) + end + + def forms_for_uuid(form_uuid) + pending_forms_scope + .where(form_uuid:) + .order(:created_at) + .to_a + end + + # ────────────────────────────────────────────── + # Batch processing + # ────────────────────────────────────────────── + + def process_batches(form_uuids) + form_uuids.each_with_object({ updated: 0, skipped: 0, error: 0 }) do |form_uuid, results| + batch = forms_for_uuid(form_uuid) + next if batch.empty? + + poll_batch(form_uuid, batch).each { |key, count| results[key] += count } + end + end + + def poll_batch(form_uuid, batch) + reports = pega_api_client.get_status_by_uuid(form_uuid) + unless valid_reports?(reports) + log_skip(form_uuid, 'no reports returned from Pega') + return { updated: 0, skipped: batch.size, error: 0 } + end + + reports_by_case_id = reports.index_by { |report| report['PEGA Case ID'] } + fallback_report = reports.first + + outcomes = batch.map { |form| apply_report(form, reports_by_case_id, fallback_report, form_uuid) } + + # Collect actionable skip reasons and emit one summary log per UUID + # instead of one line per record. "no status change" is logged at debug + # inside apply_report and intentionally excluded here. + skip_reasons = outcomes.filter_map { |outcome, reason| reason if outcome == :skipped && reason } + log_batch_skips(form_uuid, skip_reasons) if skip_reasons.any? + + { updated: outcomes.count { |o, _| o == :updated }, skipped: outcomes.count { |o, _| o == :skipped }, error: 0 } + rescue IvcChampva::PegaApi::PegaApiError => e + log_api_error(form_uuid, e) + { updated: 0, skipped: 0, error: batch.size } + end + + # ────────────────────────────────────────────── + # Report application + # ────────────────────────────────────────────── + + # Finds the report matching this form's case_id (if one exists) and updates + # the record. Falls back to the first report when case_id is not yet assigned. + # Returns a 2-tuple [outcome, reason] where outcome is :updated or :skipped. + # reason is a string for actionable skip cases that get aggregated into a + # per-UUID summary log, or nil for the high-volume no-change case (logged at debug). + def apply_report(form, reports_by_case_id, fallback_report, form_uuid) + report = report_for(form, reports_by_case_id, fallback_report) + return [:skipped, "no matching report for case_id: #{form.case_id}"] unless report + + status = extract_status(report) + case_id = report['PEGA Case ID'] + + return [:skipped, 'blank status in report'] if status.blank? + + unless needs_update?(form, status, case_id) + Rails.logger.debug { "IVC Forms PollPegaStatusJob - no status change for case_id: #{case_id}" } + return [:skipped, nil] + end + + update_form(form, status, case_id) + log_update(form_uuid, status, case_id) + [:updated, nil] + end + + # If the form already has a case_id, match it to the specific Pega report. + # Otherwise fall back to the first report so case_id gets assigned. + def report_for(form, reports_by_case_id, fallback_report) + form.case_id.present? ? reports_by_case_id[form.case_id] : fallback_report + end + + def valid_reports?(reports) + reports.is_a?(Array) && reports.any? + end + + def extract_status(report) + STATUS_KEYS.each do |key| + status = report[key].presence + return status if status + end + + nil + end + + def update_form(form, status, case_id) + # rubocop:disable Rails/SkipsModelValidations + form.update_columns(pega_status: status, case_id:, updated_at: Time.current) + # rubocop:enable Rails/SkipsModelValidations + end + + def needs_update?(form, status, case_id) + form.pega_status != status || form.case_id != case_id + end + + # ────────────────────────────────────────────── + # Logging + # ────────────────────────────────────────────── + + def log_start(count) + Rails.logger.info "IVC Forms PollPegaStatusJob - Found #{count} form UUID(s) to poll" + end + + # Used for batch-level skips only (e.g., no reports returned from Pega). + # Per-record skips are aggregated by log_batch_skips instead. + def log_skip(form_uuid, reason) + Rails.logger.info "IVC Forms PollPegaStatusJob - Skipping form_uuid: #{form_uuid} - #{reason}" + :skipped + end + + def log_batch_skips(form_uuid, reasons) + summary = reasons.tally.map { |reason, count| "#{reason} (#{count})" }.join(', ') + Rails.logger.info "IVC Forms PollPegaStatusJob - Skipped records for form_uuid: #{form_uuid} - #{summary}" + end + + def log_update(form_uuid, status, case_id) + Rails.logger.info 'IVC Forms PollPegaStatusJob - Updated ' \ + "form_uuid: #{form_uuid}, status: #{status}, case_id: #{case_id}" + end + + def log_complete(results) + Rails.logger.info 'IVC Forms PollPegaStatusJob - Complete - ' \ + "updated: #{results[:updated]}, skipped: #{results[:skipped]}, errors: #{results[:error]}" + end + + def log_api_error(form_uuid, error) + Rails.logger.error 'IVC Forms PollPegaStatusJob - PegaApiError for ' \ + "form_uuid: #{form_uuid}, error: #{error.message}" + end + + def log_error(error) + Rails.logger.error 'IVC Forms PollPegaStatusJob Error', + message: error.message, + backtrace: error.backtrace&.first(10) + end + + # ────────────────────────────────────────────── + # Dependencies + # ────────────────────────────────────────────── + + def pega_api_client + @pega_api_client ||= IvcChampva::PegaApi::Client.new + end + end +end diff --git a/modules/ivc_champva/lib/pega_api/client.rb b/modules/ivc_champva/lib/pega_api/client.rb index db6067efbc0d..23f5a4c39893 100644 --- a/modules/ivc_champva/lib/pega_api/client.rb +++ b/modules/ivc_champva/lib/pega_api/client.rb @@ -61,6 +61,35 @@ def headers(date_start, date_end, case_id = '', uuid = '') } end + ## + # HTTP GET call to the Pega status API to retrieve the latest case statuses for a given UUID. + # The response is double-encoded: the outer body is JSON containing a `body` field that is + # itself a JSON-encoded array of case objects. + # + # @param uuid [String] the form UUID to look up + # + # @return [Array] the case rows, each containing 'PEGA Case ID', 'Status', 'UUID', etc. + def get_status_by_uuid(uuid) + resp = connection.get(config.status_path) do |req| + req.headers['Content-Type'] = 'application/json' + req.headers['x-api-key'] = Settings.ivc_champva.pega_api.api_key.to_s + req.headers['Uuid'] = uuid.to_s + end + + raise "response code: #{resp.status}, response body: #{resp.body}" unless resp.status == 200 + + # Outer envelope check — API returns HTTP 200 even on logical errors + outer = JSON.parse(resp.body, symbolize_names: false) + unless outer['statusCode'] == 200 + raise "alternate response code: #{outer['statusCode']}, response body: #{outer['body']}" + end + + # body is a stringified JSON array — requires a second parse + JSON.parse(outer['body']) + rescue => e + raise PegaApiError, e.message.to_s + end + ## # Checks if a provided IvcChampvaForm record has a corresponding PEGA report # diff --git a/modules/ivc_champva/lib/pega_api/configuration.rb b/modules/ivc_champva/lib/pega_api/configuration.rb index cb488ab629de..e59b37bc0967 100644 --- a/modules/ivc_champva/lib/pega_api/configuration.rb +++ b/modules/ivc_champva/lib/pega_api/configuration.rb @@ -10,6 +10,10 @@ def base_path Settings.ivc_champva.pega_api.base_path.to_s end + def status_path + Settings.ivc_champva.pega_api.status_path.to_s + end + def service_name 'PEGA_API' end diff --git a/modules/ivc_champva/spec/jobs/poll_pega_status_job_spec.rb b/modules/ivc_champva/spec/jobs/poll_pega_status_job_spec.rb new file mode 100644 index 000000000000..acffd93b4287 --- /dev/null +++ b/modules/ivc_champva/spec/jobs/poll_pega_status_job_spec.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pega_api/client' + +RSpec.describe IvcChampva::PollPegaStatusJob do + subject(:job) { described_class.new } + + let(:form_uuid) { SecureRandom.uuid } + let(:created_at) { 2.hours.ago } + + def pega_report(case_id:, status:, uuid: form_uuid) + { + 'PEGA Case ID' => case_id, + 'Doctype' => 'Application under 65', + 'Determination Type' => status, + 'Eligibity Date' => nil, + 'UUID' => uuid + } + end + + def stub_pega(reports:, uuid: form_uuid) + allow_any_instance_of(IvcChampva::PegaApi::Client) + .to receive(:get_status_by_uuid) + .with(uuid) + .and_return(reports) + end + + before { allow(Flipper).to receive(:enabled?).with(described_class::FEATURE_TOGGLE).and_return(true) } + + describe '#perform' do + context 'when the feature flag is disabled' do + before { allow(Flipper).to receive(:enabled?).with(described_class::FEATURE_TOGGLE).and_return(false) } + + it 'does not poll Pega and returns immediately' do + expect_any_instance_of(IvcChampva::PegaApi::Client).not_to receive(:get_status_by_uuid) + job.perform + end + end + + context 'when there are no forms to poll' do + it 'does not call Pega' do + expect_any_instance_of(IvcChampva::PegaApi::Client).not_to receive(:get_status_by_uuid) + job.perform + end + end + + context 'when a form has no submitted_by_icn' do + it 'still polls by form_uuid' do + create(:ivc_champva_form, form_uuid:, submitted_by_icn: nil, pega_status: nil, created_at:) + expect_any_instance_of(IvcChampva::PegaApi::Client) + .to receive(:get_status_by_uuid).with(form_uuid).and_return([]) + job.perform + end + end + + context 'when a form already has a complete/terminal pega_status' do + described_class::COMPLETE_STATUSES.each do |terminal_status| + it "does not poll forms with status '#{terminal_status}'" do + create(:ivc_champva_form, form_uuid:, + pega_status: terminal_status, created_at:) + expect_any_instance_of(IvcChampva::PegaApi::Client).not_to receive(:get_status_by_uuid) + job.perform + end + end + end + + context 'when a form has nil pega_status and no case_id (webhook never fired)' do + let!(:forms) do + create_list(:ivc_champva_form, 2, form_uuid:, + pega_status: nil, case_id: nil, created_at:) + end + + before do + stub_pega(reports: [pega_report(case_id: 'D-12345', status: 'Received')]) + end + + it 'updates pega_status on all records sharing the form_uuid' do + job.perform + forms.each { |f| expect(f.reload.pega_status).to eq('Received') } + end + + it 'assigns the case_id from the first report to all records' do + job.perform + forms.each { |f| expect(f.reload.case_id).to eq('D-12345') } + end + end + + context 'when a form already has a case_id' do + let!(:form) do + create(:ivc_champva_form, form_uuid:, + pega_status: 'Open', case_id: 'D-100017', created_at:) + end + + before do + stub_pega(reports: [ + pega_report(case_id: 'D-100018', status: 'Open'), + pega_report(case_id: 'D-100017', status: 'Processed') + ]) + end + + it 'updates using the report that matches the existing case_id, not the first report' do + job.perform + expect(form.reload.pega_status).to eq('Processed') + expect(form.reload.case_id).to eq('D-100017') + end + end + + context 'when the matching report has unchanged status and case_id' do + let!(:form) do + create(:ivc_champva_form, form_uuid:, + pega_status: 'Processed', case_id: 'D-100017', created_at:) + end + + before do + stub_pega(reports: [pega_report(case_id: 'D-100017', status: 'Processed')]) + end + + it 'does not write an update' do + original_updated_at = form.updated_at + sleep 0.01 + job.perform + expect(form.reload.updated_at).to eq(original_updated_at) + end + end + + context 'when a form has a case_id that no longer appears in the Pega response' do + let!(:form) do + create(:ivc_champva_form, form_uuid:, + pega_status: 'Open', case_id: 'D-GONE', created_at:) + end + + before do + stub_pega(reports: [pega_report(case_id: 'D-100018', status: 'Processed')]) + end + + it 'leaves pega_status unchanged' do + job.perform + expect(form.reload.pega_status).to eq('Open') + end + end + + context 'when a form has an in-progress status' do + %w[submitted Submitted Received Processed].each do |in_progress_status| + it "polls forms with status '#{in_progress_status}'" do + uuid = SecureRandom.uuid + form = create(:ivc_champva_form, form_uuid: uuid, + pega_status: in_progress_status, case_id: nil, created_at:) + stub_pega(uuid:, reports: [pega_report(case_id: 'D-new', status: 'Processed', uuid:)]) + job.perform + expect(form.reload.pega_status).to eq('Processed') + end + end + end + + context 'when Pega returns an empty array' do + let!(:form) { create(:ivc_champva_form, form_uuid:, pega_status: nil, created_at:) } + + before do + stub_pega(reports: []) + end + + it 'leaves pega_status unchanged' do + job.perform + expect(form.reload.pega_status).to be_nil + end + end + + context 'when Pega returns a report with a blank status' do + let!(:form) do + create(:ivc_champva_form, form_uuid:, + pega_status: nil, case_id: nil, created_at:) + end + + before do + stub_pega(reports: [pega_report(case_id: 'D-000', status: '')]) + end + + it 'leaves pega_status unchanged' do + job.perform + expect(form.reload.pega_status).to be_nil + end + end + + context 'when Pega returns the misspelled determination key' do + let!(:form) do + create(:ivc_champva_form, form_uuid:, + pega_status: nil, case_id: nil, created_at:) + end + + before do + stub_pega(reports: [{ + 'PEGA Case ID' => 'D-777', + 'Deternimation Type' => 'Processed', + 'UUID' => form_uuid + }]) + end + + it 'uses Deternimation Type as a fallback for status' do + job.perform + expect(form.reload.pega_status).to eq('Processed') + expect(form.reload.case_id).to eq('D-777') + end + end + + context 'when the Pega API raises a PegaApiError' do + let!(:form) { create(:ivc_champva_form, form_uuid:, pega_status: nil, created_at:) } + + before do + allow_any_instance_of(IvcChampva::PegaApi::Client) + .to receive(:get_status_by_uuid) + .and_raise(IvcChampva::PegaApi::PegaApiError, 'API timeout') + allow(Rails.logger).to receive(:error) + end + + it 'logs the error' do + job.perform + expect(Rails.logger).to have_received(:error).with(/PegaApiError.*API timeout/) + end + + it 'does not raise and continues to completion' do + expect { job.perform }.not_to raise_error + end + + it 'does not update pega_status' do + job.perform + expect(form.reload.pega_status).to be_nil + end + end + + context 'when multiple form_uuids are in different states' do + let(:uuid_pending) { SecureRandom.uuid } + let(:uuid_received) { SecureRandom.uuid } + let(:uuid_complete) { SecureRandom.uuid } + let!(:pending_form) do + create(:ivc_champva_form, form_uuid: uuid_pending, pega_status: nil, case_id: nil, created_at:) + end + let!(:received_form) do + create(:ivc_champva_form, form_uuid: uuid_received, pega_status: 'Received', case_id: nil, created_at:) + end + let!(:complete_form) do + create(:ivc_champva_form, form_uuid: uuid_complete, pega_status: 'eligible - issued a card', created_at:) + end + + before do + reports_by_uuid = { + uuid_pending => [pega_report(case_id: 'D-1', status: 'Processed', uuid: uuid_pending)], + uuid_received => [pega_report(case_id: 'D-2', status: 'Processed', uuid: uuid_received)] + } + allow_any_instance_of(IvcChampva::PegaApi::Client).to receive(:get_status_by_uuid) do |_, uuid| + reports_by_uuid[uuid] || [] + end + end + + it 'updates pending and in-progress forms' do + job.perform + expect(pending_form.reload.pega_status).to eq('Processed') + expect(received_form.reload.pega_status).to eq('Processed') + end + + it 'skips complete forms without updating their status' do + job.perform + expect(complete_form.reload.pega_status).to eq('eligible - issued a card') + end + end + end +end diff --git a/modules/ivc_champva/spec/lib/pega_api_client_spec.rb b/modules/ivc_champva/spec/lib/pega_api_client_spec.rb index a5ddca9e306c..c1308afaca24 100644 --- a/modules/ivc_champva/spec/lib/pega_api_client_spec.rb +++ b/modules/ivc_champva/spec/lib/pega_api_client_spec.rb @@ -120,6 +120,55 @@ end end + describe 'get_status_by_uuid' do + let(:uuid) { 'ea6ee9e7-1f56-4539-9c3c-173c43c4593c' } + + let(:mock_status_body) do + '{"statusCode": 200, "body": "[{\"PEGA Case ID\": \"D-100018\", \"Status\": \"Open\", ' \ + '\"Doctype\": \"OHI Certificate\", \"Deternimation Type\": \"Document Identification error\", ' \ + '\"Eligibity Date\": null, \"UUID\": \"ea6ee9e7-1f56-4539-9c3c-173c43c4593c\"}, {\"PEGA Case ID\": ' \ + '\"D-100017\", \"Status\": \"Open\", \"Doctype\": \"OHI Certificate\", \"Deternimation Type\": ' \ + '\"Document Identification error\", \"Eligibity Date\": null, ' \ + '\"UUID\": \"ea6ee9e7-1f56-4539-9c3c-173c43c4593c\"}, {\"PEGA Case ID\": \"D-99021\", \"Status\": \"Open\", ' \ + '\"Doctype\": \"Application under 65\", \"Deternimation Type\": ' \ + '\"Eligiblity denied/Additional information needed\", \"Eligibity Date\": \"20260226\", ' \ + '\"UUID\": \"ea6ee9e7-1f56-4539-9c3c-173c43c4593c\"}]"}' + end + + context 'when Pega returns a valid 200 envelope with stringified body array' do + let(:faraday_response) { double('Faraday::Response', status: 200, body: mock_status_body) } + + before do + allow_any_instance_of(Faraday::Connection).to receive(:get).with(anything).and_return(faraday_response) + end + + it 'returns parsed case rows with expected case IDs and statuses' do + result = subject.get_status_by_uuid(uuid) + + expect(result.size).to eq(3) + expect(result.map { |row| row['PEGA Case ID'] }).to eq(%w[D-100018 D-100017 D-99021]) + expect(result.map { |row| row['Deternimation Type'] }).to eq( + ['Document Identification error', 'Document Identification error', + 'Eligiblity denied/Additional information needed'] + ) + end + end + + context 'when Pega returns 200 HTTP but non-200 envelope statusCode' do + let(:faraday_response) do + double('Faraday::Response', status: 200, body: { 'statusCode' => 500, 'body' => 'boom' }.to_json) + end + + before do + allow_any_instance_of(Faraday::Connection).to receive(:get).with(anything).and_return(faraday_response) + end + + it 'raises a PegaApiError' do + expect { subject.get_status_by_uuid(uuid) }.to raise_error(IvcChampva::PegaApi::PegaApiError) + end + end + end + # Temporary, delete me # This test is used to hit the production endpoint when running locally. # It can be removed once we have some real code that uses the Pega API client.