diff --git a/.idea/runConfigurations/API_DEV___No_Scheduling.xml b/.idea/runConfigurations/API_DEV___No_Scheduling.xml deleted file mode 100644 index c443a58b4..000000000 --- a/.idea/runConfigurations/API_DEV___No_Scheduling.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/api/src/main/java/com/lantanagroup/link/api/MeasureEvaluator.java b/api/src/main/java/com/lantanagroup/link/api/MeasureEvaluator.java index 9eba82c1e..63fbead71 100644 --- a/api/src/main/java/com/lantanagroup/link/api/MeasureEvaluator.java +++ b/api/src/main/java/com/lantanagroup/link/api/MeasureEvaluator.java @@ -46,8 +46,6 @@ private MeasureReport generateMeasureReport() { MeasureReport measureReport; String patientDataBundleId = ReportIdHelper.getPatientDataBundleId(reportContext.getMasterIdentifierValue(), patientId); String measureId = this.measureContext.getMeasure().getIdElement().getIdPart(); - String start = this.criteria.getPeriodStart().substring(0, this.criteria.getPeriodStart().indexOf(".")); - String end = this.criteria.getPeriodEnd().substring(0, this.criteria.getPeriodEnd().indexOf(".")); Bundle patientBundle; try (Stopwatch stopwatch = this.stopwatchManager.start(Constants.TASK_RETRIEVE_PATIENT_DATA, Constants.CATEGORY_REPORT)) { @@ -60,7 +58,7 @@ private MeasureReport generateMeasureReport() { Helper.dumpToFile(patientBundle, config.getDebugPath(), fileName); } - logger.info("Executing $evaluate-measure for measure: {}, start: {}, end: {}, patient: {}, resources: {}", measureId, start, end, patientId, patientBundle.getEntry().size()); + logger.info("Executing $evaluate-measure for measure: {}, start: {}, end: {}, patient: {}, resources: {}", measureId, criteria.getPeriodStart(), criteria.getPeriodEnd(), patientId, patientBundle.getEntry().size()); //noinspection unused try (Stopwatch stopwatch = this.stopwatchManager.start(Constants.TASK_MEASURE, Constants.CATEGORY_EVALUATE)) { diff --git a/api/src/main/java/com/lantanagroup/link/api/auth/PreAuthTokenHeaderFilter.java b/api/src/main/java/com/lantanagroup/link/api/auth/PreAuthTokenHeaderFilter.java index 6d99aaf8c..8b8fa53d8 100644 --- a/api/src/main/java/com/lantanagroup/link/api/auth/PreAuthTokenHeaderFilter.java +++ b/api/src/main/java/com/lantanagroup/link/api/auth/PreAuthTokenHeaderFilter.java @@ -10,17 +10,17 @@ import com.lantanagroup.link.config.api.ApiConfig; import com.lantanagroup.link.db.SharedService; import com.lantanagroup.link.db.model.User; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; @@ -52,7 +52,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha logger.debug("Validating the requesting IP address against the token IP address."); DecodedJWT jwt = JWT.decode(authHeader.substring(7)); - if (!jwt.getClaim("ip").isNull() && !"0:0:0:0:0:0:0:1(0:0:0:0:0:0:0:1)".equals(ipAddress) && !jwt.getClaim("ip").asString().equals(ipAddress)) { + if (!jwt.getClaim("ip").isNull() && !"127.0.0.1(127.0.0.1)".equals(ipAddress) && !"0:0:0:0:0:0:0:1(0:0:0:0:0:0:0:1)".equals(ipAddress) && !jwt.getClaim("ip").asString().equals(ipAddress)) { throw new JWTVerificationException("IP Address does not match."); } } diff --git a/api/src/main/java/com/lantanagroup/link/api/controller/GlobalReportController.java b/api/src/main/java/com/lantanagroup/link/api/controller/GlobalReportController.java index 4bc20ccbe..b5f5de454 100644 --- a/api/src/main/java/com/lantanagroup/link/api/controller/GlobalReportController.java +++ b/api/src/main/java/com/lantanagroup/link/api/controller/GlobalReportController.java @@ -37,25 +37,17 @@ public List getReports( if (!startDate.isEmpty()) { Date date = Helper.parseFhirDate(startDate); - reports = reports.stream().filter(x -> { - try { - return Helper.parseFhirDate(x.getPeriodStart()).after(date) || Helper.parseFhirDate(x.getPeriodStart()).equals(date); - } catch (ParseException e) { - throw new RuntimeException(e); - } - }).collect(Collectors.toList()); + reports = reports.stream().filter(x -> + Helper.parseFhirDate(x.getPeriodStart()).after(date) || Helper.parseFhirDate(x.getPeriodStart()).equals(date) + ).collect(Collectors.toList()); } if (!endDate.isEmpty()) { Date date = Helper.parseFhirDate(endDate); - reports = reports.stream().filter(x -> { - try { - return Helper.parseFhirDate(x.getPeriodEnd()).before(date) || Helper.parseFhirDate(x.getPeriodStart()).equals(date); - } catch (ParseException e) { - throw new RuntimeException(e); - } - }).collect(Collectors.toList()); + reports = reports.stream().filter(x -> + Helper.parseFhirDate(x.getPeriodEnd()).before(date) || Helper.parseFhirDate(x.getPeriodStart()).equals(date) + ).collect(Collectors.toList()); } if (!tenantId.isEmpty()) { @@ -66,8 +58,7 @@ public List getReports( reports = reports.stream().filter(x -> x.getStatus().equals(ReportStatuses.valueOf(status))).collect(Collectors.toList()); } - if(measureIds.length() > 0) - { + if (!measureIds.isEmpty()) { String[] ids = measureIds.split(","); reports = reports.stream().filter(x -> { HashSet measureIdsSet = new HashSet<>(x.getMeasureIds()); @@ -80,7 +71,11 @@ public List getReports( }).collect(Collectors.toList()); } - reports = reports.stream().skip((long) (page -1) * count).limit(count).collect(Collectors.toList()); + reports = reports + .stream() + .skip((long) (page - 1) * count) + .limit(count) + .collect(Collectors.toList()); return reports; } diff --git a/api/src/main/java/com/lantanagroup/link/api/controller/LogController.java b/api/src/main/java/com/lantanagroup/link/api/controller/LogController.java index 2671830d6..57d777f16 100644 --- a/api/src/main/java/com/lantanagroup/link/api/controller/LogController.java +++ b/api/src/main/java/com/lantanagroup/link/api/controller/LogController.java @@ -34,12 +34,8 @@ public LogSearchResponse getLogs(@RequestParam(required = false) String startDat Date startDateObj; Date endDateObj; - try { - startDateObj = Helper.parseFhirDate(startDate); - endDateObj = Helper.parseFhirDate(endDate); - } catch (ParseException ex) { - throw new IllegalArgumentException("Invalid date format, must be in the format yyyy-MM-dd'T'HH:mm:ss.SSSXXX or yyyy-MM-dd'T'HH:mm:ssXXX"); - } + startDateObj = Helper.parseFhirDate(startDate); + endDateObj = Helper.parseFhirDate(endDate); List logMessages = this.sharedService.findLogMessages(startDateObj, endDateObj, severity, page, content); LogSearchResponse res = new LogSearchResponse(); diff --git a/api/src/main/java/com/lantanagroup/link/api/controller/MeasureDefController.java b/api/src/main/java/com/lantanagroup/link/api/controller/MeasureDefController.java index c8d139d21..3458ef863 100644 --- a/api/src/main/java/com/lantanagroup/link/api/controller/MeasureDefController.java +++ b/api/src/main/java/com/lantanagroup/link/api/controller/MeasureDefController.java @@ -2,11 +2,11 @@ import ca.uhn.fhir.rest.api.EncodingEnum; import com.lantanagroup.link.FhirContextProvider; -import com.lantanagroup.link.FhirDataProvider; import com.lantanagroup.link.FhirHelper; import com.lantanagroup.link.Helper; import com.lantanagroup.link.api.MeasureServiceWrapper; import com.lantanagroup.link.config.api.ApiConfig; +import com.lantanagroup.link.config.api.MeasureDefConfig; import com.lantanagroup.link.db.SharedService; import com.lantanagroup.link.db.model.MeasureDefinition; import com.lantanagroup.link.db.model.MeasurePackage; @@ -86,11 +86,12 @@ public void createOrUpdateMeasureDef(@RequestBody(required = false) Bundle bundl throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Either a Bundle must be specified in a JSON body of the request, or a \"measureId\" query parameter must be specified"); } - if (StringUtils.isNotEmpty(measureId) && this.apiConfig.getMeasureDefUrls().get(measureId) == null) { + MeasureDefConfig foundMeasureDef = this.apiConfig.getMeasureDefinition(measureId); + if (StringUtils.isNotEmpty(measureId) && foundMeasureDef == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "The specified measureId is not configured with a measure definition URL"); } - String url = StringUtils.isNotEmpty(measureId) ? this.apiConfig.getMeasureDefUrls().get(measureId) : null; + String url = foundMeasureDef != null ? foundMeasureDef.getDefinitionUrl() : null; Bundle bundle = bundleBody == null ? this.getBundleFromUrl(url) : bundleBody; if (bundle == null) { diff --git a/api/src/main/java/com/lantanagroup/link/api/controller/ReportController.java b/api/src/main/java/com/lantanagroup/link/api/controller/ReportController.java index 24c2b5302..7fecee6f6 100644 --- a/api/src/main/java/com/lantanagroup/link/api/controller/ReportController.java +++ b/api/src/main/java/com/lantanagroup/link/api/controller/ReportController.java @@ -6,6 +6,7 @@ import com.lantanagroup.link.*; import com.lantanagroup.link.api.ReportGenerator; import com.lantanagroup.link.auth.LinkCredentials; +import com.lantanagroup.link.config.api.ApiConfig; import com.lantanagroup.link.db.SharedService; import com.lantanagroup.link.db.TenantService; import com.lantanagroup.link.db.model.*; @@ -82,6 +83,10 @@ public class ReportController extends BaseController { @Autowired private ValidationService validationService; + @Autowired + private ApiConfig apiConfig; + + @InitBinder public void initBinder(WebDataBinder binder) { binder.setDisallowedFields(DISALLOWED_FIELDS); @@ -275,15 +280,15 @@ public Report generateReport( } private void checkReportingPlan(TenantService tenantService, String periodStart, List measureIds) throws ParseException, URISyntaxException, IOException { - if (tenantService.getConfig().getReportingPlan() == null) { + if (apiConfig.getReportingPlan() == null) { return; } - if (!tenantService.getConfig().getReportingPlan().isEnabled()) { + if (!apiConfig.getReportingPlan().isEnabled()) { return; } - if (StringUtils.isEmpty(tenantService.getConfig().getReportingPlan().getUrl())) { + if (StringUtils.isEmpty(apiConfig.getReportingPlan().getUrl())) { logger.error("Reporting plan for tenant {} is not configured with a URL", tenantService.getConfig().getId()); throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR); } @@ -293,13 +298,13 @@ private void checkReportingPlan(TenantService tenantService, String periodStart, throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR); } - ReportingPlanService reportingPlanService = new ReportingPlanService(tenantService.getConfig().getReportingPlan(), tenantService.getConfig().getCdcOrgId()); + ReportingPlanService reportingPlanService = new ReportingPlanService(apiConfig.getReportingPlan(), tenantService.getConfig().getCdcOrgId()); Date date = Helper.parseFhirDate(periodStart); int year = date.getYear() + 1900; int month = date.getMonth() + 1; for (String bundleId : measureIds) { - String planName = tenantService.getConfig().getReportingPlan().getPlanNames().get(bundleId); + String planName = apiConfig.getReportingPlan().getPlanNames().get(bundleId); if (!reportingPlanService.isReporting(planName, year, month)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Measure not in MRP for specified year and month"); } diff --git a/api/src/main/java/com/lantanagroup/link/api/controller/TenantController.java b/api/src/main/java/com/lantanagroup/link/api/controller/TenantController.java index 784fd3c07..5bf9a5daf 100644 --- a/api/src/main/java/com/lantanagroup/link/api/controller/TenantController.java +++ b/api/src/main/java/com/lantanagroup/link/api/controller/TenantController.java @@ -4,6 +4,7 @@ import com.lantanagroup.link.api.scheduling.Scheduler; import com.lantanagroup.link.db.SharedService; import com.lantanagroup.link.db.TenantService; +import com.lantanagroup.link.db.model.Report; import com.lantanagroup.link.db.model.tenant.Tenant; import com.lantanagroup.link.db.model.tenant.TenantVendors; import com.lantanagroup.link.model.SearchTenantResponse; @@ -49,7 +50,7 @@ public List searchTenants() { } @GetMapping("summary") - public TenantSummaryResponse searchTenantSummaries(@RequestParam(required = false) String searchCriteria, @RequestParam(defaultValue = "NAME", required = false) String sort, @RequestParam(defaultValue = "1", required = false) int page, @RequestParam(defaultValue = "true", required = false) boolean sortAscend) { + public TenantSummaryResponse searchTenants(@RequestParam(required = false) String searchCriteria, @RequestParam(defaultValue = "NAME", required = false) String sort, @RequestParam(defaultValue = "1", required = false) int page, @RequestParam(defaultValue = "true", required = false) boolean sortAscend) { // validation int itemsPerPage = 5; @@ -67,9 +68,22 @@ public TenantSummaryResponse searchTenantSummaries(@RequestParam(required = fals throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sort criteria. Valid values are NAME, NHSN_ORG_ID, SUBMISSION_DATE"); } } - List tenants = this.sharedService.getTenantSummary(searchCriteria, TenantSummarySort.valueOf(sort.trim()), sortAscend).stream().collect(Collectors.toList()); - List results = tenants.stream().skip(skip).limit(itemsPerPage).collect(Collectors.toList()); + List tenants = this.sharedService.searchTenants(searchCriteria, TenantSummarySort.valueOf(sort.trim()), sortAscend); + List results = tenants.stream() + .skip(skip) + .limit(itemsPerPage) + .collect(Collectors.toList()); + + results.forEach(t -> { + TenantService tenantService = TenantService.create(this.sharedService.getTenantConfig(t.getId())); + Report lastReport = tenantService.findLastReport(); + + if (lastReport != null) { + t.setLastSubmissionId(lastReport.getId()); + t.setLastSubmissionDate(String.valueOf(lastReport.getSubmittedTime())); + } + }); TenantSummaryResponse response = new TenantSummaryResponse(); response.setTotal(tenants.size()); diff --git a/api/src/main/java/com/lantanagroup/link/api/controller/ValidationController.java b/api/src/main/java/com/lantanagroup/link/api/controller/ValidationController.java index a1e435519..54c390db4 100644 --- a/api/src/main/java/com/lantanagroup/link/api/controller/ValidationController.java +++ b/api/src/main/java/com/lantanagroup/link/api/controller/ValidationController.java @@ -49,7 +49,6 @@ public class ValidationController extends BaseController { /** * Validates a Bundle provided in the request body * - * @param tenantId The id of the tenant * @param severity The minimum severity level to report on * @return Returns an OperationOutcome resource that provides details about each of the issues found */ @@ -73,7 +72,6 @@ public OperationOutcome validate(@RequestBody Bundle bundle, @RequestParam(defau /** * Validates a Bundle provided in the request body * - * @param tenantId The id of the tenant * @param severity The minimum severity level to report on * @return Returns an OperationOutcome resource that provides details about each of the issues found */ @@ -92,7 +90,9 @@ public String validateSummary(@RequestBody Bundle bundle, @RequestParam(defaultV * @throws IOException */ @GetMapping("/{tenantId}/{reportId}") - public OperationOutcome getValidationIssuesForReport(@PathVariable String tenantId, @PathVariable String reportId, @RequestParam(defaultValue = "INFORMATION") OperationOutcome.IssueSeverity severity, @RequestParam(required = false) String code) { + public OperationOutcome getValidationIssuesForReport(@PathVariable String tenantId, @PathVariable String reportId, + @RequestParam(defaultValue = "INFORMATION") OperationOutcome.IssueSeverity severity, + @RequestParam(required = false) String code) { TenantService tenantService = TenantService.create(this.sharedService, tenantId); if (tenantService == null) { diff --git a/core/src/main/java/com/lantanagroup/link/ApplyConceptMaps.java b/core/src/main/java/com/lantanagroup/link/ApplyConceptMaps.java index 5cb0053a6..fbdb2462f 100644 --- a/core/src/main/java/com/lantanagroup/link/ApplyConceptMaps.java +++ b/core/src/main/java/com/lantanagroup/link/ApplyConceptMaps.java @@ -15,7 +15,6 @@ public class ApplyConceptMaps { private static final Logger logger = LoggerFactory.getLogger(ApplyConceptMaps.class); - private final DefaultProfileValidationSupport validationSupport = new DefaultProfileValidationSupport(FhirContextProvider.getFhirContext()); private List conceptMaps; @@ -27,14 +26,8 @@ public static boolean isMapped(Coding coding, String system, String code) { .anyMatch(mappedCoding -> mappedCoding.is(system, code)); } - public ApplyConceptMaps() { - validationSupport.fetchAllStructureDefinitions(); - } + public ApplyConceptMaps() {} - private FHIRPathEngine getFhirPathEngine() { - HapiWorkerContext workerContext = new HapiWorkerContext(FhirContextProvider.getFhirContext(), validationSupport); - return new FHIRPathEngine(workerContext); - } private void translateCoding(ConceptMap map, Coding code) { map.getGroup().stream().forEach((ConceptMap.ConceptMapGroupComponent group) -> { @@ -81,7 +74,7 @@ public List filterResourcesByPathList(DomainResource resource, List results = new ArrayList<>(); // logger.debug(String.format("FindCodings for resource %s based on path %s", resource.getResourceType() + "/" + resource.getIdElement().getIdPart(), List.of(pathList))); pathList.stream().forEach(path -> { - results.addAll(getFhirPathEngine().evaluate(resource, path)); + results.addAll(FhirHelper.getFhirPathEngine().evaluate(resource, path)); }); return results; } diff --git a/core/src/main/java/com/lantanagroup/link/Constants.java b/core/src/main/java/com/lantanagroup/link/Constants.java index d7dfacba4..ac2cf2538 100644 --- a/core/src/main/java/com/lantanagroup/link/Constants.java +++ b/core/src/main/java/com/lantanagroup/link/Constants.java @@ -61,4 +61,9 @@ public class Constants { public static final String VALIDATION_ISSUE_TASK = "Validation"; public static final String VALIDATION_ISSUE_CATEGORY = "Validation Issues"; public static final String REPORT_GENERATION_TASK = "Report Generation"; + + //Submission file names + public static final String ORGANIZATION_FILE_NAME = "organization.json"; + public static final String DEVICE_FILE_NAME = "device.json"; + public static final String QUERY_PLAN_FILE_NAME = "query-plan.yml"; } diff --git a/core/src/main/java/com/lantanagroup/link/FhirBundleProcessor.java b/core/src/main/java/com/lantanagroup/link/FhirBundleProcessor.java index 110f41c9d..7b385fe2e 100644 --- a/core/src/main/java/com/lantanagroup/link/FhirBundleProcessor.java +++ b/core/src/main/java/com/lantanagroup/link/FhirBundleProcessor.java @@ -33,60 +33,77 @@ public class FhirBundleProcessor { @Getter private final List otherResources = new ArrayList<>(); + @Getter + private final HashMap bundleEntryIndexToFileMap = new HashMap<>(); + public FhirBundleProcessor(Bundle bundle) { this.bundle = bundle; - this.linkOrganization = this.bundle.getEntry().stream() - .filter(e -> { - return e.getResource().getResourceType().equals(ResourceType.Organization) && - e.getResource().getMeta().hasProfile(Constants.SubmittingOrganizationProfile); - }) - .findFirst() - .orElse(null); - - this.linkDevice = this.bundle.getEntry().stream() - .filter(e -> { - return e.getResource().getResourceType().equals(ResourceType.Device) && - e.getResource().getMeta().hasProfile(Constants.SubmittingDeviceProfile); - }) - .findFirst() - .orElse(null); - - this.linkQueryPlanLibrary = this.bundle.getEntry().stream() - .filter(e -> e.getResource().getResourceType().equals(ResourceType.Library)) - .filter(e -> { - Library library = (Library) e.getResource(); - return library.getType().getCoding().stream() - .anyMatch(c -> c.getCode().equals(Constants.LibraryTypeModelDefinitionCode)); - }) - .findFirst() - .orElse(null); - - this.linkCensusLists = this.bundle.getEntry().stream() - .filter(e -> - e.getResource().getResourceType().equals(ResourceType.List) && - e.getResource().getMeta().getProfile().stream() - .anyMatch(p -> p.getValue().equals(Constants.CensusProfileUrl))) - .sorted(new FhirBundlerEntrySorter.ResourceComparator()) - .collect(Collectors.toList()); - - this.aggregateMeasureReports = this.bundle.getEntry().stream() - .filter(e -> e.getResource().getResourceType().equals(ResourceType.MeasureReport) && ((MeasureReport) e.getResource()).getType().equals(MeasureReport.MeasureReportType.SUBJECTLIST)) - .sorted(new FhirBundlerEntrySorter.ResourceComparator()) - .collect(Collectors.toList()); - - for (Bundle.BundleEntryComponent e : bundle.getEntry()) { - String patientReference = FhirHelper.getPatientReference(e.getResource()); - if (patientReference != null) { + Bundle.BundleEntryComponent linkOrganization = null; + Bundle.BundleEntryComponent linkDevice = null; + Bundle.BundleEntryComponent linkQueryPlanLibrary = null; + List linkCensusLists = new ArrayList<>(); + List aggregateMeasureReports = new ArrayList<>(); + + for (int index = 0; index < bundle.getEntry().size(); index++) { + Bundle.BundleEntryComponent e = bundle.getEntry().get(index); + Resource resource = e.getResource(); + ResourceType resourceType = resource.getResourceType(); + String resourceId = resource.getIdElement().getIdPart(); + String patientReference = FhirHelper.getPatientReference(resource); + if(resourceType.equals(ResourceType.Organization) && + resource.getMeta().hasProfile(Constants.SubmittingOrganizationProfile)){ + if(linkOrganization == null){ + linkOrganization = e; + bundleEntryIndexToFileMap.put(index, Constants.ORGANIZATION_FILE_NAME); + } + } + else if(resourceType.equals(ResourceType.Device) && + resource.getMeta().hasProfile(Constants.SubmittingDeviceProfile)){ + if(linkDevice == null){ + linkDevice = e; + bundleEntryIndexToFileMap.put(index, Constants.DEVICE_FILE_NAME); + } + } + else if(resourceType.equals(ResourceType.Library) && ((Library) resource).getType().getCoding().stream() + .anyMatch(c -> c.getCode().equals(Constants.LibraryTypeModelDefinitionCode))){ + if(linkQueryPlanLibrary == null){ + linkQueryPlanLibrary = e; + bundleEntryIndexToFileMap.put(index, String.format("census-%s.json", resourceId)); + } + } + else if(resourceType.equals(ResourceType.List) && + resource.getMeta().getProfile().stream() + .anyMatch(p -> p.getValue().equals(Constants.CensusProfileUrl))){ + linkCensusLists.add(e); + bundleEntryIndexToFileMap.put(index, String.format("census-%s.json", + resource.getIdElement().getIdPart())); + } else if(resourceType.equals(ResourceType.MeasureReport) + && ((MeasureReport) resource).getType().equals(MeasureReport.MeasureReportType.SUBJECTLIST)) { + aggregateMeasureReports.add(e); + bundleEntryIndexToFileMap.put(index, String.format("aggregate-%s.json", resourceId)); + } else if (patientReference != null) { String patientId = patientReference.replace("Patient/", ""); if (!this.patientResources.containsKey(patientId)) { this.patientResources.put(patientId, new ArrayList<>()); } this.patientResources.get(patientId).add(e); - } else if (isOtherResource(e)) { + bundleEntryIndexToFileMap.put(index, String.format("patient-%s.json", patientId)); + } else { this.otherResources.add(e); + bundleEntryIndexToFileMap.put(index, "other-resources.json"); } } + + //Cleanup + this.linkOrganization = linkOrganization; + this.linkDevice = linkDevice; + this.linkQueryPlanLibrary = linkQueryPlanLibrary; + this.linkCensusLists = linkCensusLists.stream() + .sorted(new FhirBundlerEntrySorter.ResourceComparator()).collect(Collectors.toList()); + this.aggregateMeasureReports = aggregateMeasureReports.stream() + .sorted(new FhirBundlerEntrySorter.ResourceComparator()).collect(Collectors.toList()); + } private static boolean isSameResource(Bundle.BundleEntryComponent e1, Bundle.BundleEntryComponent e2) { diff --git a/core/src/main/java/com/lantanagroup/link/FhirHelper.java b/core/src/main/java/com/lantanagroup/link/FhirHelper.java index 10cdb9e82..40f7914ba 100644 --- a/core/src/main/java/com/lantanagroup/link/FhirHelper.java +++ b/core/src/main/java/com/lantanagroup/link/FhirHelper.java @@ -1,5 +1,6 @@ package com.lantanagroup.link; +import ca.uhn.fhir.context.support.DefaultProfileValidationSupport; import ca.uhn.fhir.parser.IParser; import com.fasterxml.jackson.databind.module.SimpleModule; import com.google.common.base.Strings; @@ -15,7 +16,9 @@ import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext; import org.hl7.fhir.r4.model.*; +import org.hl7.fhir.r4.utils.FHIRPathEngine; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,6 +33,13 @@ public class FhirHelper { private static final Logger logger = LoggerFactory.getLogger(FhirHelper.class); + private static final DefaultProfileValidationSupport validationSupport = + new DefaultProfileValidationSupport(FhirContextProvider.getFhirContext()); + + static { + validationSupport.fetchAllStructureDefinitions(); + } + public static org.hl7.fhir.r4.model.Address getFHIRAddress(Address address) { org.hl7.fhir.r4.model.Address ret = new org.hl7.fhir.r4.model.Address(); @@ -339,6 +349,11 @@ private static void addEventNotesToDevice(Device device, String eventCategory, L } } + public static FHIRPathEngine getFhirPathEngine() { + HapiWorkerContext workerContext = new HapiWorkerContext(FhirContextProvider.getFhirContext(), validationSupport); + return new FHIRPathEngine(workerContext); + } + /** * Returns patient's reference of the related resource * diff --git a/core/src/main/java/com/lantanagroup/link/Helper.java b/core/src/main/java/com/lantanagroup/link/Helper.java index de39e818d..b6e2f0ee4 100644 --- a/core/src/main/java/com/lantanagroup/link/Helper.java +++ b/core/src/main/java/com/lantanagroup/link/Helper.java @@ -13,6 +13,7 @@ import org.apache.commons.text.StringEscapeUtils; import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.CapabilityStatement; +import org.hl7.fhir.r4.model.DateTimeType; import org.hl7.fhir.r4.model.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,20 +67,8 @@ public static String getFhirDate(Date date) { return new SimpleDateFormat(SIMPLE_DATE_MILLIS_FORMAT).format(date); } - public static Date parseFhirDate(String dateStr) throws ParseException { - if (StringUtils.isEmpty(dateStr)) { - return null; - } - - SimpleDateFormat formatterMillis = new SimpleDateFormat(SIMPLE_DATE_MILLIS_FORMAT); - SimpleDateFormat formatterSec = new SimpleDateFormat(SIMPLE_DATE_SECONDS_FORMAT); - Date dateReturned; - try { - dateReturned = formatterMillis.parse(dateStr); - } catch (Exception ex) { - dateReturned = formatterSec.parse(dateStr); - } - return dateReturned; + public static Date parseFhirDate(String dateStr) { + return new DateTimeType(dateStr).getValue(); } public static List concatenate(List list1, List list2) { diff --git a/core/src/main/java/com/lantanagroup/link/config/api/ApiConfig.java b/core/src/main/java/com/lantanagroup/link/config/api/ApiConfig.java index 92b3e535c..2633e9b39 100644 --- a/core/src/main/java/com/lantanagroup/link/config/api/ApiConfig.java +++ b/core/src/main/java/com/lantanagroup/link/config/api/ApiConfig.java @@ -1,6 +1,8 @@ package com.lantanagroup.link.config.api; import com.lantanagroup.link.config.YamlPropertySourceFactory; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -8,10 +10,7 @@ import org.springframework.context.annotation.PropertySource; import org.springframework.validation.annotation.Validated; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.PositiveOrZero; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; @Getter @@ -159,9 +158,9 @@ public class ApiConfig { private boolean noScheduling = false; /** - * api.measure-def-urls
A set of URLs representing the latest measure definition, keyed by measure ID + * api.measure-definitions
Each of the measure definitions supported by the installation, by identifier, short name, long name and the url for where the definition lives. */ - private HashMap measureDefUrls = new HashMap<>(); + private List measureDefinitions = new ArrayList<>(); /** * Allows use of QA debugging endpoints. DO NOT ALLOW IN PRODUCTION!!!! @@ -175,4 +174,24 @@ public class ApiConfig { */ private String validationPackagesPath = "classpath:/packages/**"; + /** + * Configuration for how to query the MRP (reporting plan) interface at CDC/NHSN to determine if a facility/tenant + * is signed up to report during the calculated reporting period. + */ + private ReportingPlan reportingPlan; + + /** + * Finds a single measure definition by it's ID + * @param measureId The id of the measure to find + * @return MeasureDefConfig + */ + public MeasureDefConfig getMeasureDefinition(String measureId) { + return measureDefinitions + .stream() + .filter(m -> m.getId().equals(measureId)) + .findFirst() + .orElse(null); + } + + } diff --git a/core/src/main/java/com/lantanagroup/link/config/api/MeasureDefConfig.java b/core/src/main/java/com/lantanagroup/link/config/api/MeasureDefConfig.java new file mode 100644 index 000000000..a6d5c8d58 --- /dev/null +++ b/core/src/main/java/com/lantanagroup/link/config/api/MeasureDefConfig.java @@ -0,0 +1,31 @@ +package com.lantanagroup.link.config.api; + +import lombok.Getter; +import lombok.Setter; + +/** + * Configuration for a supported measure definition within the installation + */ +@Getter +@Setter +public class MeasureDefConfig { + /** + * The unique identifier for the measure definition + */ + private String id; + + /** + * The short name for the measure definition (i.e. "Hypo") + */ + private String shortName; + + /** + * The long name for the measure definition (i.e. "Hypoglycemia") + */ + private String longName; + + /** + * The URL where the latest version of the measure definition lives + */ + private String definitionUrl; +} diff --git a/core/src/main/java/com/lantanagroup/link/db/model/tenant/ReportingPlan.java b/core/src/main/java/com/lantanagroup/link/config/api/ReportingPlan.java similarity index 91% rename from core/src/main/java/com/lantanagroup/link/db/model/tenant/ReportingPlan.java rename to core/src/main/java/com/lantanagroup/link/config/api/ReportingPlan.java index 24f229ced..a8d76b40c 100644 --- a/core/src/main/java/com/lantanagroup/link/db/model/tenant/ReportingPlan.java +++ b/core/src/main/java/com/lantanagroup/link/config/api/ReportingPlan.java @@ -1,4 +1,4 @@ -package com.lantanagroup.link.db.model.tenant; +package com.lantanagroup.link.config.api; import lombok.Getter; import lombok.Setter; diff --git a/core/src/main/java/com/lantanagroup/link/db/SharedService.java b/core/src/main/java/com/lantanagroup/link/db/SharedService.java index c2935b1e1..1e6fe265a 100644 --- a/core/src/main/java/com/lantanagroup/link/db/SharedService.java +++ b/core/src/main/java/com/lantanagroup/link/db/SharedService.java @@ -11,11 +11,15 @@ import com.lantanagroup.link.Helper; import com.lantanagroup.link.auth.LinkCredentials; import com.lantanagroup.link.config.api.ApiConfig; +import com.lantanagroup.link.config.api.MeasureDefConfig; import com.lantanagroup.link.db.model.*; import com.lantanagroup.link.db.model.tenant.Tenant; import com.lantanagroup.link.model.*; import com.mchange.v2.c3p0.ComboPooledDataSource; import com.microsoft.sqlserver.jdbc.SQLServerException; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.r4.model.Bundle; import org.slf4j.Logger; @@ -23,16 +27,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import jakarta.annotation.PostConstruct; -import jakarta.servlet.http.HttpServletRequest; import javax.sql.DataSource; import java.io.IOException; import java.math.BigDecimal; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.sql.*; import java.text.SimpleDateFormat; import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.*; import java.util.function.Function; @@ -79,6 +81,9 @@ private static GlobalReportResponse getGlobalReportResponse(Tenant tenantConfig, logger.error("Error parsing measureIds", e); } + report.setTotalPatients(rs.getInt(9)); + report.setMaxTotalInIP(rs.getInt(10)); + return report; } @@ -821,7 +826,8 @@ public List getAllReports() { for (Tenant tenantConfig : this.getTenantConfigs()) { // TODO: Move to TenantService try (Connection conn = this.getSQLConnection(tenantConfig.getConnectionString())) { - PreparedStatement ps = conn.prepareStatement("SELECT id, version, status, generatedTime, submittedTime, periodStart, periodEnd, measureIds FROM [dbo].[report]"); + String getReportsSQL = IOUtils.resourceToString("/get-reports.sql", StandardCharsets.UTF_8); + PreparedStatement ps = conn.prepareStatement(getReportsSQL); ResultSet rs = ps.executeQuery(); while (rs.next()) { String reportId = rs.getString(1); @@ -831,7 +837,8 @@ public List getAllReports() { } catch (SQLException e) { logger.error("SQL exception while retrieving global reports from database", e); throw new RuntimeException(e); - } catch (JsonProcessingException e) { + } catch (IOException e) { + logger.error("Could not read get-reports.sql file", e); throw new RuntimeException(e); } } @@ -839,74 +846,39 @@ public List getAllReports() { return reports; } - private TenantSummary getTenantSummaryResponse(Tenant tenantConfig, ResultSet rs) throws SQLException { + private TenantSummary getTenantSummaryResponse(Tenant tenantConfig) { TenantSummary tenantSummary = new TenantSummary(); tenantSummary.setId(tenantConfig.getId()); tenantSummary.setName(tenantConfig.getName()); tenantSummary.setNhsnOrgId(tenantConfig.getCdcOrgId()); - try (Connection conn = this.getSQLConnection(tenantConfig.getConnectionString())) { - String sql = "SELECT (SELECT COUNT(*) FROM OPENJSON(patients)) " + - "FROM dbo.patientList " + - "WHERE periodEnd = ? " + - "and periodStart = ?"; - PreparedStatement ps = conn.prepareStatement(sql); - ps.setString(1, rs.getString(5)); - ps.setString(2, rs.getString(4)); - ResultSet tPRs = ps.executeQuery(); - while (tPRs.next()) { - tenantSummary.setTotalPopulation(tPRs.getString(1)); - break; - } - } catch (SQLException e) { - logger.error("SQL exception while compiling totalPatients from database", e); - throw new RuntimeException(e); + List measures = null; + + if (tenantConfig.getScheduling() != null && tenantConfig.getScheduling().getGenerateAndSubmitReports() != null) { + measures = tenantConfig + .getScheduling() + .getGenerateAndSubmitReports() + .stream() + .flatMap(gr -> gr.getMeasureIds().stream()) + .distinct() + .map(mid -> { + TenantSummaryMeasure measure = new TenantSummaryMeasure(); + measure.setId(mid); + MeasureDefConfig measureDefConfig = this.config.getMeasureDefinition(mid); + if (measureDefConfig != null) { + measure.setShortName(measureDefConfig.getShortName()); + measure.setLongName(measureDefConfig.getLongName()); + } + return measure; + }) + .collect(Collectors.toList()); } - String measureIds = rs.getString(2); - measureIds = measureIds.replaceAll("\\[", "").replaceAll("\\]", ""); - - List measures = Arrays.asList(measureIds.split(",")); - // get measures from measureIds string, split on comma, convert to list of TenantSummaryMeasure - List list = measures.stream().map(m -> { - var measure = new TenantSummaryMeasure(); - - try (Connection conn = this.getSQLConnection(tenantConfig.getConnectionString())){ - String sql = "SELECT (SELECT COUNT(*) FROM OPENJSON(report, '$.contained[0].entry')) " + - "FROM dbo.[aggregate] " + - "where reportId = ? "+ - "and measureId = ?"; - PreparedStatement ps = conn.prepareStatement(sql); - ps.setString(1, rs.getString(1)); - ps.setString(2, m.replaceAll("\"", "")); - ResultSet iPRs = ps.executeQuery(); - while (iPRs.next()) { - measure.setIncludedPopulation(iPRs.getString(1)); - break; - } - } catch (SQLException e) { - logger.error("SQL exception while compiling totalPatients from database", e); - throw new RuntimeException(e); - } - - m = m.replace("\"", ""); - measure.setId(m); - measure.setShortName(m); - measure.setLongName(m); - return measure; - }).collect(Collectors.toList()); - - tenantSummary.setMeasures(list); - tenantSummary.setLastSubmissionId(rs.getString(1)); - //convert time stamp to date yyyy-MM-dd HH:mm:ss - DateTimeFormatter dateTimeFormatter = null; - String date = rs.getTimestamp(3).toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - tenantSummary.setLastSubmissionDate(date); + tenantSummary.setMeasures(measures); return tenantSummary; } private static void sortTenantSummaryList(TenantSummarySort sort, boolean sortAscend, List tenantSummaryList) { - Map> sortColumnMapper = new HashMap<>(); sortColumnMapper.put(TenantSummarySort.NAME, TenantSummary::getName); @@ -920,33 +892,22 @@ private static void sortTenantSummaryList(TenantSummarySort sort, boolean sortAs } } - public List getTenantSummary(String searchCriteria, TenantSummarySort sort, boolean sortAscend) { - - List tenantSummaryList = new ArrayList<>(); - // filter the tenants - for (Tenant tenantConfig : this.getTenantConfigs()) { - // if search criteria is not empty, filter tenants by search criteria - if (!StringUtils.isBlank(searchCriteria)) { - if (!(tenantConfig.getName().toLowerCase().contains(searchCriteria.toLowerCase()) || tenantConfig.getId().toLowerCase().contains(searchCriteria.toLowerCase()) || tenantConfig.getCdcOrgId().toLowerCase().contains(searchCriteria.toLowerCase()))) { - continue; - } - } - // TODO: Move to TenantService - try (Connection conn = this.getSQLConnection(tenantConfig.getConnectionString())) { - PreparedStatement ps = conn.prepareStatement("SELECT id, measureIds, submittedTime, periodStart, periodEnd FROM [dbo].[report] WHERE submittedTime IS NOT NULL ORDER BY submittedTime DESC"); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - TenantSummary tenantSummary = getTenantSummaryResponse(tenantConfig, rs); - tenantSummaryList.add(tenantSummary); - } - } catch (SQLException e) { - logger.error("SQL exception while retrieving global reports from database", e); - throw new RuntimeException(e); - } - } + public List searchTenants(String searchCriteria, TenantSummarySort sort, boolean sortAscend) { + List tenantSummaries = this.getTenantConfigs() + .stream().filter(tenantConfig -> { + if (StringUtils.isEmpty(searchCriteria)) { + return true; + } + return StringUtils.containsIgnoreCase(tenantConfig.getName(), searchCriteria) || + StringUtils.containsIgnoreCase(tenantConfig.getId(), searchCriteria) || + StringUtils.containsIgnoreCase(tenantConfig.getCdcOrgId(), searchCriteria); + }) + .map(this::getTenantSummaryResponse) + .collect(Collectors.toList()); // sort by name or nhsnOrgId or last submission date - sortTenantSummaryList(sort, sortAscend, tenantSummaryList); - return tenantSummaryList; + sortTenantSummaryList(sort, sortAscend, tenantSummaries); + + return tenantSummaries; } } diff --git a/core/src/main/java/com/lantanagroup/link/db/TenantService.java b/core/src/main/java/com/lantanagroup/link/db/TenantService.java index 02af92801..190627b68 100644 --- a/core/src/main/java/com/lantanagroup/link/db/TenantService.java +++ b/core/src/main/java/com/lantanagroup/link/db/TenantService.java @@ -199,6 +199,10 @@ public List getReportsByPatientListId(UUID id) { return this.reports.findByPatientListId(id); } + public Report findLastReport() { + return this.reports.findLastReport(); + } + public List searchReports() { return this.reports.findAll(); } @@ -379,4 +383,8 @@ public Integer countUncategorizedValidationResults(String reportId) { public List getUncategorizedValidationResults(String reportId) { return this.validations.getUncategorized(reportId); } + + public String getOrganizationID(){ + return this.config.getCdcOrgId(); + } } diff --git a/core/src/main/java/com/lantanagroup/link/db/model/tenant/Tenant.java b/core/src/main/java/com/lantanagroup/link/db/model/tenant/Tenant.java index bcdcee20a..59db2b3f9 100644 --- a/core/src/main/java/com/lantanagroup/link/db/model/tenant/Tenant.java +++ b/core/src/main/java/com/lantanagroup/link/db/model/tenant/Tenant.java @@ -79,11 +79,6 @@ public class Tenant { */ private FhirQuery fhirQuery; - /** - * Configuration for how to query the MRP (reporting plan) interface at CDC/NHSN to determine if a facility/tenant - * is signed up to report during the calculated reporting period. - */ - private ReportingPlan reportingPlan; /** * ISO 8601 formatted duration in which to keep data for each tenant. Defaulted to 3 months. diff --git a/core/src/main/java/com/lantanagroup/link/db/repositories/ReportRepository.java b/core/src/main/java/com/lantanagroup/link/db/repositories/ReportRepository.java index f3388849c..cf5703fff 100644 --- a/core/src/main/java/com/lantanagroup/link/db/repositories/ReportRepository.java +++ b/core/src/main/java/com/lantanagroup/link/db/repositories/ReportRepository.java @@ -48,6 +48,12 @@ public List findByPatientListId(UUID patientListId) { return jdbc.query(sql, parameters, mapper); } + public Report findLastReport() { + String sql = "SELECT TOP 1 * FROM dbo.report WHERE submittedTime IS NOT NULL ORDER BY submittedTime DESC;"; + return jdbc.query(sql, mapper).stream() + .reduce(StreamUtils::toOnlyElement) + .orElse(null); + } private int insert(Report model) { String sql = "INSERT INTO dbo.report (id, measureIds, periodStart, periodEnd, status, version, generatedTime, submittedTime, deviceInfo, queryPlan) " + "VALUES (:id, :measureIds, :periodStart, :periodEnd, :status, :version, :generatedTime, :submittedTime, :deviceInfo, :queryPlan);"; diff --git a/core/src/main/java/com/lantanagroup/link/model/GlobalReportResponse.java b/core/src/main/java/com/lantanagroup/link/model/GlobalReportResponse.java index ad06bc4da..6ff114ff1 100644 --- a/core/src/main/java/com/lantanagroup/link/model/GlobalReportResponse.java +++ b/core/src/main/java/com/lantanagroup/link/model/GlobalReportResponse.java @@ -10,4 +10,6 @@ public class GlobalReportResponse extends Report { private String tenantId; private String tenantName; private String cdcOrgId; + private Integer totalPatients; + private Integer maxTotalInIP; } diff --git a/core/src/main/java/com/lantanagroup/link/model/TenantSummary.java b/core/src/main/java/com/lantanagroup/link/model/TenantSummary.java index 24b2f1de9..39454abec 100644 --- a/core/src/main/java/com/lantanagroup/link/model/TenantSummary.java +++ b/core/src/main/java/com/lantanagroup/link/model/TenantSummary.java @@ -4,7 +4,6 @@ import lombok.Setter; import java.util.ArrayList; -import java.util.Date; import java.util.List; @Getter @@ -15,6 +14,5 @@ public class TenantSummary { private String nhsnOrgId; private String lastSubmissionId; private String lastSubmissionDate; - private String totalPopulation; private List measures = new ArrayList<>(); } diff --git a/core/src/main/java/com/lantanagroup/link/model/TenantSummaryMeasure.java b/core/src/main/java/com/lantanagroup/link/model/TenantSummaryMeasure.java index 6c312bf0b..5467fd0d4 100644 --- a/core/src/main/java/com/lantanagroup/link/model/TenantSummaryMeasure.java +++ b/core/src/main/java/com/lantanagroup/link/model/TenantSummaryMeasure.java @@ -13,5 +13,4 @@ public class TenantSummaryMeasure { private String id; private String shortName; private String longName; - private String includedPopulation; } diff --git a/core/src/main/java/com/lantanagroup/link/sender/FileSystemSender.java b/core/src/main/java/com/lantanagroup/link/sender/FileSystemSender.java index 6eb2afbc8..ac94d8831 100644 --- a/core/src/main/java/com/lantanagroup/link/sender/FileSystemSender.java +++ b/core/src/main/java/com/lantanagroup/link/sender/FileSystemSender.java @@ -2,6 +2,7 @@ import ca.uhn.fhir.parser.IParser; import com.lantanagroup.link.*; +import com.lantanagroup.link.Constants; import com.lantanagroup.link.auth.LinkCredentials; import com.lantanagroup.link.config.sender.FileSystemSenderConfig; import com.lantanagroup.link.db.TenantService; @@ -11,9 +12,7 @@ import lombok.Setter; import org.apache.commons.lang3.StringUtils; import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.hl7.fhir.r4.model.Bundle; -import org.hl7.fhir.r4.model.Library; -import org.hl7.fhir.r4.model.Resource; +import org.hl7.fhir.r4.model.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -24,14 +23,15 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; + + import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.security.*; import java.text.SimpleDateFormat; -import java.util.Arrays; -import java.util.Date; +import java.util.*; import static com.google.common.primitives.Bytes.concat; @@ -52,16 +52,8 @@ private FileSystemSenderConfig.Formats getFormat() { return this.config.getFormat(); } - public Path getFilePath() { + public Path getFilePath(String type) { String suffix = ""; - String path; - - if (this.config == null || this.config.getPath() == null || this.config.getPath().isEmpty()) { - logger.info("Not configured with a path to store the submission bundle. Using the system temporary directory"); - throw new IllegalArgumentException("Error: Not configured with a path in FileSystemSender to store the submission bundle"); - } else { - path = Helper.expandEnvVars(this.config.getPath()); - } if (this.config.getIsBundle()) { switch (this.getFormat()) { @@ -71,10 +63,25 @@ public Path getFilePath() { case JSON: suffix = ".json"; break; + default: + throw new RuntimeException("No suffix specified for submission file"); } } - String fileName = "submission-" + (new SimpleDateFormat("yyyyMMdd'T'HHmmss").format(new Date())) + suffix; + return this.getFilePath(type, suffix); + } + + public Path getFilePath(String type, String suffix) { + String path; + + if (this.config == null || this.config.getPath() == null || this.config.getPath().isEmpty()) { + logger.info("Not configured with a path to store the submission bundle. Using the system temporary directory"); + throw new IllegalArgumentException("Error: Not configured with a path in FileSystemSender to store the submission bundle"); + } else { + path = Helper.expandEnvVars(this.config.getPath()); + } + + String fileName = type + "-" + (new SimpleDateFormat("yyyyMMdd'T'HHmmss").format(new Date())) + suffix; return Paths.get(path, fileName); } @@ -154,7 +161,7 @@ private void saveToFile(Resource resource, String path) throws Exception { logger.info("Saved submission bundle to file system: {}", path); } - private void saveToFolder(Bundle bundle, String path, TenantService tenantService, Report report) throws Exception { + private void saveToFolder(TenantService tenantService, Report report, Bundle bundle, OperationOutcome outcome, String path) throws Exception { File folder = new File(path); if (!folder.exists() && !folder.mkdirs()) { @@ -167,39 +174,47 @@ private void saveToFolder(Bundle bundle, String path, TenantService tenantServic // Save link resources logger.debug("Saving link resources"); if (fhirBundleProcessor.getLinkOrganization() != null) { - this.saveToFile(fhirBundleProcessor.getLinkOrganization().getResource(), Paths.get(path, "organization.json").toString()); + this.saveToFile(fhirBundleProcessor.getLinkOrganization().getResource(), + Paths.get(path, Constants.ORGANIZATION_FILE_NAME).toString()); } if (fhirBundleProcessor.getLinkDevice() != null) { - this.saveToFile(fhirBundleProcessor.getLinkDevice().getResource(), Paths.get(path, "device.json").toString()); + this.saveToFile(fhirBundleProcessor.getLinkDevice().getResource(), + Paths.get(path, Constants.DEVICE_FILE_NAME).toString()); } if (fhirBundleProcessor.getLinkQueryPlanLibrary() != null) { Library library = (Library) fhirBundleProcessor.getLinkQueryPlanLibrary().getResource(); - this.saveToFile(library.getContentFirstRep().getData(), Paths.get(path, "query-plan.yml").toString()); + this.saveToFile(library.getContentFirstRep().getData(), + Paths.get(path, Constants.QUERY_PLAN_FILE_NAME).toString()); } // Save aggregate measure reports logger.debug("Saving aggregate measure reports"); - if (!fhirBundleProcessor.getAggregateMeasureReports().isEmpty()) { - for (int i = 0; i < fhirBundleProcessor.getAggregateMeasureReports().size(); i++) { - this.saveToFile(fhirBundleProcessor.getAggregateMeasureReports().get(i).getResource(), Paths.get(path, String.format("aggregate-%d.json", i + 1)).toString()); + List aggregates = fhirBundleProcessor.getAggregateMeasureReports(); + if (aggregates != null && !aggregates.isEmpty()) { + for (Bundle.BundleEntryComponent aggregate : aggregates) { + Resource aggregateReport = aggregate.getResource(); + this.saveToFile(aggregateReport, Paths.get(path, String.format("aggregate-%s.json", aggregateReport.getIdElement().getIdPart())).toString()); } } // Save census lists logger.debug("Saving census lists"); - if (fhirBundleProcessor.getLinkCensusLists() != null && !fhirBundleProcessor.getLinkCensusLists().isEmpty()) { - for (int i = 0; i < fhirBundleProcessor.getLinkCensusLists().size(); i++) { - this.saveToFile(fhirBundleProcessor.getLinkCensusLists().get(i).getResource(), Paths.get(path, String.format("census-%d.json", i + 1)).toString()); + List lists = fhirBundleProcessor.getLinkCensusLists(); + if (lists != null && !lists.isEmpty()) { + for (Bundle.BundleEntryComponent entry : lists) { + Resource list = entry.getResource(); + this.saveToFile(list, + Paths.get(path, String.format("census-%s.json", list.getIdElement().getIdPart())).toString()); } } // Save patient resources logger.debug("Saving patient resources as patient bundles"); - - if (!fhirBundleProcessor.getPatientResources().isEmpty()) { - for (String patientId : fhirBundleProcessor.getPatientResources().keySet()) { + Set patientIds = fhirBundleProcessor.getPatientResources().keySet(); + if (!patientIds.isEmpty()) { + for (String patientId : patientIds) { Bundle patientBundle = new Bundle(); patientBundle.setType(Bundle.BundleType.COLLECTION); patientBundle.getEntry().addAll(fhirBundleProcessor.getPatientResources().get(patientId)); @@ -220,10 +235,29 @@ private void saveToFolder(Bundle bundle, String path, TenantService tenantServic // Save validation results as HTML logger.debug("Saving validation results as HTML"); - String html = new ValidationCategorizer().getValidationCategoriesAndResultsHtml(tenantService, report); + String html = this.getValidationReportHTML(tenantService, report); if (StringUtils.isNotEmpty(html)) { this.saveToFile(html.getBytes(StandardCharsets.UTF_8), Paths.get(path, "validation-report.html").toString()); } + + // Annotating validation results with what file each issue occurs in and saving + logger.debug("Annotating and saving validation results"); + List issues = outcome.getIssue(); + issues.forEach(i -> { + if(i.hasExpression()){ + String expression = i.getExpression().get(0).toString(); + if(expression.contains("Bundle.entry[")){ + String entryIndex = expression.substring(expression.indexOf("Bundle.entry[") + 13, expression.indexOf("]")); + i.setDiagnostics(fhirBundleProcessor.getBundleEntryIndexToFileMap().get(Integer.parseInt(entryIndex))); + } + } + }); + this.saveToFile(outcome, Paths.get(path, "validation-results.json").toString()); + } + + private String getValidationReportHTML(TenantService tenantService, Report report) throws IOException { + return new ValidationCategorizer() + .getValidationCategoriesAndResultsHtml(tenantService, report); } @SuppressWarnings("unused") @@ -239,12 +273,28 @@ public void send(TenantService tenantService, Bundle submissionBundle, Report re this.config.getFormat(), StringUtils.isEmpty(this.config.getEncryptSecret()) ? "without" : "with"); - String path = this.getFilePath().toString(); + + OperationOutcome outcome = tenantService.getValidationResultsOperationOutcome( + report.getId(), + OperationOutcome.IssueSeverity.INFORMATION, + null); if (this.config.getIsBundle()) { - this.saveToFile(submissionBundle, path); + this.saveToFile(submissionBundle, this.getFilePath("submission").toString()); + this.saveToFile(outcome, this.getFilePath("validation").toString()); + + // Save validation results as HTML + logger.debug("Saving validation results as HTML"); + String html = this.getValidationReportHTML(tenantService, report); + if (StringUtils.isNotEmpty(html)) { + this.saveToFile(html.getBytes(StandardCharsets.UTF_8), this.getFilePath("validation", ".html").toString()); + } } else { - this.saveToFolder(submissionBundle, path, tenantService, report); + String orgId = tenantService.getOrganizationID(); + String path = orgId != null && !orgId.isEmpty() ? + this.getFilePath(orgId).toString() : + this.getFilePath("submission").toString(); + this.saveToFolder(tenantService, report, submissionBundle, outcome, path); } } } diff --git a/core/src/main/resources/get-reports.sql b/core/src/main/resources/get-reports.sql new file mode 100644 index 000000000..8fe4de51d --- /dev/null +++ b/core/src/main/resources/get-reports.sql @@ -0,0 +1,22 @@ +SELECT r.id, + r.[version], + r.[status], + r.generatedTime, + r.submittedTime, + r.periodStart, + r.periodEnd, + r.measureIds, + counts.totalPatients, + counts.maxTotalInIP +FROM dbo.report r + LEFT JOIN (SELECT R.id, + MAX(PL.patientCount) AS totalPatients, + MAX(A.ipPatientCount) AS maxTotalInIP + FROM dbo.report AS R + INNER JOIN dbo.reportPatientList AS RPL ON R.id = RPL.reportId + INNER JOIN (SELECT *, (SELECT COUNT(*) FROM OPENJSON(patients)) AS patientCount + FROM dbo.patientList) AS PL ON RPL.patientListId = PL.id + INNER JOIN (SELECT *, + (SELECT COUNT(*) FROM OPENJSON(report, '$.contained[0].entry')) AS ipPatientCount + FROM dbo.[aggregate]) AS A ON R.id = A.reportId + GROUP BY R.id) AS counts ON r.id = counts.id \ No newline at end of file diff --git a/core/src/test/java/com/lantanagroup/link/sender/FileSystemSenderTests.java b/core/src/test/java/com/lantanagroup/link/sender/FileSystemSenderTests.java index 60bf7f882..879f74d38 100644 --- a/core/src/test/java/com/lantanagroup/link/sender/FileSystemSenderTests.java +++ b/core/src/test/java/com/lantanagroup/link/sender/FileSystemSenderTests.java @@ -13,7 +13,7 @@ public class FileSystemSenderTests { @Test public void getFilePathTest_NoConfigPath() { FileSystemSender sender = new FileSystemSender(); - Assert.assertThrows(IllegalArgumentException.class, () -> { sender.getFilePath(); }); + Assert.assertThrows(IllegalArgumentException.class, () -> { sender.getFilePath("submission"); }); } @Test @@ -23,7 +23,7 @@ public void getFilePathTest_ConfigPath() { FileSystemSender sender = new FileSystemSender(); sender.setConfig(config); - Path path = sender.getFilePath(); + Path path = sender.getFilePath("submission"); Assert.assertNotNull(path); Assert.assertTrue(path.toString().startsWith(config.getPath())); } diff --git a/nhsn/src/main/java/com/lantanagroup/link/nhsn/ReportingPlanService.java b/nhsn/src/main/java/com/lantanagroup/link/nhsn/ReportingPlanService.java index 9b8c7fbef..26096337c 100644 --- a/nhsn/src/main/java/com/lantanagroup/link/nhsn/ReportingPlanService.java +++ b/nhsn/src/main/java/com/lantanagroup/link/nhsn/ReportingPlanService.java @@ -5,20 +5,16 @@ import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.lantanagroup.link.Helper; -import com.lantanagroup.link.db.model.tenant.ReportingPlan; +import com.lantanagroup.link.config.api.ReportingPlan; import lombok.Getter; import lombok.Setter; import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringEscapeUtils; import org.apache.http.HttpHeaders; -import org.apache.http.NameValuePair; import org.apache.http.client.HttpClient; -import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; import org.apache.http.client.utils.URIBuilder; import org.apache.http.impl.client.HttpClients; -import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,7 +27,6 @@ import java.io.IOException; import java.net.URISyntaxException; -import java.util.ArrayList; import java.util.List; public class ReportingPlanService { diff --git a/web/src/app/helpers/ReportHelper.ts b/web/src/app/helpers/ReportHelper.ts index 0d968b164..461b37eb3 100644 --- a/web/src/app/helpers/ReportHelper.ts +++ b/web/src/app/helpers/ReportHelper.ts @@ -1,4 +1,4 @@ -import { Report } from "../shared/interfaces/report.model"; +import {Report} from "../shared/interfaces/report.model"; type StatusType = 'pending' | 'submitted' | 'failed'; @@ -38,6 +38,8 @@ export function generateRandomData(numEntries: number): Report[] { status: status, version: "1.0", patientLists: [], + totalPatients: Math.floor(Math.random() * 1000), + maxTotalInIP: Math.floor(Math.random() * 1000) }); } return randomData; @@ -378,4 +380,4 @@ export const normalizationData: any = [ "Column4": "############", "Column5": "############" } -] \ No newline at end of file +] diff --git a/web/src/app/pages/activities/activities.component.ts b/web/src/app/pages/activities/activities.component.ts index 8cf6e4dd5..a49e26b2d 100644 --- a/web/src/app/pages/activities/activities.component.ts +++ b/web/src/app/pages/activities/activities.component.ts @@ -1,21 +1,21 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { from } from 'rxjs'; -import { HeroComponent } from 'src/app/shared/hero/hero.component'; -import { CardComponent } from 'src/app/shared/card/card.component'; -import { MetricComponent } from 'src/app/shared/metric/metric.component'; -import { DataTablesModule } from 'angular-datatables'; -import { TableComponent } from 'src/app/shared/table/table.component'; -import { SectionComponent } from 'src/app/shared/section/section.component'; -import { Report } from 'src/app/shared/interfaces/report.model'; -import { SearchBar } from 'src/app/shared/interfaces/table.model'; -import { ReportApiService } from 'src/services/api/report/report-api.service'; -import { MetricApiService } from 'src/services/api/metric/metric-api.service'; -import { MetricCard, TimePeriod } from 'src/app/shared/interfaces/metrics.model'; +import {Component, OnInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {from} from 'rxjs'; +import {HeroComponent} from 'src/app/shared/hero/hero.component'; +import {CardComponent} from 'src/app/shared/card/card.component'; +import {MetricComponent} from 'src/app/shared/metric/metric.component'; +import {DataTablesModule} from 'angular-datatables'; +import {TableComponent} from 'src/app/shared/table/table.component'; +import {SectionComponent} from 'src/app/shared/section/section.component'; +import {Report} from 'src/app/shared/interfaces/report.model'; +import {SearchBar} from 'src/app/shared/interfaces/table.model'; +import {ReportApiService} from 'src/services/api/report/report-api.service'; +import {MetricApiService} from 'src/services/api/metric/metric-api.service'; +import {MetricCard, TimePeriod} from 'src/app/shared/interfaces/metrics.model'; -import { calculatePeriodLength, formatDate } from 'src/app/helpers/ReportHelper'; -import { PascalCaseToSpace, ConvertDateString, ConvertToLocaleTime } from 'src/app/helpers/GlobalPipes.pipe'; -import { LoaderComponent } from 'src/app/shared/loader/loader.component'; +import {calculatePeriodLength, formatDate} from 'src/app/helpers/ReportHelper'; +import {ConvertDateString, ConvertToLocaleTime, PascalCaseToSpace} from 'src/app/helpers/GlobalPipes.pipe'; +import {LoaderComponent} from 'src/app/shared/loader/loader.component'; @Component({ selector: 'app-activities', @@ -99,7 +99,7 @@ export class ActivitiesComponent implements OnInit { calculateDtOptions(): DataTables.Settings { // DataTable configuration // ! for column ordering, will change - const columnIdMap = ['TIMESTAMP', 'ACTIVITY', 'DETAILS', 'FACILITY', 'NHSN_ORG_ID', 'REPORTING_PERIOD', 'MEASURES'], + const columnIdMap = ['TIMESTAMP', 'ACTIVITY', 'DETAILS', 'FACILITY', 'NHSN_ORG_ID', 'REPORTING_PERIOD', 'MEASURES', 'PATIENTS'], pageLength = 15 return { @@ -162,7 +162,7 @@ export class ActivitiesComponent implements OnInit { render: function (data, type, row) { let dotClass; switch (row.STATUS) { - case 'Submitted': + case 'Submitted': dotClass = 'success' break case 'Draft': @@ -171,7 +171,7 @@ export class ActivitiesComponent implements OnInit { default: dotClass = 'failed' } - + return `
${data} @@ -240,6 +240,11 @@ export class ActivitiesComponent implements OnInit { } return ''; } + }, + { + title: 'Patients', + data: columnIdMap[7], + orderable: false }] } @@ -291,6 +296,12 @@ export class ActivitiesComponent implements OnInit { activity = 'Successful Submission' } + let patients = 'N/A'; + + if (report.totalPatients >= 0 && report.maxTotalInIP >= 0) { + patients = report.maxTotalInIP + '/' + report.totalPatients; + } + return { ID: report.id, STATUS: status, @@ -303,7 +314,8 @@ export class ActivitiesComponent implements OnInit { MEASURES: report.measureIds.map(m => { const measure = this.pascalCaseToSpace.transform(m) return measure.split(' ')[0] - }) + }), + PATIENTS: patients }; }); } diff --git a/web/src/app/pages/facilities/facilities.component.ts b/web/src/app/pages/facilities/facilities.component.ts index 5ffe009e9..f71358ce3 100644 --- a/web/src/app/pages/facilities/facilities.component.ts +++ b/web/src/app/pages/facilities/facilities.component.ts @@ -1,16 +1,16 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { from } from 'rxjs'; -import { HeroComponent } from 'src/app/shared/hero/hero.component'; -import { IconComponent } from 'src/app/shared/icon/icon.component'; -import { ButtonComponent } from 'src/app/shared/button/button.component'; -import { SectionComponent } from 'src/app/shared/section/section.component'; -import { SectionHeadingComponent } from 'src/app/shared/section-heading/section-heading.component'; -import { TableComponent } from 'src/app/shared/table/table.component'; -import { Tenant } from 'src/app/shared/interfaces/tenant.model'; -import { SearchBar } from 'src/app/shared/interfaces/table.model'; -import { FacilitiesApiService } from 'src/services/api/facilities/facilities-api.service'; -import { PascalCaseToSpace, ConvertDateString, ConvertToLocaleTime } from 'src/app/helpers/GlobalPipes.pipe'; +import {Component, OnInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {from} from 'rxjs'; +import {HeroComponent} from 'src/app/shared/hero/hero.component'; +import {IconComponent} from 'src/app/shared/icon/icon.component'; +import {ButtonComponent} from 'src/app/shared/button/button.component'; +import {SectionComponent} from 'src/app/shared/section/section.component'; +import {SectionHeadingComponent} from 'src/app/shared/section-heading/section-heading.component'; +import {TableComponent} from 'src/app/shared/table/table.component'; +import {Tenant} from 'src/app/shared/interfaces/tenant.model'; +import {SearchBar} from 'src/app/shared/interfaces/table.model'; +import {FacilitiesApiService} from 'src/services/api/facilities/facilities-api.service'; +import {ConvertDateString, ConvertToLocaleTime, PascalCaseToSpace} from 'src/app/helpers/GlobalPipes.pipe'; @Component({ selector: 'app-facilities', @@ -28,7 +28,7 @@ export class FacilitiesComponent implements OnInit { private convertToLocaleTime = new ConvertToLocaleTime dtOptions: DataTables.Settings = {}; - + dtSearchBar: SearchBar = { title: 'Search Facilities', placeholder: 'Enter facility name, NHSN Org ID, etc.' @@ -105,13 +105,17 @@ export class FacilitiesComponent implements OnInit { data: columnIdMap[2], orderable: false, createdCell: (cell, cellData) => { - if (cellData.toLowerCase().includes('progress')) { + if (cellData && cellData.toLowerCase().includes('progress')) { $(cell).addClass('cell--initiated'); } else { $(cell).addClass('cell--complete'); } }, render: function (data, type, row) { + if (!row.DETAILS || row.DETAILS === 'No Scheduled Reports') { + return 'N/A'; + } + return `Bundle
#${data}
` } }, @@ -120,8 +124,11 @@ export class FacilitiesComponent implements OnInit { data: columnIdMap[3], orderable: true, render: function(data, type, row) { - let parts = data.split(' ', 2) - return parts[0] + '
' + data.substring(parts[0].length).trim() + if (data) { + let parts = data.split(' ', 2) + return parts[0] + '
' + data.substring(parts[0].length).trim() + } + return 'N/A'; } }, { @@ -144,7 +151,7 @@ export class FacilitiesComponent implements OnInit { // This is the method that would accept the reponse data from the api and process it further to be sent to the dt options. processDataForTable(tenantsData: Tenant[] | undefined) { - if(!tenantsData) + if (!tenantsData) return return tenantsData.map(td => { @@ -165,12 +172,12 @@ export class FacilitiesComponent implements OnInit { NHSN_ORG_ID: td.nhsnOrgId, DETAILS: td.lastSubmissionId, SUBMISSION_DATE: submissionDate, - MEASURES: td.measures + MEASURES: td.measures ? td.measures .filter(m => m && m.shortName) .map(m => { const measure = this.pascalCaseToSpace.transform(m.shortName.trimStart()) return measure.split(' ')[0] - }) + }) : 'No Scheduled Reports' }; }); } diff --git a/web/src/app/shared/interfaces/report.model.ts b/web/src/app/shared/interfaces/report.model.ts index 6c5e828ba..27a9a5f22 100644 --- a/web/src/app/shared/interfaces/report.model.ts +++ b/web/src/app/shared/interfaces/report.model.ts @@ -12,9 +12,11 @@ export interface Report { tenantId: string; tenantName: string; cdcOrgId: string; - reportId: string + reportId: string; details: string; -}; + totalPatients: number; + maxTotalInIP: number; +} export interface ReportFilter { tenantId?: string; @@ -29,4 +31,4 @@ export interface ReportFilter { export interface ReportSummary { total: number reports: Report[] -} \ No newline at end of file +} diff --git a/web/src/services/api/report/report-api.service.ts b/web/src/services/api/report/report-api.service.ts index 7a0fb5779..139951ea3 100644 --- a/web/src/services/api/report/report-api.service.ts +++ b/web/src/services/api/report/report-api.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@angular/core'; -import { DataService } from 'src/services/api/data.service'; -import { firstValueFrom } from 'rxjs'; -import { ReportFilter } from 'src/app/shared/interfaces/report.model'; +import {Injectable} from '@angular/core'; +import {DataService} from 'src/services/api/data.service'; +import {firstValueFrom} from 'rxjs'; +import {ReportFilter} from 'src/app/shared/interfaces/report.model'; @Injectable({ providedIn: 'root' @@ -22,7 +22,7 @@ export class ReportApiService { }); // Determine the URL to call based on whether there are filters - const url = queryParams.toString() ? `report/?${queryParams}` : 'report/'; + const url = queryParams.toString() ? `report?${queryParams}` : 'report/'; // Fetch the data const response = await firstValueFrom(this.dataService.getData(url));