From 16e7540e0985f475d756b97899aeb16a94bfdadb Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Thu, 5 Feb 2026 12:11:47 -0500 Subject: [PATCH 01/11] feat!: Create endpoint to kick off rwt results validation [#OCD-4757] --- .../chpl/filter/EnvironmentHeaderFilter.java | 17 ++++----- .../healthit/chpl/util/ServerEnvironment.java | 23 ++++++++++++ .../RealWorldTestingController.java | 35 ++++++++++++++++++- .../java/gov/healthit/chpl/FeatureList.java | 1 + 4 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java index 19c22c0138..28a29208ca 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java @@ -2,29 +2,30 @@ import java.io.IOException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import gov.healthit.chpl.util.ServerEnvironment; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + @Component public class EnvironmentHeaderFilter extends OncePerRequestFilter { - private String serverEnvironment; + private ServerEnvironment serverEnvironment; @Autowired public EnvironmentHeaderFilter(@Value("${server.environment}") String serverEnvironment) { - this.serverEnvironment = serverEnvironment != null ? serverEnvironment : ""; + this.serverEnvironment = serverEnvironment != null ? ServerEnvironment.getByName(serverEnvironment) : null; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (serverEnvironment.equalsIgnoreCase("production")) { + if (serverEnvironment.equals(ServerEnvironment.PRODUCITON)) { response.addHeader("Environment", "PRODUCTION"); } else { response.addHeader("Environment", "NON-PRODUCTION"); diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java new file mode 100644 index 0000000000..e000917dd9 --- /dev/null +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java @@ -0,0 +1,23 @@ +package gov.healthit.chpl.util; + +import java.util.stream.Stream; + +import lombok.Getter; + +public enum ServerEnvironment { + PRODUCITON("production"), + NON_PRODUCTION("non-production"); + + @Getter + private String name; + ServerEnvironment(String name) { + this.name = name; + } + + public static ServerEnvironment getByName(String envName) { + return Stream.of(ServerEnvironment.values()) + .filter(val -> val.getName().equalsIgnoreCase(envName)) + .findAny() + .orElse(null); + } +} diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java index e6742bf0a5..9ba7d22cb9 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java @@ -1,9 +1,13 @@ package gov.healthit.chpl.web.controller; +import org.apache.commons.lang3.NotImplementedException; +import org.ff4j.FF4j; import org.quartz.SchedulerException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; @@ -11,10 +15,13 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import gov.healthit.chpl.FeatureList; +import gov.healthit.chpl.domain.schedule.ChplOneTimeTrigger; import gov.healthit.chpl.exception.UserRetrievalException; import gov.healthit.chpl.exception.ValidationException; import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUploadResponse; import gov.healthit.chpl.realworldtesting.manager.RealWorldTestingManager; +import gov.healthit.chpl.util.ServerEnvironment; import gov.healthit.chpl.util.SwaggerSecurityRequirement; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -26,10 +33,16 @@ public class RealWorldTestingController { private RealWorldTestingManager realWorldTestingManager; + private FF4j ff4j; + private ServerEnvironment serverEnvironment; @Autowired - public RealWorldTestingController(RealWorldTestingManager realWorldTestingManager) { + public RealWorldTestingController(RealWorldTestingManager realWorldTestingManager, + FF4j ff4j, + @Value("${server.environment}") String serverEnvironment) { this.realWorldTestingManager = realWorldTestingManager; + this.ff4j = ff4j; + this.serverEnvironment = serverEnvironment != null ? ServerEnvironment.getByName(serverEnvironment) : null; } @Operation(summary = "Upload a file with real world testing data for certified products.", @@ -49,4 +62,24 @@ public RealWorldTestingController(RealWorldTestingManager realWorldTestingManage RealWorldTestingUploadResponse response = realWorldTestingManager.uploadRealWorldTestingCsv(file); return new ResponseEntity(response, HttpStatus.OK); } + + @Operation(summary = "Create and run a background job that fetches Real World Testing validation information " + + "about any URL. The validation is expecting an RWT Results URL. Validation data will be emailed to the " + + "logged-in user.", + security = { + @SecurityRequirement(name = SwaggerSecurityRequirement.API_KEY), + @SecurityRequirement(name = SwaggerSecurityRequirement.BEARER) + }) + @RequestMapping(value = "/validate-results-url", method = RequestMethod.POST) + public @ResponseBody ChplOneTimeTrigger createAiValidationJob(@RequestBody String url) { + if (!ff4j.check(FeatureList.RWT_AI_INTEGRATION) + || this.serverEnvironment == null + || !this.serverEnvironment.equals(ServerEnvironment.PRODUCITON)) { + throw new NotImplementedException("This method has not been implemented"); + } + //TODO + // write method to trigger one-time job + // also write job + return null; + } } diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/FeatureList.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/FeatureList.java index c4ba4417e5..e52eaacccd 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/FeatureList.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/FeatureList.java @@ -9,4 +9,5 @@ private FeatureList() { public static final String ONC_TO_ASTP_EMAIL = "onc-to-astp-email"; public static final String SERVICE_BASE_URL_LIST_CHANGE_REQUEST = "sbul-change-request"; public static final String RWT_CHANGE_REQUEST = "rwt-change-request"; + public static final String RWT_AI_INTEGRATION = "rwt-ai-integration"; } From ff7253194de16e60b49fa109de49b73216d6e6e7 Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Fri, 6 Feb 2026 12:44:51 -0500 Subject: [PATCH 02/11] feat: Add plumbing for RWT Validation job, loggers [#OCD-4757] --- .../RealWorldTestingController.java | 9 +- ...log4j2-xinclude-file-appenders-console.xml | 7 + .../log4j2-xinclude-file-appenders-local.xml | 12 ++ .../log4j2-xinclude-file-appenders.xml | 12 ++ .../log4j2-xinclude-loggers-local.xml | 3 + .../resources/log4j2-xinclude-loggers.xml | 4 + .../src/main/resources/jobs.xml | 16 ++ .../RealWorldTestingDomainPermissions.java | 6 +- .../ValidateUrlActionPermissions.java | 32 ++++ ...BackgroundJobTriggerActionPermissions.java | 2 + ...rldTestingResultsUrlValidationRequest.java | 14 ++ .../domain/RealWorldTestingUrlType.java | 6 + .../manager/RealWorldTestingManager.java | 38 +++++ .../RealWorldTestingUrlValidationJob.java | 147 ++++++++++++++++++ 14 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/realworldtesting/ValidateUrlActionPermissions.java create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingResultsUrlValidationRequest.java create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingUrlType.java create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java index 9ba7d22cb9..740fdd627e 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java @@ -19,6 +19,7 @@ import gov.healthit.chpl.domain.schedule.ChplOneTimeTrigger; import gov.healthit.chpl.exception.UserRetrievalException; import gov.healthit.chpl.exception.ValidationException; +import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingResultsUrlValidationRequest; import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUploadResponse; import gov.healthit.chpl.realworldtesting.manager.RealWorldTestingManager; import gov.healthit.chpl.util.ServerEnvironment; @@ -71,15 +72,13 @@ public RealWorldTestingController(RealWorldTestingManager realWorldTestingManage @SecurityRequirement(name = SwaggerSecurityRequirement.BEARER) }) @RequestMapping(value = "/validate-results-url", method = RequestMethod.POST) - public @ResponseBody ChplOneTimeTrigger createAiValidationJob(@RequestBody String url) { + public @ResponseBody ChplOneTimeTrigger createAiValidationJob(@RequestBody RealWorldTestingResultsUrlValidationRequest request) + throws UserRetrievalException, SchedulerException, ValidationException { if (!ff4j.check(FeatureList.RWT_AI_INTEGRATION) || this.serverEnvironment == null || !this.serverEnvironment.equals(ServerEnvironment.PRODUCITON)) { throw new NotImplementedException("This method has not been implemented"); } - //TODO - // write method to trigger one-time job - // also write job - return null; + return realWorldTestingManager.validateResultsUrlAsBackgroundJob(request); } } diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml index cde8a02ea4..d087955169 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml @@ -211,6 +211,13 @@ + + + + + + + diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml index 7d13b3d90c..fed557d0ee 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml @@ -383,6 +383,18 @@ interval="1" modulate="true" /> + + + %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %X{dd.trace_id} %X{dd.span_id} - %m%n + + + + + + + + %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %X{dd.trace_id} %X{dd.span_id} - %m%n + + + + + + + + diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml index b1d7009f61..f97a3d35c9 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml @@ -120,6 +120,10 @@ + + + + diff --git a/chpl/chpl-resources/src/main/resources/jobs.xml b/chpl/chpl-resources/src/main/resources/jobs.xml index ad268be7b9..4ec3e6d6d8 100644 --- a/chpl/chpl-resources/src/main/resources/jobs.xml +++ b/chpl/chpl-resources/src/main/resources/jobs.xml @@ -417,6 +417,22 @@ + + + realWorldTestingUrlValidationJob + chplBackgroundJobs + Requests validation of an RWT Results URL from an external source. An email is sent to the user with validation results. + gov.healthit.chpl.scheduler.job.realworldtesting.RealWorldTestingUrlValidationJob + true + false + + + authorities + chpl-admin;chpl-onc;chpl-onc-acb + + + + Overnight Broken Surveillance Rules Report diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/RealWorldTestingDomainPermissions.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/RealWorldTestingDomainPermissions.java index d2cf35ac46..b5fa314c25 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/RealWorldTestingDomainPermissions.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/RealWorldTestingDomainPermissions.java @@ -6,15 +6,19 @@ import org.springframework.stereotype.Component; import gov.healthit.chpl.permissions.domains.realworldtesting.UploadActionPermissions; +import gov.healthit.chpl.permissions.domains.realworldtesting.ValidateUrlActionPermissions; @Component public class RealWorldTestingDomainPermissions extends DomainPermissions { public static final String UPLOAD = "UPLOAD"; + public static final String VALIDATE_URL = "VALIDATE_URL"; @Autowired public RealWorldTestingDomainPermissions( - @Qualifier("realWorldTestingUploadActionPermissions") UploadActionPermissions uploadActionPermissions) { + @Qualifier("realWorldTestingUploadActionPermissions") UploadActionPermissions uploadActionPermissions, + @Qualifier("realWorldTestingValidateUrlActionPermissions") ValidateUrlActionPermissions validateUrlActionPermissions) { getActionPermissions().put(UPLOAD, uploadActionPermissions); + getActionPermissions().put(VALIDATE_URL, validateUrlActionPermissions); } } diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/realworldtesting/ValidateUrlActionPermissions.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/realworldtesting/ValidateUrlActionPermissions.java new file mode 100644 index 0000000000..b9841b3580 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/realworldtesting/ValidateUrlActionPermissions.java @@ -0,0 +1,32 @@ +package gov.healthit.chpl.permissions.domains.realworldtesting; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import gov.healthit.chpl.changerequest.dao.DeveloperCertificationBodyMapDAO; +import gov.healthit.chpl.dao.CertifiedProductDAO; +import gov.healthit.chpl.permissions.ResourcePermissionsFactory; +import gov.healthit.chpl.permissions.domains.ActionPermissions; + +@Component("realWorldTestingValidateUrlActionPermissions") +public class ValidateUrlActionPermissions extends ActionPermissions { + + @Autowired + public ValidateUrlActionPermissions(ResourcePermissionsFactory resourcePermissionsFactory, + CertifiedProductDAO certifiedProductDao, + DeveloperCertificationBodyMapDAO developerCertificationBodyMapDao) { + super(resourcePermissionsFactory, certifiedProductDao, developerCertificationBodyMapDao); + } + + @Override + public boolean hasAccess() { + return getResourcePermissions().isUserRoleAdmin() + || getResourcePermissions().isUserRoleOnc() + || getResourcePermissions().isUserRoleAcbAdmin(); + } + + @Override + public boolean hasAccess(final Object obj) { + return false; + } +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/scheduler/CreateBackgroundJobTriggerActionPermissions.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/scheduler/CreateBackgroundJobTriggerActionPermissions.java index 8e0d8661c1..5613391483 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/scheduler/CreateBackgroundJobTriggerActionPermissions.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/permissions/domains/scheduler/CreateBackgroundJobTriggerActionPermissions.java @@ -19,6 +19,7 @@ import gov.healthit.chpl.scheduler.job.certificationId.CertificationIdEmailJob; import gov.healthit.chpl.scheduler.job.certificationStatus.UpdateCurrentCertificationStatusJob; import gov.healthit.chpl.scheduler.job.changerequest.ChangeRequestReportEmailJob; +import gov.healthit.chpl.scheduler.job.realworldtesting.RealWorldTestingUrlValidationJob; import gov.healthit.chpl.scheduler.job.surveillanceReport.AnnualReportGenerationJob; import gov.healthit.chpl.scheduler.job.surveillanceReport.QuarterlyReportGenerationJob; @@ -44,6 +45,7 @@ public CreateBackgroundJobTriggerActionPermissions(ResourcePermissionsFactory re BACKGROUND_JOBS_ACB_CAN_CREATE.add(ChangeRequestReportEmailJob.JOB_NAME); BACKGROUND_JOBS_ACB_CAN_CREATE.add(UpdateCurrentCertificationStatusJob.JOB_NAME); BACKGROUND_JOBS_ACB_CAN_CREATE.add(CognitoUserCacheRefreshJob.JOB_NAME); + BACKGROUND_JOBS_ACB_CAN_CREATE.add(RealWorldTestingUrlValidationJob.JOB_NAME); BACKGROUND_JOBS_CMS_STAFF_CAN_CREATE.add(CertificationIdEmailJob.JOB_NAME); diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingResultsUrlValidationRequest.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingResultsUrlValidationRequest.java new file mode 100644 index 0000000000..1f1e35a1f5 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingResultsUrlValidationRequest.java @@ -0,0 +1,14 @@ +package gov.healthit.chpl.realworldtesting.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class RealWorldTestingResultsUrlValidationRequest { + private Long listingId; + private String url; + private Integer year; +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingUrlType.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingUrlType.java new file mode 100644 index 0000000000..6cd995270d --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/domain/RealWorldTestingUrlType.java @@ -0,0 +1,6 @@ +package gov.healthit.chpl.realworldtesting.domain; + +public enum RealWorldTestingUrlType { + PLANS, + RESULTS; +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/manager/RealWorldTestingManager.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/manager/RealWorldTestingManager.java index f3439d54f0..780a34a517 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/manager/RealWorldTestingManager.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/realworldtesting/manager/RealWorldTestingManager.java @@ -29,11 +29,14 @@ import gov.healthit.chpl.exception.ValidationException; import gov.healthit.chpl.manager.SchedulerManager; import gov.healthit.chpl.realworldtesting.dao.RealWorldTestingByDeveloperDao; +import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingResultsUrlValidationRequest; import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingType; import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUpload; import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUploadResponse; import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUrlByDeveloper; +import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUrlType; import gov.healthit.chpl.scheduler.job.RealWorldTestingUploadJob; +import gov.healthit.chpl.scheduler.job.realworldtesting.RealWorldTestingUrlValidationJob; import gov.healthit.chpl.util.AuthUtil; import gov.healthit.chpl.util.ErrorMessageUtil; @@ -85,6 +88,41 @@ public RealWorldTestingUploadResponse uploadRealWorldTestingCsv(MultipartFile fi return response; } + @Transactional + @PreAuthorize("@permissions.hasAccess(T(gov.healthit.chpl.permissions.Permissions).REAL_WORLD_TESTING, " + + "T(gov.healthit.chpl.permissions.domains.RealWorldTestingDomainPermissions).VALIDATE_URL)") + public ChplOneTimeTrigger validateResultsUrlAsBackgroundJob(RealWorldTestingResultsUrlValidationRequest request) + throws ValidationException, SchedulerException, UserRetrievalException { + validateResultsUrlRequest(request); + + ChplOneTimeTrigger validateUrlReportTrigger = new ChplOneTimeTrigger(); + ChplJob validateUrlReportJob = new ChplJob(); + validateUrlReportJob.setName(RealWorldTestingUrlValidationJob.JOB_NAME); + validateUrlReportJob.setGroup(SchedulerManager.CHPL_BACKGROUND_JOBS_KEY); + JobDataMap jobDataMap = new JobDataMap(); + jobDataMap.put(RealWorldTestingUrlValidationJob.LISTING_ID_KEY, request.getListingId()); + jobDataMap.put(RealWorldTestingUrlValidationJob.URL_KEY, request.getUrl()); + jobDataMap.put(RealWorldTestingUrlValidationJob.URL_TYPE_KEY, RealWorldTestingUrlType.RESULTS.name()); + //default to last year for now if year is not provided + jobDataMap.put(RealWorldTestingUrlValidationJob.YEAR_KEY, request.getYear() == null ? LocalDate.now().minusYears(1).getYear() : request.getYear()); + jobDataMap.put(RealWorldTestingUrlValidationJob.USER_KEY, AuthUtil.getCurrentUser()); + validateUrlReportJob.setJobDataMap(jobDataMap); + validateUrlReportTrigger.setJob(validateUrlReportJob); + validateUrlReportTrigger.setRunDateMillis(System.currentTimeMillis() + SchedulerManager.FIVE_SECONDS_IN_MILLIS); + validateUrlReportTrigger = schedulerManager.createBackgroundJobTrigger(validateUrlReportTrigger); + + return validateUrlReportTrigger; + } + + private void validateResultsUrlRequest(RealWorldTestingResultsUrlValidationRequest request) throws ValidationException { + if (StringUtils.isEmpty(request.getUrl())) { + throw new ValidationException(List.of("A url must be provided.")); + } + if (request.getListingId() == null || request.getListingId() < 0) { + throw new ValidationException(List.of("A valid CHPL listing ID must be provided.")); + } + } + private ChplOneTimeTrigger startRwtUploadJob(List rwts) throws SchedulerException, ValidationException, UserRetrievalException { diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java new file mode 100644 index 0000000000..41d88d641c --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java @@ -0,0 +1,147 @@ +package gov.healthit.chpl.scheduler.job.realworldtesting; + +import org.apache.commons.lang3.StringUtils; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.web.context.support.SpringBeanAutowiringSupport; + +import gov.healthit.chpl.auth.user.JWTAuthenticatedUser; +import gov.healthit.chpl.email.ChplEmailFactory; +import gov.healthit.chpl.email.ChplHtmlEmailBuilder; +import gov.healthit.chpl.email.footer.AdminFooter; +import gov.healthit.chpl.exception.EmailNotSentException; +import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUrlType; +import gov.healthit.chpl.scheduler.job.QuartzJob; +import gov.healthit.chpl.util.ErrorMessageUtil; +import lombok.extern.log4j.Log4j2; + +@DisallowConcurrentExecution +@Log4j2(topic = "realWorldTestingUrlValidationJobLogger") +public class RealWorldTestingUrlValidationJob extends QuartzJob { + public static final String JOB_NAME = "realWorldTestingUrlValidationJob"; + public static final String LISTING_ID_KEY = "listingId"; + public static final String URL_KEY = "url"; + public static final String URL_TYPE_KEY = "urlType"; + public static final String YEAR_KEY = "year"; + public static final String USER_KEY = "user"; + + @Autowired + private ChplHtmlEmailBuilder chplHtmlEmailBuilder; + + @Value("${chpl.email.valediction}") + private String chplEmailValediction; + + @Value("${contact.acbatlUrl}") + private String acbatlFeedbackUrl; + + @Value("${surveillance.quarterlyReport.success.subject}") + private String quarterlyReportSubject; + + @Value("${surveillance.quarterlyReport.failure.subject}") + private String quarterlyReportFailureSubject; + + @Autowired + private ErrorMessageUtil msgUtil; + + @Autowired + private Environment env; + + @Autowired + private ChplEmailFactory chplEmailFactory; + + @Override + public void execute(JobExecutionContext jobContext) throws JobExecutionException { + SpringBeanAutowiringSupport.processInjectionBasedOnCurrentContext(this); + + LOGGER.info("********* Starting the Real World Testing Url Validation job. *********"); + + JobDataMap jobDataMap = jobContext.getMergedJobDataMap(); + boolean isJobDataValid = isJobDataValid(jobDataMap); + if (isJobDataValid) { + JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); + Long listingId = (Long) jobDataMap.get(LISTING_ID_KEY); + String url = (String) jobDataMap.get(URL_KEY); + RealWorldTestingUrlType urlType = RealWorldTestingUrlType.valueOf((String) jobDataMap.get(URL_TYPE_KEY)); + Integer year = (Integer) jobDataMap.get(YEAR_KEY); + setSecurityContext(user); + + //TODO ensure URL type is RESULTS + //TODO call AI endpoint, get response or handle error + //TODO parse results and send email + sendEmail(user.getEmail(), quarterlyReportFailureSubject, + env.getProperty("surveillance.quarterlyReport.fileError.htmlBody")); + + } else { + JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); + if (user != null && user.getEmail() != null) { + sendEmail(user.getEmail(), quarterlyReportFailureSubject, + env.getProperty("surveillance.quarterlyReport.badJobData.htmlBody")); + } + } + LOGGER.info("********* Completed the Real World Testing Url Validation job. *********"); + } + + private boolean isJobDataValid(JobDataMap jobDataMap) { + boolean isValid = true; + JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); + if (user == null) { + isValid = false; + LOGGER.fatal("No user could be found in the job data."); + } + + Long listingId = (Long) jobDataMap.get(LISTING_ID_KEY); + if (listingId == null) { + isValid = false; + LOGGER.fatal("No listing ID could be found in the job data."); + } + + String url = (String) jobDataMap.get(URL_KEY); + if (StringUtils.isEmpty(url)) { + isValid = false; + LOGGER.fatal("No URL could be found in the job data."); + } + + String urlType = (String) jobDataMap.get(URL_TYPE_KEY); + if (StringUtils.isEmpty(urlType)) { + isValid = false; + LOGGER.fatal("No URL Type could be found in the job data."); + } else { + RealWorldTestingUrlType urlTypeEnum = RealWorldTestingUrlType.valueOf(urlType); + if (urlTypeEnum == null || !urlTypeEnum.equals(RealWorldTestingUrlType.RESULTS)) { + isValid = false; + LOGGER.fatal("URL Type " + urlType + " is not recognized or supported."); + } + } + + String year = (String) jobDataMap.get(YEAR_KEY); + if (StringUtils.isEmpty(year)) { + isValid = false; + LOGGER.fatal("No year could be found in the job data."); + } + return isValid; + } + + private void sendEmail(String recipientEmail, String subject, String htmlContent) { + LOGGER.info("Sending email to: " + recipientEmail); + + try { + chplEmailFactory.emailBuilder() + .recipient(recipientEmail) + .subject(subject) + .htmlMessage(chplHtmlEmailBuilder.initialize() + .heading(subject) + .paragraph("", htmlContent) + .paragraph("", String.format(chplEmailValediction, acbatlFeedbackUrl)) + .footer(AdminFooter.class) + .build()) + .sendEmail(); + } catch (EmailNotSentException ex) { + LOGGER.error("Could not send email to " + recipientEmail, ex); + } + } +} From 953e8ea35a47e02518f56fc4e3f35be2ac005b4d Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Tue, 10 Feb 2026 10:56:14 -0500 Subject: [PATCH 03/11] feat: Authenticate with ASTP-AI Cognito API and get access token [#OCD-4757] --- .../chpl/ApiExceptionControllerAdvice.java | 9 +++ .../RealWorldTestingController.java | 20 +++++ .../src/main/resources/environment.properties | 8 ++ .../gov/healthit/chpl/CHPLServiceConfig.java | 21 ++++++ .../chpl/astpai/AmazonTokenResponse.java | 31 ++++++++ .../astpai/AstpAiAuthenticationService.java | 73 +++++++++++++++++++ .../chpl/astpai/AstpAiQueryService.java | 9 +++ .../astpai/AstpAiRequestFailedException.java | 44 +++++++++++ .../RealWorldTestingUrlValidationJob.java | 42 ++++++++++- 9 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AmazonTokenResponse.java create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiAuthenticationService.java create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiRequestFailedException.java diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/ApiExceptionControllerAdvice.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/ApiExceptionControllerAdvice.java index bb6425546e..1b0a294f73 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/ApiExceptionControllerAdvice.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/ApiExceptionControllerAdvice.java @@ -19,6 +19,7 @@ import com.datadog.api.client.ApiException; +import gov.healthit.chpl.astpai.AstpAiRequestFailedException; import gov.healthit.chpl.auth.ChplAccountEmailNotConfirmedException; import gov.healthit.chpl.domain.error.ErrorResponse; import gov.healthit.chpl.domain.error.ObjectMissingValidationErrorResponse; @@ -77,6 +78,14 @@ public ResponseEntity exception(JiraRequestFailedException e) { HttpStatus.NO_CONTENT); } + @ExceptionHandler(AstpAiRequestFailedException.class) + public ResponseEntity exception(AstpAiRequestFailedException e) { + LOGGER.error(e.getMessage()); + return new ResponseEntity( + new ErrorResponse("ASTP-AI information is not currently available, please check back later."), + HttpStatus.NO_CONTENT); + } + @ExceptionHandler(InsightRequestFailedException.class) public ResponseEntity exception(InsightRequestFailedException e) { LOGGER.error(e.getMessage()); diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java index 740fdd627e..9d60233dcd 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java @@ -16,6 +16,9 @@ import org.springframework.web.multipart.MultipartFile; import gov.healthit.chpl.FeatureList; +import gov.healthit.chpl.astpai.AmazonTokenResponse; +import gov.healthit.chpl.astpai.AstpAiAuthenticationService; +import gov.healthit.chpl.astpai.AstpAiRequestFailedException; import gov.healthit.chpl.domain.schedule.ChplOneTimeTrigger; import gov.healthit.chpl.exception.UserRetrievalException; import gov.healthit.chpl.exception.ValidationException; @@ -33,15 +36,18 @@ @RequestMapping("/real-world-testing") public class RealWorldTestingController { + private AstpAiAuthenticationService authService; private RealWorldTestingManager realWorldTestingManager; private FF4j ff4j; private ServerEnvironment serverEnvironment; @Autowired public RealWorldTestingController(RealWorldTestingManager realWorldTestingManager, + AstpAiAuthenticationService authService, FF4j ff4j, @Value("${server.environment}") String serverEnvironment) { this.realWorldTestingManager = realWorldTestingManager; + this.authService = authService; this.ff4j = ff4j; this.serverEnvironment = serverEnvironment != null ? ServerEnvironment.getByName(serverEnvironment) : null; } @@ -81,4 +87,18 @@ public RealWorldTestingController(RealWorldTestingManager realWorldTestingManage } return realWorldTestingManager.validateResultsUrlAsBackgroundJob(request); } + + @Operation(summary = "Create and run a background job that fetches Real World Testing validation information " + + "about any URL. The validation is expecting an RWT Results URL. Validation data will be emailed to the " + + "logged-in user.", + security = { + @SecurityRequirement(name = SwaggerSecurityRequirement.API_KEY), + @SecurityRequirement(name = SwaggerSecurityRequirement.BEARER) + }) + @RequestMapping(value = "/astp-ai-auth", method = RequestMethod.POST) + public @ResponseBody AmazonTokenResponse authenticateToAspAi(@RequestBody RealWorldTestingResultsUrlValidationRequest request) + throws AstpAiRequestFailedException { + + return authService.authenticate(); + } } diff --git a/chpl/chpl-resources/src/main/resources/environment.properties b/chpl/chpl-resources/src/main/resources/environment.properties index 37051fd27f..97870a9e83 100644 --- a/chpl/chpl-resources/src/main/resources/environment.properties +++ b/chpl/chpl-resources/src/main/resources/environment.properties @@ -119,6 +119,14 @@ jira.nonconformityUrl=/search/?maxResults=100&jql=project="Review for Signals/Di insight.submissionsUrl=https://healthit-gov-develop.go-vip.net/wp-json/data-dashboard/v1/developers/%s/submissions ################################################### +############ ASTP-AI CONNECTION PROPERTIES ########### +astpai.authenticate.url=https://us-east-1zmkmlezba.auth.us-east-1.amazoncognito.com/oauth2/token +astpai.authenticate.clientSecret=SECRET +astpai.authenticate.clientId=SECRET +astpai.domain=https://astp-dev.ainq.ai/api/ +astpai.rwtResultUrlValidation.endpoint=rwt-validations/from-url +################################################### + ###### CHPL-SERVICE DOWNLOAD JAR PROPERTIES ###### dataSourceName=java:/comp/env/jdbc/openchpl ################################################### diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java index 10812311c0..1354011890 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java @@ -277,6 +277,27 @@ public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttp return restTemplate; } + @Bean + public RestTemplate httpsRestTemplate() + throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException { + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultSocketConfig(SocketConfig.custom() + .setSoTimeout(getRequestTimeout(), TimeUnit.MILLISECONDS) + .build()) + .setTlsSocketStrategy(new DefaultClientTlsStrategy( + SSLContexts.custom().loadTrustMaterial(TrustAllStrategy.INSTANCE).build(), + NoopHostnameVerifier.INSTANCE)) + .build()) + .build(); + + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); + requestFactory.setHttpClient(httpClient); + requestFactory.setConnectionRequestTimeout(getRequestTimeout()); + + return new RestTemplate(requestFactory); + } + private int getRequestTimeout() { int requestTimeout = DEFAULT_REQUEST_TIMEOUT; String requestTimeoutProperty = env.getProperty("jira.requestTimeoutMillis"); diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AmazonTokenResponse.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AmazonTokenResponse.java new file mode 100644 index 0000000000..fbe8b9c1c1 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AmazonTokenResponse.java @@ -0,0 +1,31 @@ +package gov.healthit.chpl.astpai; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public class AmazonTokenResponse { + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("id_token") + private String idToken; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Integer expiresIn; +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiAuthenticationService.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiAuthenticationService.java new file mode 100644 index 0000000000..f0f6f58f96 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiAuthenticationService.java @@ -0,0 +1,73 @@ +package gov.healthit.chpl.astpai; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import lombok.extern.log4j.Log4j2; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +@Log4j2 +@Service +public class AstpAiAuthenticationService { + + private RestTemplate httpsRestTemplate; + private String authenticationUrl; + private String authenticationRequestBody; + private JsonMapper jsonMapper; + + @Autowired + public AstpAiAuthenticationService(RestTemplate httpsRestTemplate, + JsonMapper jsonMapper, + @Value("${astpai.authenticate.url}") String authenticationUrl, + @Value("${astpai.authenticate.clientSecret}") String authenticationClientSecret, + @Value("${astpai.authenticate.clientId}") String authenticationClientId) { + this.httpsRestTemplate = httpsRestTemplate; + this.jsonMapper = jsonMapper; + this.authenticationUrl = authenticationUrl; + this.authenticationRequestBody = String.format("grant_type=client_credentials&scope=default-m2m-resource-server-p3thsy/read&client_id=%s&client_secret=%s", authenticationClientId, authenticationClientSecret); + } + + public AmazonTokenResponse authenticate() throws AstpAiRequestFailedException { + LOGGER.info("Making request to " + authenticationUrl); + ResponseEntity response = null; + try { + LOGGER.debug("Request body:" + authenticationRequestBody); + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded"); + headers.add("Accept", "application/json"); + headers.add("Accept-Encoding", "UTF-8"); + HttpEntity entity = new HttpEntity<>(authenticationRequestBody, headers); + + response = httpsRestTemplate.exchange(authenticationUrl, HttpMethod.POST, entity, String.class); + LOGGER.debug("Response: " + response.getBody()); + } catch (HttpClientErrorException httpEx) { + LOGGER.error("Unable to authenticate with the URL " + authenticationUrl + ". Message: " + httpEx.getMessage() + "; response status code " + httpEx.getStatusCode()); + throw new AstpAiRequestFailedException(httpEx.getMessage(), httpEx, httpEx.getStatusCode()); + } catch (Exception ex) { + HttpStatusCode statusCode = (response != null && response.getStatusCode() != null + ? response.getStatusCode() : HttpStatusCode.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value())); + LOGGER.error("Unable to authenticate with the URL " + authenticationUrl + ". Message: " + ex.getMessage() + "; response status code " + statusCode); + throw new AstpAiRequestFailedException(ex.getMessage(), ex, statusCode); + } + + String responseBody = response == null ? "" : response.getBody(); + AmazonTokenResponse token = null; + try { + token = jsonMapper.readValue(responseBody, AmazonTokenResponse.class); + } catch (JacksonException ex) { + LOGGER.error("Unable to read the response body as our custom AmazonTokenResponse", ex); + throw new AstpAiRequestFailedException(ex.getMessage(), ex); + } + return token; + } +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java new file mode 100644 index 0000000000..9e7240b338 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java @@ -0,0 +1,9 @@ +package gov.healthit.chpl.astpai; + +import org.springframework.stereotype.Service; + +@Service +public class AstpAiQueryService { + + //TODO methods to query the AI tool +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiRequestFailedException.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiRequestFailedException.java new file mode 100644 index 0000000000..4292845b49 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiRequestFailedException.java @@ -0,0 +1,44 @@ +package gov.healthit.chpl.astpai; + +import java.io.IOException; + +import org.springframework.http.HttpStatusCode; + +import lombok.Data; + +@Data +public class AstpAiRequestFailedException extends IOException { + private static final long serialVersionUID = 3861221517156321545L; + private HttpStatusCode statusCode; + + public AstpAiRequestFailedException() { + super(); + } + + public AstpAiRequestFailedException(String message) { + super(message); + } + + public AstpAiRequestFailedException(String message, HttpStatusCode statusCode) { + super(message); + this.statusCode = statusCode; + } + + public AstpAiRequestFailedException(String message, Throwable cause) { + super(message, cause); + } + + public AstpAiRequestFailedException(String message, Throwable cause, HttpStatusCode statusCode) { + super(message, cause); + this.statusCode = statusCode; + } + + public AstpAiRequestFailedException(Throwable cause) { + super(cause); + } + + public AstpAiRequestFailedException(Throwable cause, HttpStatusCode statusCode) { + super(cause); + this.statusCode = statusCode; + } +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java index 41d88d641c..be8eb61a08 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java @@ -10,6 +10,9 @@ import org.springframework.core.env.Environment; import org.springframework.web.context.support.SpringBeanAutowiringSupport; +import gov.healthit.chpl.astpai.AmazonTokenResponse; +import gov.healthit.chpl.astpai.AstpAiAuthenticationService; +import gov.healthit.chpl.astpai.AstpAiRequestFailedException; import gov.healthit.chpl.auth.user.JWTAuthenticatedUser; import gov.healthit.chpl.email.ChplEmailFactory; import gov.healthit.chpl.email.ChplHtmlEmailBuilder; @@ -45,6 +48,9 @@ public class RealWorldTestingUrlValidationJob extends QuartzJob { @Value("${surveillance.quarterlyReport.failure.subject}") private String quarterlyReportFailureSubject; + @Autowired + private AstpAiAuthenticationService astpAiService; + @Autowired private ErrorMessageUtil msgUtil; @@ -70,16 +76,25 @@ public void execute(JobExecutionContext jobContext) throws JobExecutionException Integer year = (Integer) jobDataMap.get(YEAR_KEY); setSecurityContext(user); - //TODO ensure URL type is RESULTS + //authenticate + AmazonTokenResponse token = null; + try { + token = astpAiService.authenticate(); + } catch (AstpAiRequestFailedException ex) { + LOGGER.error("Unable to authenticate with ASTP-AI", ex); + sendErrorEmail(user.getEmail(), quarterlyReportFailureSubject, + env.getProperty("surveillance.quarterlyReport.badJobData.htmlBody")); + } //TODO call AI endpoint, get response or handle error + //TODO parse results and send email - sendEmail(user.getEmail(), quarterlyReportFailureSubject, + sendResultsEmail(user.getEmail(), quarterlyReportFailureSubject, env.getProperty("surveillance.quarterlyReport.fileError.htmlBody")); } else { JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); if (user != null && user.getEmail() != null) { - sendEmail(user.getEmail(), quarterlyReportFailureSubject, + sendErrorEmail(user.getEmail(), quarterlyReportFailureSubject, env.getProperty("surveillance.quarterlyReport.badJobData.htmlBody")); } } @@ -126,7 +141,26 @@ private boolean isJobDataValid(JobDataMap jobDataMap) { return isValid; } - private void sendEmail(String recipientEmail, String subject, String htmlContent) { + private void sendResultsEmail(String recipientEmail, String subject, String htmlContent) { + LOGGER.info("Sending email to: " + recipientEmail); + + try { + chplEmailFactory.emailBuilder() + .recipient(recipientEmail) + .subject(subject) + .htmlMessage(chplHtmlEmailBuilder.initialize() + .heading(subject) + .paragraph("", htmlContent) + .paragraph("", String.format(chplEmailValediction, acbatlFeedbackUrl)) + .footer(AdminFooter.class) + .build()) + .sendEmail(); + } catch (EmailNotSentException ex) { + LOGGER.error("Could not send email to " + recipientEmail, ex); + } + } + + private void sendErrorEmail(String recipientEmail, String subject, String htmlContent) { LOGGER.info("Sending email to: " + recipientEmail); try { From e2da7ce8cdf80d0ea4b16c80dc6a76b97477c6ca Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Tue, 10 Feb 2026 14:43:08 -0500 Subject: [PATCH 04/11] feat: Query AIA endpoint and email user the raw results [#OCD-4757] --- .../RealWorldTestingController.java | 22 +--- .../src/main/resources/email.properties | 10 ++ .../src/main/resources/environment.properties | 5 +- .../gov/healthit/chpl/CHPLServiceConfig.java | 26 +++- .../chpl/astpai/AstpAiQueryService.java | 50 +++++++- .../chpl/astpai/UrlValidationRequest.java | 26 ++++ .../RealWorldTestingUrlValidationJob.java | 117 +++++++++++++----- 7 files changed, 194 insertions(+), 62 deletions(-) create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationRequest.java diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java index 9d60233dcd..4a598c77bc 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java @@ -16,9 +16,6 @@ import org.springframework.web.multipart.MultipartFile; import gov.healthit.chpl.FeatureList; -import gov.healthit.chpl.astpai.AmazonTokenResponse; -import gov.healthit.chpl.astpai.AstpAiAuthenticationService; -import gov.healthit.chpl.astpai.AstpAiRequestFailedException; import gov.healthit.chpl.domain.schedule.ChplOneTimeTrigger; import gov.healthit.chpl.exception.UserRetrievalException; import gov.healthit.chpl.exception.ValidationException; @@ -36,18 +33,15 @@ @RequestMapping("/real-world-testing") public class RealWorldTestingController { - private AstpAiAuthenticationService authService; private RealWorldTestingManager realWorldTestingManager; private FF4j ff4j; private ServerEnvironment serverEnvironment; @Autowired public RealWorldTestingController(RealWorldTestingManager realWorldTestingManager, - AstpAiAuthenticationService authService, FF4j ff4j, @Value("${server.environment}") String serverEnvironment) { this.realWorldTestingManager = realWorldTestingManager; - this.authService = authService; this.ff4j = ff4j; this.serverEnvironment = serverEnvironment != null ? ServerEnvironment.getByName(serverEnvironment) : null; } @@ -82,23 +76,9 @@ public RealWorldTestingController(RealWorldTestingManager realWorldTestingManage throws UserRetrievalException, SchedulerException, ValidationException { if (!ff4j.check(FeatureList.RWT_AI_INTEGRATION) || this.serverEnvironment == null - || !this.serverEnvironment.equals(ServerEnvironment.PRODUCITON)) { + || this.serverEnvironment.equals(ServerEnvironment.PRODUCITON)) { throw new NotImplementedException("This method has not been implemented"); } return realWorldTestingManager.validateResultsUrlAsBackgroundJob(request); } - - @Operation(summary = "Create and run a background job that fetches Real World Testing validation information " - + "about any URL. The validation is expecting an RWT Results URL. Validation data will be emailed to the " - + "logged-in user.", - security = { - @SecurityRequirement(name = SwaggerSecurityRequirement.API_KEY), - @SecurityRequirement(name = SwaggerSecurityRequirement.BEARER) - }) - @RequestMapping(value = "/astp-ai-auth", method = RequestMethod.POST) - public @ResponseBody AmazonTokenResponse authenticateToAspAi(@RequestBody RealWorldTestingResultsUrlValidationRequest request) - throws AstpAiRequestFailedException { - - return authService.authenticate(); - } } diff --git a/chpl/chpl-resources/src/main/resources/email.properties b/chpl/chpl-resources/src/main/resources/email.properties index 70a0a1f653..06202ebc8f 100644 --- a/chpl/chpl-resources/src/main/resources/email.properties +++ b/chpl/chpl-resources/src/main/resources/email.properties @@ -294,6 +294,16 @@ rwt.report.filename=real-world-testing-report- rwt.report.subject=CHPL Real World Testing Report rwt.report.body=Report contains data for the following ONC-ACBs +# Real World Testing Validation Email properties +rwtResults.validation.subject=RWT Results URL Validation +rwtResults.validation.body=

Validation Results for RWT Results URL

\ +
  • URL: %s
  • Listing: %s
  • Year: %s
\ +

%s

+rwtResults.validation.failure.subject=RWT Results URL Validation Failed +rwtResults.validation.failure.body=

Validation Failed

\ +
  • URL: %s
  • Listing: %s
  • Year: %s
\ +

Reason: %s

+ # Scheduled Job Change job.change.subject=CHPL Scheduled Job Notification job.change.body= A CHPL scheduled job has been modified. Please check the information below to confirm these changes are acceptable to you.

%s was %s

%s diff --git a/chpl/chpl-resources/src/main/resources/environment.properties b/chpl/chpl-resources/src/main/resources/environment.properties index 97870a9e83..c56a002eb1 100644 --- a/chpl/chpl-resources/src/main/resources/environment.properties +++ b/chpl/chpl-resources/src/main/resources/environment.properties @@ -123,8 +123,9 @@ insight.submissionsUrl=https://healthit-gov-develop.go-vip.net/wp-json/data-dash astpai.authenticate.url=https://us-east-1zmkmlezba.auth.us-east-1.amazoncognito.com/oauth2/token astpai.authenticate.clientSecret=SECRET astpai.authenticate.clientId=SECRET -astpai.domain=https://astp-dev.ainq.ai/api/ -astpai.rwtResultUrlValidation.endpoint=rwt-validations/from-url +astpai.domain=https://astp-dev.ainq.ai/api +astpai.rwtResultUrlValidation.endpoint=/rwt-validations/from-url +astpai.requestTimeoutMillis=300000 ################################################### ###### CHPL-SERVICE DOWNLOAD JAR PROPERTIES ###### diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java index 1354011890..bed55ef256 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java @@ -232,7 +232,7 @@ public RestTemplate jiraAuthenticatedRestTemplate() CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() .setDefaultSocketConfig(SocketConfig.custom() - .setSoTimeout(getRequestTimeout(), TimeUnit.MILLISECONDS) + .setSoTimeout(getJiraRequestTimeout(), TimeUnit.MILLISECONDS) .build()) .setTlsSocketStrategy(new DefaultClientTlsStrategy( SSLContexts.custom().loadTrustMaterial(TrustAllStrategy.INSTANCE).build(), @@ -242,7 +242,7 @@ public RestTemplate jiraAuthenticatedRestTemplate() HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); requestFactory.setHttpClient(httpClient); - requestFactory.setConnectionRequestTimeout(getRequestTimeout()); + requestFactory.setConnectionRequestTimeout(getJiraRequestTimeout()); RestTemplate restTemplate = new RestTemplate(requestFactory); restTemplate.getInterceptors().add( @@ -277,13 +277,27 @@ public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttp return restTemplate; } + private int getJiraRequestTimeout() { + int requestTimeout = DEFAULT_REQUEST_TIMEOUT; + String requestTimeoutProperty = env.getProperty("jira.requestTimeoutMillis"); + if (!StringUtils.isEmpty(requestTimeoutProperty)) { + try { + requestTimeout = Integer.parseInt(requestTimeoutProperty); + } catch (NumberFormatException ex) { + LOGGER.warn("Cannot parse " + requestTimeoutProperty + " as an integer. " + + "Using the default value " + DEFAULT_REQUEST_TIMEOUT); + } + } + return requestTimeout; + } + @Bean public RestTemplate httpsRestTemplate() throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException { CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() .setDefaultSocketConfig(SocketConfig.custom() - .setSoTimeout(getRequestTimeout(), TimeUnit.MILLISECONDS) + .setSoTimeout(getAstpAiRequestTimeout(), TimeUnit.MILLISECONDS) .build()) .setTlsSocketStrategy(new DefaultClientTlsStrategy( SSLContexts.custom().loadTrustMaterial(TrustAllStrategy.INSTANCE).build(), @@ -293,14 +307,14 @@ public RestTemplate httpsRestTemplate() HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); requestFactory.setHttpClient(httpClient); - requestFactory.setConnectionRequestTimeout(getRequestTimeout()); + requestFactory.setConnectionRequestTimeout(getJiraRequestTimeout()); return new RestTemplate(requestFactory); } - private int getRequestTimeout() { + private int getAstpAiRequestTimeout() { int requestTimeout = DEFAULT_REQUEST_TIMEOUT; - String requestTimeoutProperty = env.getProperty("jira.requestTimeoutMillis"); + String requestTimeoutProperty = env.getProperty("astpai.requestTimeoutMillis"); if (!StringUtils.isEmpty(requestTimeoutProperty)) { try { requestTimeout = Integer.parseInt(requestTimeoutProperty); diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java index 9e7240b338..34f1ea42a0 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java @@ -1,9 +1,57 @@ package gov.healthit.chpl.astpai; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import lombok.extern.log4j.Log4j2; + +@Log4j2 @Service public class AstpAiQueryService { - //TODO methods to query the AI tool + private RestTemplate httpsRestTemplate; + private String rwtResultsValidationUrl; + + @Autowired + public AstpAiQueryService(RestTemplate httpsRestTemplate, + @Value("${astpai.domain}") String astpAiDomain, + @Value("${astpai.rwtResultUrlValidation.endpoint}") String astpAiRwtResultValidationApi) { + this.httpsRestTemplate = httpsRestTemplate; + this.rwtResultsValidationUrl = astpAiDomain + astpAiRwtResultValidationApi; + } + + public String getRwtResultsUrlValidationResponse(String accessToken, UrlValidationRequest requestBody) + throws AstpAiRequestFailedException { + LOGGER.info("Making request to " + rwtResultsValidationUrl + " with access token " + accessToken); + ResponseEntity response = null; + try { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + accessToken); + headers.add("Accept", "application/json"); + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + response = httpsRestTemplate.exchange(rwtResultsValidationUrl, HttpMethod.POST, entity, String.class); + LOGGER.debug("Response: " + response.getBody()); + } catch (HttpClientErrorException httpEx) { + LOGGER.error("Unable to query the URL " + rwtResultsValidationUrl + ". Message: " + httpEx.getMessage() + "; response status code " + httpEx.getStatusCode()); + throw new AstpAiRequestFailedException(httpEx.getMessage(), httpEx, httpEx.getStatusCode()); + } catch (Exception ex) { + HttpStatusCode statusCode = (response != null && response.getStatusCode() != null + ? response.getStatusCode() : HttpStatusCode.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value())); + LOGGER.error("Unable to query the URL " + rwtResultsValidationUrl + ". Message: " + ex.getMessage() + "; response status code " + statusCode); + throw new AstpAiRequestFailedException(ex.getMessage(), ex, statusCode); + } + + String responseBody = response == null ? "" : response.getBody(); + return responseBody; + } } diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationRequest.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationRequest.java new file mode 100644 index 0000000000..af6dba55a7 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationRequest.java @@ -0,0 +1,26 @@ +package gov.healthit.chpl.astpai; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Data +@AllArgsConstructor +@Builder +public class UrlValidationRequest { + private String url; + + @JsonProperty("chpl_product_number") + private String chplProductNumber; + + @JsonProperty("target_year") + private Integer targetYear; + + @JsonProperty("max_depth") + private Integer maxDepth; + +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java index be8eb61a08..220d83e088 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java @@ -7,20 +7,23 @@ import org.quartz.JobExecutionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.env.Environment; import org.springframework.web.context.support.SpringBeanAutowiringSupport; import gov.healthit.chpl.astpai.AmazonTokenResponse; import gov.healthit.chpl.astpai.AstpAiAuthenticationService; +import gov.healthit.chpl.astpai.AstpAiQueryService; import gov.healthit.chpl.astpai.AstpAiRequestFailedException; +import gov.healthit.chpl.astpai.UrlValidationRequest; import gov.healthit.chpl.auth.user.JWTAuthenticatedUser; import gov.healthit.chpl.email.ChplEmailFactory; import gov.healthit.chpl.email.ChplHtmlEmailBuilder; import gov.healthit.chpl.email.footer.AdminFooter; import gov.healthit.chpl.exception.EmailNotSentException; +import gov.healthit.chpl.exception.InvalidArgumentsException; import gov.healthit.chpl.realworldtesting.domain.RealWorldTestingUrlType; import gov.healthit.chpl.scheduler.job.QuartzJob; -import gov.healthit.chpl.util.ErrorMessageUtil; +import gov.healthit.chpl.search.ListingSearchService; +import gov.healthit.chpl.search.domain.ListingSearchResult; import lombok.extern.log4j.Log4j2; @DisallowConcurrentExecution @@ -32,6 +35,7 @@ public class RealWorldTestingUrlValidationJob extends QuartzJob { public static final String URL_TYPE_KEY = "urlType"; public static final String YEAR_KEY = "year"; public static final String USER_KEY = "user"; + private static final Integer MAX_SEARCH_DEPTH = 5; @Autowired private ChplHtmlEmailBuilder chplHtmlEmailBuilder; @@ -42,24 +46,34 @@ public class RealWorldTestingUrlValidationJob extends QuartzJob { @Value("${contact.acbatlUrl}") private String acbatlFeedbackUrl; - @Value("${surveillance.quarterlyReport.success.subject}") - private String quarterlyReportSubject; + @Value("${rwtResults.validation.subject}") + private String emailSubject; - @Value("${surveillance.quarterlyReport.failure.subject}") - private String quarterlyReportFailureSubject; + @Value("${rwtResults.validation.body}") + private String emailBody; + + @Value("${rwtResults.validation.failure.subject}") + private String failureEmailSubject; + + @Value("${rwtResults.validation.failure.body}") + private String failureEmailBody; @Autowired - private AstpAiAuthenticationService astpAiService; + private AstpAiAuthenticationService aiAuthService; @Autowired - private ErrorMessageUtil msgUtil; + private AstpAiQueryService aiQueryService; @Autowired - private Environment env; + private ListingSearchService listingSearchService; @Autowired private ChplEmailFactory chplEmailFactory; + private String url; + private Long listingId; + private Integer year; + @Override public void execute(JobExecutionContext jobContext) throws JobExecutionException { SpringBeanAutowiringSupport.processInjectionBasedOnCurrentContext(this); @@ -70,32 +84,49 @@ public void execute(JobExecutionContext jobContext) throws JobExecutionException boolean isJobDataValid = isJobDataValid(jobDataMap); if (isJobDataValid) { JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); - Long listingId = (Long) jobDataMap.get(LISTING_ID_KEY); - String url = (String) jobDataMap.get(URL_KEY); + listingId = (Long) jobDataMap.get(LISTING_ID_KEY); + url= (String) jobDataMap.get(URL_KEY); RealWorldTestingUrlType urlType = RealWorldTestingUrlType.valueOf((String) jobDataMap.get(URL_TYPE_KEY)); - Integer year = (Integer) jobDataMap.get(YEAR_KEY); + year = (Integer) jobDataMap.get(YEAR_KEY); setSecurityContext(user); //authenticate AmazonTokenResponse token = null; try { - token = astpAiService.authenticate(); + token = aiAuthService.authenticate(); } catch (AstpAiRequestFailedException ex) { LOGGER.error("Unable to authenticate with ASTP-AI", ex); - sendErrorEmail(user.getEmail(), quarterlyReportFailureSubject, - env.getProperty("surveillance.quarterlyReport.badJobData.htmlBody")); + sendErrorEmail(user.getEmail(), "Unable to authenticate with ASTP-AI"); } - //TODO call AI endpoint, get response or handle error - - //TODO parse results and send email - sendResultsEmail(user.getEmail(), quarterlyReportFailureSubject, - env.getProperty("surveillance.quarterlyReport.fileError.htmlBody")); + //call AI endpoint, get response or handle error + String aiResponse = null; + if (token != null) { + try { + aiResponse = aiQueryService.getRwtResultsUrlValidationResponse(token.getAccessToken(), UrlValidationRequest.builder() + .chplProductNumber(getChplProductNumber()) + .url(url) + .maxDepth(MAX_SEARCH_DEPTH) + .targetYear(year) + .build()); + } catch (AstpAiRequestFailedException ex) { + LOGGER.error("Unable to query ASTP-AI endpoint", ex); + sendErrorEmail(user.getEmail(), "Unable to query ASTP-AI endpoint: " + ex.getMessage()); + } catch (Exception ex) { + LOGGER.error("Unexpected error querying ASTP-AI endpoint", ex); + sendErrorEmail(user.getEmail(), "Unexpected error querying ASTP-AI endpoint: " + ex.getMessage()); + } + } else { + LOGGER.error("Unable to authenticate with ASTP-AI"); + sendErrorEmail(user.getEmail(), "Unable to authenticate with ASTP-AI"); + } + //parse results and send email + sendResultsEmail(user.getEmail(), aiResponse); } else { + //invalid inputs in the job data JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); if (user != null && user.getEmail() != null) { - sendErrorEmail(user.getEmail(), quarterlyReportFailureSubject, - env.getProperty("surveillance.quarterlyReport.badJobData.htmlBody")); + sendErrorEmail(user.getEmail(), "Invalid inputs for RWT URL Validation"); } } LOGGER.info("********* Completed the Real World Testing Url Validation job. *********"); @@ -113,6 +144,17 @@ private boolean isJobDataValid(JobDataMap jobDataMap) { if (listingId == null) { isValid = false; LOGGER.fatal("No listing ID could be found in the job data."); + } else { + ListingSearchResult listing = null; + try { + listing = listingSearchService.findListing(listingId); + } catch (InvalidArgumentsException ex) { + LOGGER.fatal("Invalid listing ID " + listingId + " found in the job data.", ex); + isValid = false; + } + if (listing == null) { + isValid = false; + } } String url = (String) jobDataMap.get(URL_KEY); @@ -133,24 +175,36 @@ private boolean isJobDataValid(JobDataMap jobDataMap) { } } - String year = (String) jobDataMap.get(YEAR_KEY); - if (StringUtils.isEmpty(year)) { + Integer year = (Integer) jobDataMap.get(YEAR_KEY); + if (year == null) { isValid = false; LOGGER.fatal("No year could be found in the job data."); } return isValid; } - private void sendResultsEmail(String recipientEmail, String subject, String htmlContent) { + private String getChplProductNumber() { + ListingSearchResult result = null; + try { + result = listingSearchService.findListing(listingId); + } catch (InvalidArgumentsException ex) { + LOGGER.error("No listing with ID " + listingId + " was found."); + } + if (result == null) { + return ""; + } + return result.getChplProductNumber(); + } + + private void sendResultsEmail(String recipientEmail, String results) { LOGGER.info("Sending email to: " + recipientEmail); try { chplEmailFactory.emailBuilder() .recipient(recipientEmail) - .subject(subject) + .subject(emailSubject) .htmlMessage(chplHtmlEmailBuilder.initialize() - .heading(subject) - .paragraph("", htmlContent) + .paragraph("", String.format(emailBody, url, getChplProductNumber(), year + "", results)) .paragraph("", String.format(chplEmailValediction, acbatlFeedbackUrl)) .footer(AdminFooter.class) .build()) @@ -160,16 +214,15 @@ private void sendResultsEmail(String recipientEmail, String subject, String html } } - private void sendErrorEmail(String recipientEmail, String subject, String htmlContent) { + private void sendErrorEmail(String recipientEmail, String errorMessage) { LOGGER.info("Sending email to: " + recipientEmail); try { chplEmailFactory.emailBuilder() .recipient(recipientEmail) - .subject(subject) + .subject(failureEmailSubject) .htmlMessage(chplHtmlEmailBuilder.initialize() - .heading(subject) - .paragraph("", htmlContent) + .paragraph("", String.format(failureEmailBody, url, listingId + "", year + "", errorMessage)) .paragraph("", String.format(chplEmailValediction, acbatlFeedbackUrl)) .footer(AdminFooter.class) .build()) From 39512e959114b4c2d6f9e1cb531ef7ffc70086ab Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Tue, 10 Feb 2026 14:44:24 -0500 Subject: [PATCH 05/11] fix: Email formatting [#OCD-4757] --- chpl/chpl-resources/src/main/resources/email.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chpl/chpl-resources/src/main/resources/email.properties b/chpl/chpl-resources/src/main/resources/email.properties index 06202ebc8f..a7d0032239 100644 --- a/chpl/chpl-resources/src/main/resources/email.properties +++ b/chpl/chpl-resources/src/main/resources/email.properties @@ -296,11 +296,11 @@ rwt.report.body=Report contains data for the following ONC-ACBs # Real World Testing Validation Email properties rwtResults.validation.subject=RWT Results URL Validation -rwtResults.validation.body=

Validation Results for RWT Results URL

\ +rwtResults.validation.body=

Validation Results for RWT Results URL

\
  • URL: %s
  • Listing: %s
  • Year: %s
\

%s

rwtResults.validation.failure.subject=RWT Results URL Validation Failed -rwtResults.validation.failure.body=

Validation Failed

\ +rwtResults.validation.failure.body=

Validation Failed

\
  • URL: %s
  • Listing: %s
  • Year: %s
\

Reason: %s

From 664b50ae8e04a2cf4c990658b2dbfdcd59caccb0 Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Wed, 11 Feb 2026 09:57:57 -0500 Subject: [PATCH 06/11] fix: Typo in server environment name [#OCD-4757] --- .../java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java | 2 +- .../src/main/java/gov/healthit/chpl/util/ServerEnvironment.java | 2 +- .../chpl/web/controller/RealWorldTestingController.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java index 28a29208ca..eab2c300cf 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java @@ -25,7 +25,7 @@ public EnvironmentHeaderFilter(@Value("${server.environment}") String serverEnvi @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (serverEnvironment.equals(ServerEnvironment.PRODUCITON)) { + if (serverEnvironment.equals(ServerEnvironment.PRODUCTION)) { response.addHeader("Environment", "PRODUCTION"); } else { response.addHeader("Environment", "NON-PRODUCTION"); diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java index e000917dd9..f10c786b4f 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/util/ServerEnvironment.java @@ -5,7 +5,7 @@ import lombok.Getter; public enum ServerEnvironment { - PRODUCITON("production"), + PRODUCTION("production"), NON_PRODUCTION("non-production"); @Getter diff --git a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java index 4a598c77bc..c279dae91b 100644 --- a/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java +++ b/chpl/chpl-api/src/main/java/gov/healthit/chpl/web/controller/RealWorldTestingController.java @@ -76,7 +76,7 @@ public RealWorldTestingController(RealWorldTestingManager realWorldTestingManage throws UserRetrievalException, SchedulerException, ValidationException { if (!ff4j.check(FeatureList.RWT_AI_INTEGRATION) || this.serverEnvironment == null - || this.serverEnvironment.equals(ServerEnvironment.PRODUCITON)) { + || this.serverEnvironment.equals(ServerEnvironment.PRODUCTION)) { throw new NotImplementedException("This method has not been implemented"); } return realWorldTestingManager.validateResultsUrlAsBackgroundJob(request); From ac338ea31c37aabc6280c30b79ebf2b514c0e783 Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Wed, 11 Feb 2026 10:22:45 -0500 Subject: [PATCH 07/11] fix: Define logger with correct name for rwt validation job [#OCD-4757] --- .../resources/log4j2-xinclude-file-appenders-console.xml | 4 ++-- .../main/resources/log4j2-xinclude-file-appenders-local.xml | 6 +++--- .../src/main/resources/log4j2-xinclude-file-appenders.xml | 6 +++--- .../src/main/resources/log4j2-xinclude-loggers-local.xml | 4 ++-- .../chpl-api/src/main/resources/log4j2-xinclude-loggers.xml | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml index d087955169..143ce787cb 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-console.xml @@ -211,9 +211,9 @@ - + - + diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml index fed557d0ee..19c3f4cb18 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders-local.xml @@ -383,9 +383,9 @@ interval="1" modulate="true" /> - %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %X{dd.trace_id} %X{dd.span_id} - %m%n diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders.xml index 92d6208205..aaec84d5ad 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-file-appenders.xml @@ -372,9 +372,9 @@ interval="1" modulate="true" /> - %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %X{dd.trace_id} %X{dd.span_id} - %m%n diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers-local.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers-local.xml index 21d2721ff7..77bf363a77 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers-local.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers-local.xml @@ -132,8 +132,8 @@ - - + + diff --git a/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml b/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml index f97a3d35c9..f9a37a52e0 100644 --- a/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml +++ b/chpl/chpl-api/src/main/resources/log4j2-xinclude-loggers.xml @@ -120,9 +120,9 @@ - - - + + + From 27792650a0fa52d103d1a9f46c25748588ea0174 Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Wed, 11 Feb 2026 10:27:07 -0500 Subject: [PATCH 08/11] feat: Add logging to RWT URL validation job [#OCD-4757] --- .../RealWorldTestingUrlValidationJob.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java index 220d83e088..5a19741f34 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java @@ -85,11 +85,12 @@ public void execute(JobExecutionContext jobContext) throws JobExecutionException if (isJobDataValid) { JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); listingId = (Long) jobDataMap.get(LISTING_ID_KEY); - url= (String) jobDataMap.get(URL_KEY); + url = (String) jobDataMap.get(URL_KEY); RealWorldTestingUrlType urlType = RealWorldTestingUrlType.valueOf((String) jobDataMap.get(URL_TYPE_KEY)); year = (Integer) jobDataMap.get(YEAR_KEY); setSecurityContext(user); + LOGGER.info("Validating URL " + url + " for listing " + listingId + " and year " + year); //authenticate AmazonTokenResponse token = null; try { @@ -97,11 +98,14 @@ public void execute(JobExecutionContext jobContext) throws JobExecutionException } catch (AstpAiRequestFailedException ex) { LOGGER.error("Unable to authenticate with ASTP-AI", ex); sendErrorEmail(user.getEmail(), "Unable to authenticate with ASTP-AI"); + return; } + LOGGER.info("Successfully authenticated with the ASTP-AI application"); //call AI endpoint, get response or handle error String aiResponse = null; if (token != null) { try { + LOGGER.info("Requesting RWT URL Validation from the ASTP-AI application"); aiResponse = aiQueryService.getRwtResultsUrlValidationResponse(token.getAccessToken(), UrlValidationRequest.builder() .chplProductNumber(getChplProductNumber()) .url(url) @@ -111,18 +115,22 @@ public void execute(JobExecutionContext jobContext) throws JobExecutionException } catch (AstpAiRequestFailedException ex) { LOGGER.error("Unable to query ASTP-AI endpoint", ex); sendErrorEmail(user.getEmail(), "Unable to query ASTP-AI endpoint: " + ex.getMessage()); + return; } catch (Exception ex) { LOGGER.error("Unexpected error querying ASTP-AI endpoint", ex); sendErrorEmail(user.getEmail(), "Unexpected error querying ASTP-AI endpoint: " + ex.getMessage()); + return; } } else { LOGGER.error("Unable to authenticate with ASTP-AI"); sendErrorEmail(user.getEmail(), "Unable to authenticate with ASTP-AI"); + return; } + LOGGER.info("Received validation results. Emailing " + user.getEmail()); //parse results and send email sendResultsEmail(user.getEmail(), aiResponse); - } else { + LOGGER.error("Invalid inputs to job."); //invalid inputs in the job data JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); if (user != null && user.getEmail() != null) { From 077dc53039b259e3b833aa1b2fc5ddf555aa23f0 Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Wed, 11 Feb 2026 12:47:58 -0500 Subject: [PATCH 09/11] feat: Clean up email formatting of rwt validation [#OCD-4757] --- .../src/main/resources/email.properties | 6 +- .../chpl/astpai/AstpAiQueryService.java | 16 +++- .../chpl/astpai/UrlValidationResponse.java | 43 ++++++++++ .../RealWorldTestingUrlValidationJob.java | 85 +++++++++++++------ 4 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationResponse.java diff --git a/chpl/chpl-resources/src/main/resources/email.properties b/chpl/chpl-resources/src/main/resources/email.properties index a7d0032239..909a1cf2a7 100644 --- a/chpl/chpl-resources/src/main/resources/email.properties +++ b/chpl/chpl-resources/src/main/resources/email.properties @@ -296,12 +296,12 @@ rwt.report.body=Report contains data for the following ONC-ACBs # Real World Testing Validation Email properties rwtResults.validation.subject=RWT Results URL Validation -rwtResults.validation.body=

Validation Results for RWT Results URL

\ -
  • URL: %s
  • Listing: %s
  • Year: %s
\ +rwtResults.validation.body=

RWT Results URL Validation

\ +

Validation Inputs

  • URL: %s
  • Listing: %s
  • Year: %s
\

%s

rwtResults.validation.failure.subject=RWT Results URL Validation Failed rwtResults.validation.failure.body=

Validation Failed

\ -
  • URL: %s
  • Listing: %s
  • Year: %s
\ +

Validation Inputs

  • URL: %s
  • Listing: %s
  • Year: %s
\

Reason: %s

# Scheduled Job Change diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java index 34f1ea42a0..babf724292 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java @@ -13,6 +13,8 @@ import org.springframework.web.client.RestTemplate; import lombok.extern.log4j.Log4j2; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; @Log4j2 @Service @@ -20,16 +22,19 @@ public class AstpAiQueryService { private RestTemplate httpsRestTemplate; private String rwtResultsValidationUrl; + private JsonMapper jsonMapper; @Autowired public AstpAiQueryService(RestTemplate httpsRestTemplate, + JsonMapper jsonMapper, @Value("${astpai.domain}") String astpAiDomain, @Value("${astpai.rwtResultUrlValidation.endpoint}") String astpAiRwtResultValidationApi) { this.httpsRestTemplate = httpsRestTemplate; + this.jsonMapper = jsonMapper; this.rwtResultsValidationUrl = astpAiDomain + astpAiRwtResultValidationApi; } - public String getRwtResultsUrlValidationResponse(String accessToken, UrlValidationRequest requestBody) + public UrlValidationResponse getRwtResultsUrlValidationResponse(String accessToken, UrlValidationRequest requestBody) throws AstpAiRequestFailedException { LOGGER.info("Making request to " + rwtResultsValidationUrl + " with access token " + accessToken); ResponseEntity response = null; @@ -52,6 +57,13 @@ public String getRwtResultsUrlValidationResponse(String accessToken, UrlValidati } String responseBody = response == null ? "" : response.getBody(); - return responseBody; + UrlValidationResponse aiQueryResponse = null; + try { + aiQueryResponse = jsonMapper.readValue(responseBody, UrlValidationResponse.class); + } catch (JacksonException ex) { + LOGGER.error("Unable to read the response body as our custom AmazonTokenResponse", ex); + throw new AstpAiRequestFailedException(ex.getMessage(), ex); + } + return aiQueryResponse; } } diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationResponse.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationResponse.java new file mode 100644 index 0000000000..9f6a22a00d --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/UrlValidationResponse.java @@ -0,0 +1,43 @@ +package gov.healthit.chpl.astpai; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Data +@AllArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public class UrlValidationResponse { + private Document document; + private Validation validation; + private String error; + + @NoArgsConstructor + @Data + @Builder + @AllArgsConstructor + public static final class Document { + private String confidence; + private String url; + } + + @NoArgsConstructor + @Data + @Builder + @AllArgsConstructor + public static final class Validation { + @JsonProperty("completeness_score") + private String completenessScore; + private List recommendations; + private String summary; + //TODO critical failures, warnings + } +} diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java index 5a19741f34..e1e02f613e 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java @@ -1,5 +1,6 @@ package gov.healthit.chpl.scheduler.job.realworldtesting; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.quartz.DisallowConcurrentExecution; import org.quartz.JobDataMap; @@ -14,6 +15,7 @@ import gov.healthit.chpl.astpai.AstpAiQueryService; import gov.healthit.chpl.astpai.AstpAiRequestFailedException; import gov.healthit.chpl.astpai.UrlValidationRequest; +import gov.healthit.chpl.astpai.UrlValidationResponse; import gov.healthit.chpl.auth.user.JWTAuthenticatedUser; import gov.healthit.chpl.email.ChplEmailFactory; import gov.healthit.chpl.email.ChplHtmlEmailBuilder; @@ -70,24 +72,20 @@ public class RealWorldTestingUrlValidationJob extends QuartzJob { @Autowired private ChplEmailFactory chplEmailFactory; + private JWTAuthenticatedUser user; private String url; private Long listingId; private Integer year; + private RealWorldTestingUrlType urlType; @Override public void execute(JobExecutionContext jobContext) throws JobExecutionException { SpringBeanAutowiringSupport.processInjectionBasedOnCurrentContext(this); LOGGER.info("********* Starting the Real World Testing Url Validation job. *********"); - JobDataMap jobDataMap = jobContext.getMergedJobDataMap(); - boolean isJobDataValid = isJobDataValid(jobDataMap); - if (isJobDataValid) { - JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); - listingId = (Long) jobDataMap.get(LISTING_ID_KEY); - url = (String) jobDataMap.get(URL_KEY); - RealWorldTestingUrlType urlType = RealWorldTestingUrlType.valueOf((String) jobDataMap.get(URL_TYPE_KEY)); - year = (Integer) jobDataMap.get(YEAR_KEY); + parseJobData(jobDataMap); + if (isJobDataValid()) { setSecurityContext(user); LOGGER.info("Validating URL " + url + " for listing " + listingId + " and year " + year); @@ -102,7 +100,7 @@ public void execute(JobExecutionContext jobContext) throws JobExecutionException } LOGGER.info("Successfully authenticated with the ASTP-AI application"); //call AI endpoint, get response or handle error - String aiResponse = null; + UrlValidationResponse aiResponse = null; if (token != null) { try { LOGGER.info("Requesting RWT URL Validation from the ASTP-AI application"); @@ -132,23 +130,31 @@ public void execute(JobExecutionContext jobContext) throws JobExecutionException } else { LOGGER.error("Invalid inputs to job."); //invalid inputs in the job data - JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); + user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); if (user != null && user.getEmail() != null) { sendErrorEmail(user.getEmail(), "Invalid inputs for RWT URL Validation"); + } else { + LOGGER.fatal("Invalid inputs to job and no user was found to email."); } } LOGGER.info("********* Completed the Real World Testing Url Validation job. *********"); } - private boolean isJobDataValid(JobDataMap jobDataMap) { + private void parseJobData(JobDataMap jobDataMap) { + user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); + listingId = (Long) jobDataMap.get(LISTING_ID_KEY); + url = (String) jobDataMap.get(URL_KEY); + urlType = RealWorldTestingUrlType.valueOf((String) jobDataMap.get(URL_TYPE_KEY)); + year = (Integer) jobDataMap.get(YEAR_KEY); + } + + private boolean isJobDataValid() { boolean isValid = true; - JWTAuthenticatedUser user = (JWTAuthenticatedUser) jobDataMap.get(USER_KEY); if (user == null) { isValid = false; LOGGER.fatal("No user could be found in the job data."); } - Long listingId = (Long) jobDataMap.get(LISTING_ID_KEY); if (listingId == null) { isValid = false; LOGGER.fatal("No listing ID could be found in the job data."); @@ -165,25 +171,16 @@ private boolean isJobDataValid(JobDataMap jobDataMap) { } } - String url = (String) jobDataMap.get(URL_KEY); if (StringUtils.isEmpty(url)) { isValid = false; LOGGER.fatal("No URL could be found in the job data."); } - String urlType = (String) jobDataMap.get(URL_TYPE_KEY); - if (StringUtils.isEmpty(urlType)) { + if (urlType == null) { isValid = false; - LOGGER.fatal("No URL Type could be found in the job data."); - } else { - RealWorldTestingUrlType urlTypeEnum = RealWorldTestingUrlType.valueOf(urlType); - if (urlTypeEnum == null || !urlTypeEnum.equals(RealWorldTestingUrlType.RESULTS)) { - isValid = false; - LOGGER.fatal("URL Type " + urlType + " is not recognized or supported."); - } + LOGGER.fatal("A valid URL Type was not found in the job data."); } - Integer year = (Integer) jobDataMap.get(YEAR_KEY); if (year == null) { isValid = false; LOGGER.fatal("No year could be found in the job data."); @@ -204,15 +201,15 @@ private String getChplProductNumber() { return result.getChplProductNumber(); } - private void sendResultsEmail(String recipientEmail, String results) { + private void sendResultsEmail(String recipientEmail, UrlValidationResponse results) { LOGGER.info("Sending email to: " + recipientEmail); - + String resultsHtml = createResultsHtml(results); try { chplEmailFactory.emailBuilder() .recipient(recipientEmail) .subject(emailSubject) .htmlMessage(chplHtmlEmailBuilder.initialize() - .paragraph("", String.format(emailBody, url, getChplProductNumber(), year + "", results)) + .paragraph("", String.format(emailBody, url, getChplProductNumber(), year + "", resultsHtml)) .paragraph("", String.format(chplEmailValediction, acbatlFeedbackUrl)) .footer(AdminFooter.class) .build()) @@ -222,6 +219,40 @@ private void sendResultsEmail(String recipientEmail, String results) { } } + private String createResultsHtml(UrlValidationResponse results) { + StringBuffer buf = new StringBuffer(); + if (!StringUtils.isEmpty(results.getError())) { + buf.append("

Error: " + results.getError() + "

"); + } + if (results.getDocument() != null) { + buf.append("

Document Discovery

    "); + if (!StringUtils.isEmpty(results.getDocument().getUrl())) { + buf.append("
  • Document URL: " + results.getDocument().getUrl() + "
  • "); + } + if (!StringUtils.isEmpty(results.getDocument().getConfidence())) { + buf.append("
  • Confidence: " + results.getDocument().getConfidence() + "
  • "); + } + buf.append("
"); + } + if (results.getValidation() != null) { + buf.append("

Validation Results

    "); + if (!StringUtils.isEmpty(results.getValidation().getCompletenessScore())) { + buf.append("
  • Completeness Score: " + results.getValidation().getCompletenessScore() + "
  • "); + } + if (!StringUtils.isEmpty(results.getValidation().getSummary())) { + buf.append("
  • Validation Summary: " + results.getValidation().getSummary() + "
  • "); + } + if (!CollectionUtils.isEmpty(results.getValidation().getRecommendations())) { + buf.append("
  • Recommendations:
      "); + results.getValidation().getRecommendations().stream() + .forEach(rec -> buf.append("
    • " + rec + "
    • ")); + buf.append("
  • "); + } + buf.append("
"); + } + return buf.toString(); + } + private void sendErrorEmail(String recipientEmail, String errorMessage) { LOGGER.info("Sending email to: " + recipientEmail); From 4161767735ba5ec88d4a41dff899d7fb2325b897 Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Wed, 11 Feb 2026 14:54:15 -0500 Subject: [PATCH 10/11] fix: Use better subject and email body title [#OCD-4757] --- chpl/chpl-resources/src/main/resources/email.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/chpl/chpl-resources/src/main/resources/email.properties b/chpl/chpl-resources/src/main/resources/email.properties index 909a1cf2a7..a24ca0b9b2 100644 --- a/chpl/chpl-resources/src/main/resources/email.properties +++ b/chpl/chpl-resources/src/main/resources/email.properties @@ -295,12 +295,12 @@ rwt.report.subject=CHPL Real World Testing Report rwt.report.body=Report contains data for the following ONC-ACBs # Real World Testing Validation Email properties -rwtResults.validation.subject=RWT Results URL Validation -rwtResults.validation.body=

RWT Results URL Validation

\ +rwtResults.validation.subject=RWT Results Report Validation +rwtResults.validation.body=

RWT Results Report Validation

\

Validation Inputs

  • URL: %s
  • Listing: %s
  • Year: %s
\

%s

-rwtResults.validation.failure.subject=RWT Results URL Validation Failed -rwtResults.validation.failure.body=

Validation Failed

\ +rwtResults.validation.failure.subject=RWT Results Report Validation Failed +rwtResults.validation.failure.body=

RWT Results Report Validation Failed

\

Validation Inputs

  • URL: %s
  • Listing: %s
  • Year: %s
\

Reason: %s

From 3debfb8a827027d12e257c3ed2418c3768b86101 Mon Sep 17 00:00:00 2001 From: Katy Ekey Date: Fri, 13 Feb 2026 08:42:40 -0500 Subject: [PATCH 11/11] fix: Adjust email text to replace heading, add % to percentages [#OCD-4757] --- chpl/chpl-resources/src/main/resources/email.properties | 8 ++++---- .../RealWorldTestingUrlValidationJob.java | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/chpl/chpl-resources/src/main/resources/email.properties b/chpl/chpl-resources/src/main/resources/email.properties index a24ca0b9b2..3530370b3b 100644 --- a/chpl/chpl-resources/src/main/resources/email.properties +++ b/chpl/chpl-resources/src/main/resources/email.properties @@ -296,12 +296,12 @@ rwt.report.body=Report contains data for the following ONC-ACBs # Real World Testing Validation Email properties rwtResults.validation.subject=RWT Results Report Validation -rwtResults.validation.body=

RWT Results Report Validation

\ -

Validation Inputs

  • URL: %s
  • Listing: %s
  • Year: %s
\ +rwtResults.validation.body=

Validation Inputs

    \ +
  • URL: %s
  • Listing: %s
  • Year: %s
\

%s

rwtResults.validation.failure.subject=RWT Results Report Validation Failed -rwtResults.validation.failure.body=

RWT Results Report Validation Failed

\ -

Validation Inputs

  • URL: %s
  • Listing: %s
  • Year: %s
\ +rwtResults.validation.failure.body=

Validation Inputs

    \ +
  • URL: %s
  • Listing: %s
  • Year: %s
\

Reason: %s

# Scheduled Job Change diff --git a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java index e1e02f613e..2b4d2dc6c5 100644 --- a/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java @@ -209,6 +209,7 @@ private void sendResultsEmail(String recipientEmail, UrlValidationResponse resul .recipient(recipientEmail) .subject(emailSubject) .htmlMessage(chplHtmlEmailBuilder.initialize() + .heading(emailSubject) .paragraph("", String.format(emailBody, url, getChplProductNumber(), year + "", resultsHtml)) .paragraph("", String.format(chplEmailValediction, acbatlFeedbackUrl)) .footer(AdminFooter.class) @@ -230,14 +231,14 @@ private String createResultsHtml(UrlValidationResponse results) { buf.append("
  • Document URL: " + results.getDocument().getUrl() + "
  • "); } if (!StringUtils.isEmpty(results.getDocument().getConfidence())) { - buf.append("
  • Confidence: " + results.getDocument().getConfidence() + "
  • "); + buf.append("
  • Confidence: " + results.getDocument().getConfidence() + "%
  • "); } buf.append(""); } if (results.getValidation() != null) { buf.append("

    Validation Results

      "); if (!StringUtils.isEmpty(results.getValidation().getCompletenessScore())) { - buf.append("
    • Completeness Score: " + results.getValidation().getCompletenessScore() + "
    • "); + buf.append("
    • Completeness Score: " + results.getValidation().getCompletenessScore() + "%
    • "); } if (!StringUtils.isEmpty(results.getValidation().getSummary())) { buf.append("
    • Validation Summary: " + results.getValidation().getSummary() + "
    • "); @@ -261,6 +262,7 @@ private void sendErrorEmail(String recipientEmail, String errorMessage) { .recipient(recipientEmail) .subject(failureEmailSubject) .htmlMessage(chplHtmlEmailBuilder.initialize() + .heading(failureEmailSubject) .paragraph("", String.format(failureEmailBody, url, listingId + "", year + "", errorMessage)) .paragraph("", String.format(chplEmailValediction, acbatlFeedbackUrl)) .footer(AdminFooter.class)