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/filter/EnvironmentHeaderFilter.java b/chpl/chpl-api/src/main/java/gov/healthit/chpl/filter/EnvironmentHeaderFilter.java index 19c22c0138..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 @@ -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.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 new file mode 100644 index 0000000000..f10c786b4f --- /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 { + PRODUCTION("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..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 @@ -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,14 @@ 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.RealWorldTestingResultsUrlValidationRequest; 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 +34,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 +63,22 @@ 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 RealWorldTestingResultsUrlValidationRequest request) + throws UserRetrievalException, SchedulerException, ValidationException { + if (!ff4j.check(FeatureList.RWT_AI_INTEGRATION) + || this.serverEnvironment == null + || this.serverEnvironment.equals(ServerEnvironment.PRODUCTION)) { + throw new NotImplementedException("This method has not been implemented"); + } + 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..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,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..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,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..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,6 +120,10 @@ + + + + diff --git a/chpl/chpl-resources/src/main/resources/email.properties b/chpl/chpl-resources/src/main/resources/email.properties index 70a0a1f653..3530370b3b 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 Report Validation +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=

Validation Inputs

    \ +
  • 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 1671719a19..3728194bb0 100644 --- a/chpl/chpl-resources/src/main/resources/environment.properties +++ b/chpl/chpl-resources/src/main/resources/environment.properties @@ -119,6 +119,15 @@ 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 +astpai.requestTimeoutMillis=300000 +################################################### + ###### CHPL-SERVICE DOWNLOAD JAR PROPERTIES ###### dataSourceName=java:/comp/env/jdbc/openchpl ################################################### 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/CHPLServiceConfig.java b/chpl/chpl-service/src/main/java/gov/healthit/chpl/CHPLServiceConfig.java index 10812311c0..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,7 +277,7 @@ public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttp return restTemplate; } - private int getRequestTimeout() { + private int getJiraRequestTimeout() { int requestTimeout = DEFAULT_REQUEST_TIMEOUT; String requestTimeoutProperty = env.getProperty("jira.requestTimeoutMillis"); if (!StringUtils.isEmpty(requestTimeoutProperty)) { @@ -291,6 +291,41 @@ private int getRequestTimeout() { return requestTimeout; } + @Bean + public RestTemplate httpsRestTemplate() + throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException { + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultSocketConfig(SocketConfig.custom() + .setSoTimeout(getAstpAiRequestTimeout(), 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(getJiraRequestTimeout()); + + return new RestTemplate(requestFactory); + } + + private int getAstpAiRequestTimeout() { + int requestTimeout = DEFAULT_REQUEST_TIMEOUT; + String requestTimeoutProperty = env.getProperty("astpai.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 JobFactory jobFactory() { QuartzJobFactory jobFactory = new QuartzJobFactory(applicationContext); 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"; } 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..babf724292 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/astpai/AstpAiQueryService.java @@ -0,0 +1,69 @@ +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 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 UrlValidationResponse 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(); + 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/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/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/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/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..2b4d2dc6c5 --- /dev/null +++ b/chpl/chpl-service/src/main/java/gov/healthit/chpl/scheduler/job/realworldtesting/RealWorldTestingUrlValidationJob.java @@ -0,0 +1,275 @@ +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; +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.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.astpai.UrlValidationResponse; +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.search.ListingSearchService; +import gov.healthit.chpl.search.domain.ListingSearchResult; +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"; + private static final Integer MAX_SEARCH_DEPTH = 5; + + @Autowired + private ChplHtmlEmailBuilder chplHtmlEmailBuilder; + + @Value("${chpl.email.valediction}") + private String chplEmailValediction; + + @Value("${contact.acbatlUrl}") + private String acbatlFeedbackUrl; + + @Value("${rwtResults.validation.subject}") + private String emailSubject; + + @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 aiAuthService; + + @Autowired + private AstpAiQueryService aiQueryService; + + @Autowired + private ListingSearchService listingSearchService; + + @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(); + parseJobData(jobDataMap); + if (isJobDataValid()) { + setSecurityContext(user); + + LOGGER.info("Validating URL " + url + " for listing " + listingId + " and year " + year); + //authenticate + AmazonTokenResponse token = null; + try { + token = aiAuthService.authenticate(); + } 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 + UrlValidationResponse 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) + .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()); + 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 + 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 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; + if (user == null) { + isValid = false; + LOGGER.fatal("No user could be found in the job data."); + } + + 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; + } + } + + if (StringUtils.isEmpty(url)) { + isValid = false; + LOGGER.fatal("No URL could be found in the job data."); + } + + if (urlType == null) { + isValid = false; + LOGGER.fatal("A valid URL Type was not found in the job data."); + } + + if (year == null) { + isValid = false; + LOGGER.fatal("No year could be found in the job data."); + } + return isValid; + } + + 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, UrlValidationResponse results) { + LOGGER.info("Sending email to: " + recipientEmail); + String resultsHtml = createResultsHtml(results); + try { + chplEmailFactory.emailBuilder() + .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) + .build()) + .sendEmail(); + } catch (EmailNotSentException ex) { + LOGGER.error("Could not send email to " + recipientEmail, ex); + } + } + + 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); + + try { + chplEmailFactory.emailBuilder() + .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) + .build()) + .sendEmail(); + } catch (EmailNotSentException ex) { + LOGGER.error("Could not send email to " + recipientEmail, ex); + } + } +}