diff --git a/README.md b/README.md index da92ce6..6496f97 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ pnpm dev - `POST /api/measures/{id}/cql/compile` - `PUT /api/measures/{id}/tests` - `POST /api/measures/{id}/tests/validate` +- `POST /api/measures/{id}/ai/draft-cql` — generate an AI-assisted CQL draft (with fallback template) +- `POST /api/measures/{id}/ai/generate-test-fixtures` — generate five AI-assisted outcome fixtures (with fallback set) - `GET /api/measures/{id}/data-readiness` — per-measure data readiness: required element mapping status, source freshness, missingness rate, blockers and warnings - `GET /api/admin/data-mappings` — list all canonical data element source mappings - `POST /api/admin/data-mappings/validate` — cross-reference integration health into mapping statuses (marks STALE on degraded source) @@ -83,6 +85,8 @@ pnpm dev - `GET /api/value-sets/{id}/detail` — full value set detail including governance metadata and code list - `GET /api/admin/terminology-mappings` — list local-to-standard terminology mappings with status and confidence - `POST /api/admin/terminology-mappings` — create a new terminology mapping (PROPOSED by default) +- `GET /api/programs/{measureId}/risk-outlook?horizonDays=30` — upcoming due-soon pressure, repeat non-compliers, and site risk rates +- `GET /api/measures/{measureId}/versions/{versionId}/export/mat?format=xml` — FHIR R4 MAT-compatible bundle export; requires `ROLE_APPROVER` or `ROLE_ADMIN` - `POST /api/runs/manual` (supports `ALL_PROGRAMS`, `MEASURE`, and `CASE` scopes) - `GET /api/runs?limit=1` - `GET /api/cases?status=open` diff --git a/backend/src/main/java/com/workwell/ai/AiAssistService.java b/backend/src/main/java/com/workwell/ai/AiAssistService.java index db8b634..94b59ea 100644 --- a/backend/src/main/java/com/workwell/ai/AiAssistService.java +++ b/backend/src/main/java/com/workwell/ai/AiAssistService.java @@ -8,11 +8,14 @@ import com.workwell.run.RunPersistenceService; import com.workwell.security.SecurityActor; import java.time.Instant; +import java.time.LocalDate; import java.util.ArrayList; import java.util.concurrent.ConcurrentHashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import org.springframework.ai.chat.client.ChatClient; import org.springframework.beans.factory.annotation.Value; @@ -198,6 +201,28 @@ You are an HL7 CQL (Clinical Quality Language) expert. You generate CQL librarie 13. Do NOT make compliance decisions — only compute from structured FHIR data """; + private static final List REQUIRED_FIXTURE_OUTCOMES = List.of( + "COMPLIANT", "DUE_SOON", "OVERDUE", "MISSING_DATA", "EXCLUDED" + ); + private static final String FIXTURE_SYSTEM_PROMPT = """ + You are a CQL test engineer. Generate test fixtures for occupational health compliance measures. + Return ONLY a valid JSON array of fixture objects. No explanation, no markdown. + + Each fixture: { + "name": "description", + "inputData": { + "examDate": "YYYY-MM-DD or null", + "programEnrolled": true/false, + "hasExemption": true/false, + "role": "string", + "site": "string" + }, + "expectedOutcome": "COMPLIANT|DUE_SOON|OVERDUE|MISSING_DATA|EXCLUDED" + } + + Generate exactly 5 fixtures covering all 5 outcome types. + """; + private String stripCodeFences(String raw) { if (raw == null) return ""; String trimmed = raw.trim(); @@ -247,6 +272,145 @@ private String buildFallbackCqlTemplate(String safeMeasureName) { """.formatted(safeMeasureName); } + public List generateTestFixtures(UUID measureId, String actor) { + if (measureId == null) { + throw new IllegalArgumentException("measureId is required"); + } + Map measureRow = jdbcTemplate.query( + """ + SELECT m.name AS name, COALESCE(mv.cql_text, '') AS cql_text + FROM measures m + JOIN measure_versions mv ON mv.measure_id = m.id + WHERE m.id = ? + ORDER BY mv.created_at DESC + LIMIT 1 + """, + rs -> rs.next() + ? Map.of("name", rs.getString("name"), "cqlText", rs.getString("cql_text")) + : null, + measureId + ); + if (measureRow == null) { + throw new IllegalArgumentException("Measure not found: " + measureId); + } + + String measureName = valueAsString(measureRow.get("name"), "Measure"); + String cqlText = valueAsString(measureRow.get("cqlText"), ""); + String prompt = "Measure name: " + measureName + "\n\n" + + "CQL library:\n" + cqlText + "\n\n" + + "Generate exactly 5 test fixtures covering each outcome type."; + + List fixtures; + boolean fallbackUsed; + String provider; + try { + String raw = callWithModelFallback(FIXTURE_SYSTEM_PROMPT, prompt); + fixtures = parseGeneratedFixtures(raw); + fallbackUsed = false; + provider = modelName; + } catch (Exception ex) { + fixtures = buildFallbackFixtures(); + fallbackUsed = true; + provider = "fallback-template"; + } + + insertAiAudit("AI_TEST_FIXTURES_GENERATED", actor, null, null, Map.of( + "measureId", measureId.toString(), + "measureName", measureName, + "count", fixtures.size(), + "model", provider, + "fallbackUsed", fallbackUsed + )); + return fixtures; + } + + private List parseGeneratedFixtures(String raw) throws JsonProcessingException { + String json = stripCodeFences(raw); + List> parsed = objectMapper.readValue(json, new TypeReference>>() {}); + List normalized = new ArrayList<>(); + for (int i = 0; i < parsed.size(); i++) { + Map row = parsed.get(i); + String expectedOutcome = valueAsString(row.get("expectedOutcome"), "").trim().toUpperCase(); + if (!REQUIRED_FIXTURE_OUTCOMES.contains(expectedOutcome)) { + continue; + } + String name = valueAsString(row.get("name"), "").trim(); + if (name.isBlank()) { + name = "Generated fixture " + (i + 1); + } + normalized.add(new GeneratedTestFixture( + name, + safeMap(row.get("inputData")), + expectedOutcome + )); + } + + Set seenOutcomes = new HashSet<>(); + for (GeneratedTestFixture fixture : normalized) { + seenOutcomes.add(fixture.expectedOutcome()); + } + if (!seenOutcomes.containsAll(REQUIRED_FIXTURE_OUTCOMES)) { + throw new IllegalStateException("Generated fixtures did not cover all required outcomes."); + } + + List ordered = new ArrayList<>(); + for (String requiredOutcome : REQUIRED_FIXTURE_OUTCOMES) { + GeneratedTestFixture match = normalized.stream() + .filter(fixture -> requiredOutcome.equals(fixture.expectedOutcome())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Missing generated fixture for outcome: " + requiredOutcome)); + ordered.add(match); + } + return ordered; + } + + private List buildFallbackFixtures() { + LocalDate today = LocalDate.now(); + List fallback = new ArrayList<>(); + fallback.add(new GeneratedTestFixture( + "Employee with exam 30 days ago → COMPLIANT", + fixtureInput(today.minusDays(30).toString(), true, false, "Maintenance Tech", "Plant A"), + "COMPLIANT" + )); + fallback.add(new GeneratedTestFixture( + "Employee with exam 340 days ago → DUE_SOON", + fixtureInput(today.minusDays(340).toString(), true, false, "Nurse", "Clinic"), + "DUE_SOON" + )); + fallback.add(new GeneratedTestFixture( + "Employee with exam 400 days ago → OVERDUE", + fixtureInput(today.minusDays(400).toString(), true, false, "Welder", "Plant B"), + "OVERDUE" + )); + fallback.add(new GeneratedTestFixture( + "Employee with no exam on file → MISSING_DATA", + fixtureInput(null, true, false, "Office Staff", "Plant A"), + "MISSING_DATA" + )); + fallback.add(new GeneratedTestFixture( + "Employee with medical exemption → EXCLUDED", + fixtureInput(null, true, true, "Industrial Hygienist", "Clinic"), + "EXCLUDED" + )); + return fallback; + } + + private Map fixtureInput( + String examDate, + boolean programEnrolled, + boolean hasExemption, + String role, + String site + ) { + Map inputData = new LinkedHashMap<>(); + inputData.put("examDate", examDate); + inputData.put("programEnrolled", programEnrolled); + inputData.put("hasExemption", hasExemption); + inputData.put("role", role); + inputData.put("site", site); + return inputData; + } + public CaseExplanationResponse explainCase(UUID caseId, String actor) { CaseFlowService.CaseDetail detail = caseFlowService.loadCase(caseId) .orElseThrow(() -> new IllegalArgumentException("Case not found")); @@ -559,6 +723,13 @@ public record DraftCqlResponse( ) { } + public record GeneratedTestFixture( + String name, + Map inputData, + String expectedOutcome + ) { + } + public record CaseExplanationResponse( String caseId, String explanation, diff --git a/backend/src/main/java/com/workwell/config/SecurityConfig.java b/backend/src/main/java/com/workwell/config/SecurityConfig.java index 87a5202..afe6ebe 100644 --- a/backend/src/main/java/com/workwell/config/SecurityConfig.java +++ b/backend/src/main/java/com/workwell/config/SecurityConfig.java @@ -108,6 +108,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/api/runs/**").hasAnyAuthority("ROLE_CASE_MANAGER", "ROLE_ADMIN") .requestMatchers(HttpMethod.POST, "/api/cases/**").hasAnyAuthority("ROLE_CASE_MANAGER", "ROLE_ADMIN") .requestMatchers(HttpMethod.GET, "/api/measures/*/traceability").authenticated() + .requestMatchers(HttpMethod.GET, "/api/measures/*/versions/*/export/mat").hasAnyAuthority("ROLE_APPROVER", "ROLE_ADMIN") .requestMatchers(HttpMethod.GET, "/api/**").authenticated() .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() diff --git a/backend/src/main/java/com/workwell/fhir/MeasureExportService.java b/backend/src/main/java/com/workwell/fhir/MeasureExportService.java new file mode 100644 index 0000000..c693c82 --- /dev/null +++ b/backend/src/main/java/com/workwell/fhir/MeasureExportService.java @@ -0,0 +1,313 @@ +package com.workwell.fhir; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.IParser; +import ca.uhn.fhir.validation.FhirValidator; +import ca.uhn.fhir.validation.ValidationResult; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.hl7.fhir.r4.model.Attachment; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.CodeableConcept; +import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Library; +import org.hl7.fhir.r4.model.Measure; +import org.hl7.fhir.r4.model.ValueSet; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +@Service +public class MeasureExportService { + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference>> LIST_MAP_TYPE = new TypeReference<>() {}; + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final FhirContext fhirContext; + + public MeasureExportService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + this.fhirContext = FhirContext.forR4Cached(); + } + + public String exportAsMatBundle(UUID measureId, UUID measureVersionId) { + MeasureVersionRow measureVersion = loadMeasureVersion(measureId, measureVersionId); + List valueSets = loadValueSets(measureVersionId); + + Bundle bundle = new Bundle(); + bundle.setId(UUID.randomUUID().toString()); + bundle.setType(Bundle.BundleType.COLLECTION); + + Library library = buildLibrary(measureVersion); + Measure measure = buildMeasure(measureVersion, library); + + addEntry(bundle, library); + addEntry(bundle, measure); + for (ValueSetRow valueSetRow : valueSets) { + addEntry(bundle, buildValueSet(valueSetRow)); + } + + validateBundle(bundle); + IParser parser = fhirContext.newXmlParser().setPrettyPrint(true); + return parser.encodeResourceToString(bundle); + } + + private MeasureVersionRow loadMeasureVersion(UUID measureId, UUID measureVersionId) { + try { + return jdbcTemplate.queryForObject( + """ + SELECT + m.id AS measure_id, + m.name AS measure_name, + m.policy_ref, + mv.id AS measure_version_id, + mv.version, + mv.status, + COALESCE(mv.cql_text, '') AS cql_text, + mv.spec_json::text AS spec_json_text + FROM measures m + JOIN measure_versions mv ON mv.measure_id = m.id + WHERE m.id = ? AND mv.id = ? + """, + (rs, rowNum) -> new MeasureVersionRow( + (UUID) rs.getObject("measure_id"), + rs.getString("measure_name"), + rs.getString("policy_ref"), + (UUID) rs.getObject("measure_version_id"), + rs.getString("version"), + rs.getString("status"), + rs.getString("cql_text"), + rs.getString("spec_json_text") + ), + measureId, + measureVersionId + ); + } catch (EmptyResultDataAccessException ex) { + throw new IllegalArgumentException("Measure version not found for the provided measure/version ids."); + } + } + + private List loadValueSets(UUID measureVersionId) { + return jdbcTemplate.query( + """ + SELECT + vs.id, + vs.oid, + vs.name, + vs.version, + COALESCE(vs.canonical_url, '') AS canonical_url, + vs.codes_json::text AS codes_json_text + FROM measure_value_set_links mvsl + JOIN value_sets vs ON vs.id = mvsl.value_set_id + WHERE mvsl.measure_version_id = ? + ORDER BY vs.name ASC + """, + (rs, rowNum) -> new ValueSetRow( + (UUID) rs.getObject("id"), + rs.getString("oid"), + rs.getString("name"), + rs.getString("version"), + rs.getString("canonical_url"), + rs.getString("codes_json_text") + ), + measureVersionId + ); + } + + private Library buildLibrary(MeasureVersionRow row) { + Library library = new Library(); + library.setId(UUID.randomUUID().toString()); + library.setName(safeIdentifier(row.measureName()) + "CQL"); + library.setTitle(row.measureName() + " CQL Library"); + library.setVersion(row.version()); + library.setStatus(resolvePublicationStatus(row.status())); + library.setType(new CodeableConcept().addCoding( + new Coding() + .setSystem("http://terminology.hl7.org/CodeSystem/library-type") + .setCode("logic-library") + .setDisplay("Logic Library") + )); + + if (row.cqlText() != null && !row.cqlText().isBlank()) { + Attachment attachment = new Attachment(); + attachment.setContentType("text/cql"); + // Provide raw UTF-8 bytes; HAPI serializes to base64 in XML. + attachment.setData(row.cqlText().getBytes(StandardCharsets.UTF_8)); + library.addContent(attachment); + } + return library; + } + + private Measure buildMeasure(MeasureVersionRow row, Library library) { + Measure measure = new Measure(); + measure.setId(UUID.randomUUID().toString()); + measure.setName(safeIdentifier(row.measureName())); + measure.setTitle(row.measureName()); + measure.setVersion(row.version()); + measure.setStatus(resolvePublicationStatus(row.status())); + measure.setPublisher("WorkWell Measure Studio"); + measure.setDescription(resolveMeasureDescription(row.specJsonText(), row.policyRef())); + measure.addLibrary("urn:uuid:" + library.getIdElement().getIdPart()); + return measure; + } + + private ValueSet buildValueSet(ValueSetRow row) { + ValueSet valueSet = new ValueSet(); + valueSet.setId(UUID.randomUUID().toString()); + valueSet.setName(safeIdentifier(row.name())); + valueSet.setTitle(row.name()); + String valueSetVersion = row.version() == null ? "" : row.version().trim(); + if (!valueSetVersion.isEmpty()) { + valueSet.setVersion(valueSetVersion); + } + valueSet.setStatus(Enumerations.PublicationStatus.ACTIVE); + valueSet.setUrl(resolveValueSetUrl(row)); + + Map>> codesBySystem = groupCodesBySystem(row.codesJsonText()); + if (!codesBySystem.isEmpty()) { + ValueSet.ValueSetComposeComponent compose = new ValueSet.ValueSetComposeComponent(); + for (Map.Entry>> entry : codesBySystem.entrySet()) { + ValueSet.ConceptSetComponent include = new ValueSet.ConceptSetComponent(); + include.setSystem(entry.getKey()); + for (Map codeRow : entry.getValue()) { + String code = valueAsString(codeRow.get("code")); + if (code.isBlank()) { + continue; + } + ValueSet.ConceptReferenceComponent concept = new ValueSet.ConceptReferenceComponent(); + concept.setCode(code); + String display = valueAsString(codeRow.get("display")); + if (!display.isBlank()) { + concept.setDisplay(display); + } + include.addConcept(concept); + } + if (!include.getConcept().isEmpty()) { + compose.addInclude(include); + } + } + if (!compose.getInclude().isEmpty()) { + valueSet.setCompose(compose); + } + } + return valueSet; + } + + private void addEntry(Bundle bundle, org.hl7.fhir.r4.model.Resource resource) { + bundle.addEntry() + .setFullUrl("urn:uuid:" + resource.getIdElement().getIdPart()) + .setResource(resource); + } + + private void validateBundle(Bundle bundle) { + FhirValidator validator = fhirContext.newValidator(); + ValidationResult result = validator.validateWithResult(bundle); + if (result.isSuccessful()) { + return; + } + String firstMessage = result.getMessages().isEmpty() + ? "Unknown FHIR validation failure." + : result.getMessages().get(0).getMessage(); + throw new IllegalStateException("FHIR validation failed for MAT export: " + firstMessage); + } + + private String resolveValueSetUrl(ValueSetRow row) { + if (row.canonicalUrl() != null && !row.canonicalUrl().isBlank()) { + return row.canonicalUrl(); + } + if (row.oid() != null && !row.oid().isBlank()) { + return "urn:oid:" + row.oid(); + } + return "urn:uuid:" + row.id(); + } + + private String resolveMeasureDescription(String specJsonText, String policyRef) { + if (specJsonText != null && !specJsonText.isBlank()) { + try { + Map spec = objectMapper.readValue(specJsonText, MAP_TYPE); + String description = valueAsString(spec.get("description")); + if (!description.isBlank()) { + return description; + } + } catch (Exception ignored) { + // Fall back to policy reference when spec parsing fails. + } + } + if (policyRef != null && !policyRef.isBlank()) { + return "Policy reference: " + policyRef; + } + return "Exported from WorkWell Measure Studio"; + } + + private Map>> groupCodesBySystem(String codesJsonText) { + Map>> grouped = new LinkedHashMap<>(); + if (codesJsonText == null || codesJsonText.isBlank()) { + return grouped; + } + try { + List> rows = objectMapper.readValue(codesJsonText, LIST_MAP_TYPE); + for (Map row : rows) { + String system = valueAsString(row.get("system")); + if (system.isBlank()) { + system = "urn:workwell:local"; + } + grouped.computeIfAbsent(system, key -> new ArrayList<>()).add(row); + } + } catch (Exception ignored) { + return Map.of(); + } + return grouped; + } + + private String safeIdentifier(String value) { + if (value == null || value.isBlank()) { + return "WorkWellMeasure"; + } + String normalized = value.replaceAll("[^A-Za-z0-9]+", ""); + return normalized.isBlank() ? "WorkWellMeasure" : normalized; + } + + private String valueAsString(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private Enumerations.PublicationStatus resolvePublicationStatus(String status) { + String normalized = status == null ? "" : status.trim().toUpperCase(); + return switch (normalized) { + case "ACTIVE" -> Enumerations.PublicationStatus.ACTIVE; + case "APPROVED" -> Enumerations.PublicationStatus.ACTIVE; + case "DEPRECATED" -> Enumerations.PublicationStatus.RETIRED; + case "DRAFT" -> Enumerations.PublicationStatus.DRAFT; + default -> Enumerations.PublicationStatus.DRAFT; + }; + } + + private record MeasureVersionRow( + UUID measureId, + String measureName, + String policyRef, + UUID measureVersionId, + String version, + String status, + String cqlText, + String specJsonText + ) {} + + private record ValueSetRow( + UUID id, + String oid, + String name, + String version, + String canonicalUrl, + String codesJsonText + ) {} +} diff --git a/backend/src/main/java/com/workwell/run/RiskOutlookService.java b/backend/src/main/java/com/workwell/run/RiskOutlookService.java new file mode 100644 index 0000000..1307169 --- /dev/null +++ b/backend/src/main/java/com/workwell/run/RiskOutlookService.java @@ -0,0 +1,318 @@ +package com.workwell.run; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +@Service +public class RiskOutlookService { + private static final int DUE_SOON_BUFFER_DAYS = 30; + + private final JdbcTemplate jdbcTemplate; + + public RiskOutlookService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public RiskOutlookResult getOutlook(UUID measureId, int horizonDays) { + if (measureId == null) { + throw new IllegalArgumentException("measureId is required"); + } + int safeHorizonDays = Math.max(1, Math.min(horizonDays, 180)); + MeasureContext context = loadMeasureContext(measureId); + List latestOutcomes = loadLatestOutcomeSnapshots(context.measureVersionId()); + + Map siteAccumulators = new LinkedHashMap<>(); + List upcomingExpirations = new ArrayList<>(); + LocalDate today = LocalDate.now(); + + for (LatestOutcomeSnapshot snapshot : latestOutcomes) { + SiteAccumulator site = siteAccumulators.computeIfAbsent(snapshot.site(), key -> new SiteAccumulator()); + site.total++; + if ("COMPLIANT".equals(snapshot.status())) { + site.compliant++; + } + + if (isBecomingDueSoon(snapshot, today, safeHorizonDays)) { + int dueSoonThresholdDays = Math.max(snapshot.complianceWindowDays() - DUE_SOON_BUFFER_DAYS, 0); + long daysSinceLastExam = ChronoUnit.DAYS.between(snapshot.lastExamDate(), today); + int daysUntilDueSoon = (int) Math.max(0, dueSoonThresholdDays - daysSinceLastExam); + LocalDate predictedDueSoonDate = snapshot.lastExamDate().plusDays(dueSoonThresholdDays); + site.upcomingExpirations++; + upcomingExpirations.add(new UpcomingExpiration( + snapshot.externalId(), + snapshot.name(), + snapshot.site(), + context.measureName(), + snapshot.lastExamDate().toString(), + snapshot.complianceWindowDays(), + (int) daysSinceLastExam, + daysUntilDueSoon, + predictedDueSoonDate.toString() + )); + } + } + + upcomingExpirations.sort( + Comparator.comparingInt(UpcomingExpiration::daysUntilDueSoon) + .thenComparing(UpcomingExpiration::name) + ); + + List siteRates = siteAccumulators.entrySet().stream() + .map(entry -> { + String site = entry.getKey(); + SiteAccumulator acc = entry.getValue(); + double currentRate = percentage(acc.compliant, acc.total); + long predictedCompliant = Math.max(0, acc.compliant - acc.upcomingExpirations); + double predictedRate = percentage(predictedCompliant, acc.total); + return new SiteComplianceRate( + site, + acc.total, + acc.compliant, + acc.upcomingExpirations, + currentRate, + predictedRate + ); + }) + .sorted(Comparator.comparingDouble(SiteComplianceRate::currentComplianceRate)) + .toList(); + + List repeatNonCompliers = loadRepeatNonCompliers(context.measureVersionId(), context.measureName()); + + return new RiskOutlookResult( + upcomingExpirations.size(), + upcomingExpirations, + repeatNonCompliers, + siteRates + ); + } + + private MeasureContext loadMeasureContext(UUID measureId) { + try { + return jdbcTemplate.queryForObject( + """ + SELECT mv.id AS measure_version_id, m.name AS measure_name + FROM measures m + JOIN measure_versions mv ON mv.measure_id = m.id + WHERE m.id = ? + ORDER BY mv.created_at DESC + LIMIT 1 + """, + (rs, rowNum) -> new MeasureContext( + (UUID) rs.getObject("measure_version_id"), + rs.getString("measure_name") + ), + measureId + ); + } catch (EmptyResultDataAccessException ex) { + throw new IllegalArgumentException("Measure not found: " + measureId); + } + } + + private List loadLatestOutcomeSnapshots(UUID measureVersionId) { + return jdbcTemplate.query( + """ + WITH ranked_outcomes AS ( + SELECT + o.employee_id, + o.status, + o.evidence_json, + o.evaluated_at, + ROW_NUMBER() OVER (PARTITION BY o.employee_id ORDER BY o.evaluated_at DESC) AS rn + FROM outcomes o + WHERE o.measure_version_id = ? + ) + SELECT + e.external_id, + e.name, + COALESCE(NULLIF(e.site, ''), 'Unknown') AS site, + ro.status, + NULLIF(ro.evidence_json -> 'why_flagged' ->> 'last_exam_date', '') AS last_exam_date, + CASE + WHEN (ro.evidence_json -> 'why_flagged' ->> 'compliance_window_days') ~ '^[0-9]+$' + THEN (ro.evidence_json -> 'why_flagged' ->> 'compliance_window_days')::INT + ELSE 365 + END AS compliance_window_days + FROM ranked_outcomes ro + JOIN employees e ON e.id = ro.employee_id + WHERE ro.rn = 1 + """, + (rs, rowNum) -> mapLatestOutcomeSnapshot(rs), + measureVersionId + ).stream().filter(snapshot -> snapshot != null).toList(); + } + + private LatestOutcomeSnapshot mapLatestOutcomeSnapshot(ResultSet rs) throws SQLException { + String lastExamDateText = rs.getString("last_exam_date"); + LocalDate lastExamDate = parseIsoDate(lastExamDateText); + return new LatestOutcomeSnapshot( + rs.getString("external_id"), + rs.getString("name"), + rs.getString("site"), + rs.getString("status"), + lastExamDate, + rs.getInt("compliance_window_days") + ); + } + + private List loadRepeatNonCompliers(UUID measureVersionId, String measureName) { + return jdbcTemplate.query( + """ + WITH period_outcomes AS ( + SELECT + o.employee_id, + o.status, + o.evaluated_at, + o.evaluation_period, + ROW_NUMBER() OVER ( + PARTITION BY o.employee_id, o.evaluation_period + ORDER BY o.evaluated_at DESC + ) AS period_rank + FROM outcomes o + WHERE o.measure_version_id = ? + ), + ordered AS ( + SELECT + po.employee_id, + e.external_id, + e.name, + COALESCE(NULLIF(e.site, ''), 'Unknown') AS site, + po.status, + po.evaluated_at, + SUM( + CASE WHEN po.status IN ('OVERDUE', 'MISSING_DATA') THEN 0 ELSE 1 END + ) OVER ( + PARTITION BY po.employee_id + ORDER BY po.evaluated_at DESC + ROWS UNBOUNDED PRECEDING + ) AS break_group + FROM period_outcomes po + JOIN employees e ON e.id = po.employee_id + WHERE po.period_rank = 1 + ), + current_streak AS ( + SELECT + employee_id, + external_id, + name, + site, + COUNT(*) FILTER (WHERE status IN ('OVERDUE', 'MISSING_DATA')) AS streak + FROM ordered + WHERE break_group = 0 + GROUP BY employee_id, external_id, name, site + ) + SELECT external_id, name, site, streak + FROM current_streak + WHERE streak >= 3 + ORDER BY streak DESC, name ASC + LIMIT 10 + """, + (rs, rowNum) -> new RepeatNonComplier( + rs.getString("external_id"), + rs.getString("name"), + rs.getString("site"), + measureName, + rs.getLong("streak") + ), + measureVersionId + ); + } + + private boolean isBecomingDueSoon(LatestOutcomeSnapshot snapshot, LocalDate today, int horizonDays) { + if (!"COMPLIANT".equals(snapshot.status())) { + return false; + } + if (snapshot.lastExamDate() == null) { + return false; + } + int dueSoonThresholdDays = Math.max(snapshot.complianceWindowDays() - DUE_SOON_BUFFER_DAYS, 0); + long daysSinceLastExam = ChronoUnit.DAYS.between(snapshot.lastExamDate(), today); + if (daysSinceLastExam >= dueSoonThresholdDays) { + return false; + } + long daysUntilDueSoon = dueSoonThresholdDays - daysSinceLastExam; + return daysUntilDueSoon <= horizonDays; + } + + private LocalDate parseIsoDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return LocalDate.parse(value.trim()); + } catch (Exception ignored) { + return null; + } + } + + private double percentage(long numerator, long denominator) { + if (denominator <= 0) { + return 0d; + } + double raw = (numerator * 100.0d) / denominator; + return Math.round(raw * 10.0d) / 10.0d; + } + + private record MeasureContext(UUID measureVersionId, String measureName) {} + + private record LatestOutcomeSnapshot( + String externalId, + String name, + String site, + String status, + LocalDate lastExamDate, + int complianceWindowDays + ) {} + + private static final class SiteAccumulator { + private long total; + private long compliant; + private long upcomingExpirations; + } + + public record RiskOutlookResult( + int upcomingNonCompliantCount, + List upcomingExpirations, + List repeatNonCompliers, + List siteComplianceRates + ) {} + + public record UpcomingExpiration( + String externalId, + String name, + String site, + String measureName, + String lastExamDate, + int complianceWindowDays, + int daysSinceLastExam, + int daysUntilDueSoon, + String predictedDueSoonDate + ) {} + + public record RepeatNonComplier( + String externalId, + String name, + String site, + String measureName, + long streakCount + ) {} + + public record SiteComplianceRate( + String site, + long total, + long compliant, + long upcomingExpirations, + double currentComplianceRate, + double predictedComplianceRate + ) {} +} diff --git a/backend/src/main/java/com/workwell/web/AiController.java b/backend/src/main/java/com/workwell/web/AiController.java index 6472b20..5095c4b 100644 --- a/backend/src/main/java/com/workwell/web/AiController.java +++ b/backend/src/main/java/com/workwell/web/AiController.java @@ -3,6 +3,7 @@ import com.workwell.ai.AiAssistService; import com.workwell.security.SecurityActor; import jakarta.validation.constraints.NotBlank; +import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; @@ -47,6 +48,17 @@ public AiAssistService.DraftCqlResponse draftCql( } } + @PostMapping("/api/measures/{measureId}/ai/generate-test-fixtures") + public List generateTestFixtures( + @PathVariable UUID measureId + ) { + try { + return aiAssistService.generateTestFixtures(measureId, SecurityActor.currentActor()); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, ex.getMessage()); + } + } + @PostMapping({"/api/cases/{caseId}/explain", "/api/cases/{caseId}/ai/explain"}) public AiAssistService.CaseExplanationResponse explainCase( @PathVariable UUID caseId diff --git a/backend/src/main/java/com/workwell/web/MeasureController.java b/backend/src/main/java/com/workwell/web/MeasureController.java index 33cb428..6e62d35 100644 --- a/backend/src/main/java/com/workwell/web/MeasureController.java +++ b/backend/src/main/java/com/workwell/web/MeasureController.java @@ -1,6 +1,7 @@ package com.workwell.web; import com.workwell.admin.DataReadinessService; +import com.workwell.fhir.MeasureExportService; import com.workwell.measure.MeasureImpactPreviewService; import com.workwell.measure.MeasureService; import com.workwell.measure.MeasureTraceabilityService; @@ -13,7 +14,10 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -34,19 +38,22 @@ public class MeasureController { private final MeasureImpactPreviewService impactPreviewService; private final DataReadinessService dataReadinessService; private final ValueSetGovernanceService valueSetGovernanceService; + private final MeasureExportService measureExportService; public MeasureController( MeasureService measureService, MeasureTraceabilityService traceabilityService, MeasureImpactPreviewService impactPreviewService, DataReadinessService dataReadinessService, - ValueSetGovernanceService valueSetGovernanceService + ValueSetGovernanceService valueSetGovernanceService, + MeasureExportService measureExportService ) { this.measureService = measureService; this.traceabilityService = traceabilityService; this.impactPreviewService = impactPreviewService; this.dataReadinessService = dataReadinessService; this.valueSetGovernanceService = valueSetGovernanceService; + this.measureExportService = measureExportService; } @Operation(summary = "List measures", description = "Catalog of measures with optional status and search filters.") @@ -270,6 +277,28 @@ public DataReadinessService.DataReadinessResponse dataReadiness(@PathVariable UU } } + @GetMapping("/api/measures/{measureId}/versions/{versionId}/export/mat") + public ResponseEntity exportMatBundle( + @PathVariable UUID measureId, + @PathVariable UUID versionId, + @RequestParam(name = "format", defaultValue = "xml") String format + ) { + if (!"xml".equalsIgnoreCase(format)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported format. Use format=xml."); + } + try { + String xml = measureExportService.exportAsMatBundle(measureId, versionId); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"measure-" + versionId + "-mat.xml\"") + .contentType(MediaType.parseMediaType("application/fhir+xml")) + .body(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, ex.getMessage()); + } catch (IllegalStateException ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + } + public record CreateMeasureRequest( @NotBlank String name, @NotBlank String policyRef, diff --git a/backend/src/main/java/com/workwell/web/ProgramController.java b/backend/src/main/java/com/workwell/web/ProgramController.java index cdb508e..ca10854 100644 --- a/backend/src/main/java/com/workwell/web/ProgramController.java +++ b/backend/src/main/java/com/workwell/web/ProgramController.java @@ -1,6 +1,7 @@ package com.workwell.web; import com.workwell.program.ProgramService; +import com.workwell.run.RiskOutlookService; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; @@ -17,9 +18,11 @@ @RestController public class ProgramController { private final ProgramService programService; + private final RiskOutlookService riskOutlookService; - public ProgramController(ProgramService programService) { + public ProgramController(ProgramService programService, RiskOutlookService riskOutlookService) { this.programService = programService; + this.riskOutlookService = riskOutlookService; } @GetMapping("/api/programs") @@ -65,6 +68,18 @@ public ProgramService.TopDrivers topDrivers( return programService.topDrivers(measureId, site, parseFromDate(from), parseToDate(to)); } + @GetMapping("/api/programs/{measureId}/risk-outlook") + public RiskOutlookService.RiskOutlookResult riskOutlook( + @PathVariable UUID measureId, + @RequestParam(name = "horizonDays", defaultValue = "30") int horizonDays + ) { + try { + return riskOutlookService.getOutlook(measureId, horizonDays); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, ex.getMessage()); + } + } + private Instant parseFromDate(String from) { if (from == null || from.isBlank()) { return null; diff --git a/backend/src/test/java/com/workwell/config/SecurityRoleIntegrationTest.java b/backend/src/test/java/com/workwell/config/SecurityRoleIntegrationTest.java index 955446f..4b3149b 100644 --- a/backend/src/test/java/com/workwell/config/SecurityRoleIntegrationTest.java +++ b/backend/src/test/java/com/workwell/config/SecurityRoleIntegrationTest.java @@ -80,6 +80,15 @@ void viewerCannotAccessAdminEndpoints() throws Exception { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(username = "viewer@workwell.dev", roles = "VIEWER") + void viewerCannotExportMatBundle() throws Exception { + UUID measureId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + mockMvc.perform(get("/api/measures/{measureId}/versions/{versionId}/export/mat", measureId, versionId)) + .andExpect(status().isForbidden()); + } + // --- AUTHOR --- @Test @@ -119,6 +128,15 @@ void authorCannotAccessAdminEndpoints() throws Exception { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(username = "author@workwell.dev", roles = "AUTHOR") + void authorCannotExportMatBundle() throws Exception { + UUID measureId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + mockMvc.perform(get("/api/measures/{measureId}/versions/{versionId}/export/mat", measureId, versionId)) + .andExpect(status().isForbidden()); + } + // --- APPROVER --- @Test @@ -138,6 +156,15 @@ void approverCannotPostCaseActions() throws Exception { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(username = "approver@workwell.dev", roles = "APPROVER") + void approverCanReachMatExportEndpoint() throws Exception { + UUID measureId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + mockMvc.perform(get("/api/measures/{measureId}/versions/{versionId}/export/mat", measureId, versionId)) + .andExpect(status().isNotFound()); + } + // --- ADMIN --- @Test @@ -154,6 +181,15 @@ void adminCanReadMeasures() throws Exception { .andExpect(status().isOk()); } + @Test + @WithMockUser(username = "admin@workwell.dev", roles = "ADMIN") + void adminCanReachMatExportEndpoint() throws Exception { + UUID measureId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + mockMvc.perform(get("/api/measures/{measureId}/versions/{versionId}/export/mat", measureId, versionId)) + .andExpect(status().isNotFound()); + } + // --- Internal eval endpoint --- @Test diff --git a/backend/src/test/java/com/workwell/fhir/MeasureExportServiceTest.java b/backend/src/test/java/com/workwell/fhir/MeasureExportServiceTest.java new file mode 100644 index 0000000..e873a23 --- /dev/null +++ b/backend/src/test/java/com/workwell/fhir/MeasureExportServiceTest.java @@ -0,0 +1,77 @@ +package com.workwell.fhir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ca.uhn.fhir.context.FhirContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.sql.ResultSet; +import java.util.List; +import java.util.UUID; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.ValueSet; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +class MeasureExportServiceTest { + + @Test + void omitsValueSetVersionWhenStoredVersionIsBlank() throws Exception { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + ObjectMapper objectMapper = new ObjectMapper(); + MeasureExportService service = new MeasureExportService(jdbcTemplate, objectMapper); + + UUID measureId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID measureVersionId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + UUID valueSetId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + + when(jdbcTemplate.queryForObject(anyString(), any(RowMapper.class), eq(measureId), eq(measureVersionId))) + .thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getObject("measure_id")).thenReturn(measureId); + when(rs.getString("measure_name")).thenReturn("Audiogram"); + when(rs.getString("policy_ref")).thenReturn("OSHA 29 CFR 1910.95"); + when(rs.getObject("measure_version_id")).thenReturn(measureVersionId); + when(rs.getString("version")).thenReturn("v1.0"); + when(rs.getString("status")).thenReturn("DRAFT"); + when(rs.getString("cql_text")).thenReturn(""); + when(rs.getString("spec_json_text")).thenReturn("{\"description\":\"Test description\"}"); + return mapper.mapRow(rs, 0); + }); + + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq(measureVersionId))) + .thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getObject("id")).thenReturn(valueSetId); + when(rs.getString("oid")).thenReturn("2.16.840.1.113883.3.1"); + when(rs.getString("name")).thenReturn("Audiogram Procedures"); + when(rs.getString("version")).thenReturn(" "); + when(rs.getString("canonical_url")).thenReturn(""); + when(rs.getString("codes_json_text")).thenReturn( + "[{\"code\":\"12345-6\",\"display\":\"Baseline audiogram\",\"system\":\"http://loinc.org\"}]" + ); + return List.of(mapper.mapRow(rs, 0)); + }); + + String xml = service.exportAsMatBundle(measureId, measureVersionId); + + assertThat(xml).doesNotContain(""); + + Bundle bundle = (Bundle) FhirContext.forR4Cached().newXmlParser().parseResource(xml); + List valueSets = bundle.getEntry().stream() + .map(Bundle.BundleEntryComponent::getResource) + .filter(ValueSet.class::isInstance) + .map(ValueSet.class::cast) + .toList(); + + assertThat(valueSets).hasSize(1); + assertThat(valueSets.get(0).hasVersion()).isFalse(); + } +} diff --git a/backend/src/test/java/com/workwell/web/AiControllerTest.java b/backend/src/test/java/com/workwell/web/AiControllerTest.java index 8e304c8..2d083c6 100644 --- a/backend/src/test/java/com/workwell/web/AiControllerTest.java +++ b/backend/src/test/java/com/workwell/web/AiControllerTest.java @@ -82,4 +82,25 @@ void runInsight() throws Exception { .andExpect(jsonPath("$.fallback").value(false)) .andExpect(jsonPath("$.insights[0]").value("Insight A")); } + + @Test + @WithMockUser(username = "author@workwell.dev", roles = "AUTHOR") + void generatesTestFixtures() throws Exception { + UUID measureId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + when(aiAssistService.generateTestFixtures(measureId, "author@workwell.dev")).thenReturn( + java.util.List.of( + new AiAssistService.GeneratedTestFixture( + "Employee with exam 30 days ago", + Map.of("examDate", "2026-04-22", "programEnrolled", true, "hasExemption", false), + "COMPLIANT" + ) + ) + ); + + mockMvc.perform(post("/api/measures/{measureId}/ai/generate-test-fixtures", measureId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Employee with exam 30 days ago")) + .andExpect(jsonPath("$[0].expectedOutcome").value("COMPLIANT")); + } } diff --git a/backend/src/test/java/com/workwell/web/MeasureControllerTest.java b/backend/src/test/java/com/workwell/web/MeasureControllerTest.java index 5259d6a..8b8efd6 100644 --- a/backend/src/test/java/com/workwell/web/MeasureControllerTest.java +++ b/backend/src/test/java/com/workwell/web/MeasureControllerTest.java @@ -3,10 +3,12 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.workwell.admin.DataReadinessService; +import com.workwell.fhir.MeasureExportService; import com.workwell.measure.MeasureImpactPreviewService; import com.workwell.measure.MeasureService; import com.workwell.measure.MeasureTraceabilityService; @@ -41,6 +43,9 @@ class MeasureControllerTest { @MockBean private ValueSetGovernanceService valueSetGovernanceService; + @MockBean + private MeasureExportService measureExportService; + @Test void createsNewMeasureVersion() throws Exception { UUID measureId = UUID.fromString("11111111-1111-1111-1111-111111111111"); @@ -191,4 +196,18 @@ void listsOshaReferences() throws Exception { .andExpect(jsonPath("$[0].title").value("Occupational Noise Exposure")) .andExpect(jsonPath("$[0].programArea").value("Hearing Conservation")); } + + @Test + void exportsMatBundleXml() throws Exception { + UUID measureId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID versionId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + String xml = ""; + when(measureExportService.exportAsMatBundle(measureId, versionId)).thenReturn(xml); + + mockMvc.perform(get("/api/measures/{measureId}/versions/{versionId}/export/mat", measureId, versionId) + .param("format", "xml")) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/fhir+xml")) + .andExpect(content().string(xml)); + } } diff --git a/backend/src/test/java/com/workwell/web/ProgramControllerTest.java b/backend/src/test/java/com/workwell/web/ProgramControllerTest.java index c42b339..77f0567 100644 --- a/backend/src/test/java/com/workwell/web/ProgramControllerTest.java +++ b/backend/src/test/java/com/workwell/web/ProgramControllerTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.workwell.program.ProgramService; +import com.workwell.run.RiskOutlookService; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -25,6 +26,9 @@ class ProgramControllerTest { @MockBean private ProgramService programService; + @MockBean + private RiskOutlookService riskOutlookService; + @Test void listsPrograms() throws Exception { UUID measureId = UUID.fromString("11111111-1111-1111-1111-111111111111"); @@ -95,4 +99,56 @@ void rejectsInvalidDateFilters() throws Exception { mockMvc.perform(get("/api/programs").param("from", "2026-13-01")) .andExpect(status().isBadRequest()); } + + @Test + void returnsRiskOutlook() throws Exception { + UUID measureId = UUID.fromString("77777777-7777-7777-7777-777777777777"); + when(riskOutlookService.getOutlook(measureId, 30)).thenReturn( + new RiskOutlookService.RiskOutlookResult( + 2, + List.of( + new RiskOutlookService.UpcomingExpiration( + "EMP-001", + "Ava Khan", + "Plant A", + "Audiogram", + "2025-06-01", + 365, + 335, + 0, + "2026-05-02" + ) + ), + List.of( + new RiskOutlookService.RepeatNonComplier( + "EMP-900", + "Rami Patel", + "Plant B", + "Audiogram", + 3 + ) + ), + List.of( + new RiskOutlookService.SiteComplianceRate("Plant A", 10, 7, 2, 70.0, 50.0) + ) + ) + ); + + mockMvc.perform(get("/api/programs/{measureId}/risk-outlook", measureId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.upcomingNonCompliantCount").value(2)) + .andExpect(jsonPath("$.upcomingExpirations[0].externalId").value("EMP-001")) + .andExpect(jsonPath("$.repeatNonCompliers[0].streakCount").value(3)) + .andExpect(jsonPath("$.siteComplianceRates[0].site").value("Plant A")); + } + + @Test + void riskOutlookReturnsNotFoundWhenMeasureIsMissing() throws Exception { + UUID measureId = UUID.fromString("88888888-8888-8888-8888-888888888888"); + when(riskOutlookService.getOutlook(measureId, 30)) + .thenThrow(new IllegalArgumentException("Measure not found")); + + mockMvc.perform(get("/api/programs/{measureId}/risk-outlook", measureId)) + .andExpect(status().isNotFound()); + } } diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 0523067..d8d9d5d 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -1,5 +1,83 @@ # Journal +## 2026-05-22 — PR #53 review follow-up (security + error mapping + MAT export hygiene) + +### Review threads resolved + +1. **MAT export authorization boundary** + - `SecurityConfig` now explicitly gates `GET /api/measures/*/versions/*/export/mat` to `ROLE_APPROVER` or `ROLE_ADMIN` before the broad authenticated GET rule. + - Prevents author/case-manager/viewer roles from downloading MAT bundles directly by URL. + +2. **Risk outlook missing-measure response classification** + - `ProgramController.riskOutlook(...)` now maps `IllegalArgumentException` from `RiskOutlookService` to `404 Not Found` via `ResponseStatusException`. + - Keeps response semantics aligned with the rest of controller-layer not-found handling. + +3. **MAT export ValueSet version handling** + - `MeasureExportService` now preserves nullable `value_sets.version` from DB and only sets FHIR `ValueSet.version` when non-blank. + - Avoids serializing empty version primitives for value sets that intentionally omit version data. + +### Tests added/updated + +- `ProgramControllerTest`: + - Added `riskOutlookReturnsNotFoundWhenMeasureIsMissing`. +- `SecurityRoleIntegrationTest`: + - Added MAT export role checks: + - VIEWER forbidden + - AUTHOR forbidden + - APPROVER allowed through security layer (request reaches controller; returns 404 for unknown IDs) + - ADMIN allowed through security layer (request reaches controller; returns 404 for unknown IDs) +- `MeasureExportServiceTest`: + - Added `omitsValueSetVersionWhenStoredVersionIsBlank` to assert no empty FHIR version output for blank DB values. + +### Docs updated + +- `README.md` API highlights now annotate MAT export endpoint role requirements (`ROLE_APPROVER`/`ROLE_ADMIN`). + +## 2026-05-22 — Sprint 7.2–7.5: AI Fixtures, Risk Outlook, MAT Export, Mobile UX + +### What changed + +**Issue 7.2 — AI Test Fixture Generator** +- Backend `AiAssistService` now supports AI fixture generation with `generateTestFixtures(measureId, actor)` and writes `AI_TEST_FIXTURES_GENERATED` audit events. +- New endpoint: `POST /api/measures/{measureId}/ai/generate-test-fixtures` on `AiController`. +- Output is normalized to exactly 5 fixtures, one per required outcome (`COMPLIANT`, `DUE_SOON`, `OVERDUE`, `MISSING_DATA`, `EXCLUDED`). +- Deterministic fallback fixture set is returned when AI output is invalid/unavailable so authoring is never blocked. +- Frontend `TestsTab` now has **Generate Fixtures** + draft fixture cards and additive controls (`Add to Draft`, `Add All to Drafts`) with explanatory AI review note. + +**Issue 7.3 — Risk Outlook / Predictive Analytics** +- Added `RiskOutlookService` with `getOutlook(measureId, horizonDays)`: + - Upcoming due-soon pressure from currently compliant employees nearing threshold. + - Repeat non-complier streaks (current consecutive non-compliant periods). + - Site-level current vs predicted compliance rates. +- New endpoint: `GET /api/programs/{measureId}/risk-outlook?horizonDays=30`. +- Programs detail page now renders a Risk Outlook panel with KPI chips, repeat non-compliers table (employee links to `/employees/[externalId]`), and site heatmap table sorted by current risk. + +**Issue 7.4 — MAT-Compatible Export** +- Added `MeasureExportService` (`com.workwell.fhir`) to build MAT-compatible FHIR R4 `Bundle` XML containing: + - `Library` with `contentType=text/cql` and raw CQL bytes (HAPI serializes base64). + - `Measure` with metadata and linked library reference. + - Linked `ValueSet` resources (including code concepts in compose/include blocks when available). +- New endpoint: `GET /api/measures/{measureId}/versions/{versionId}/export/mat?format=xml`. +- Studio Release tab now includes **Export for MAT (FHIR XML)** for APPROVER/ADMIN roles. + +**Issue 7.5 — Mobile Responsive UX** +- Dashboard shell now uses `md` breakpoint behavior for sidebar/hamburger and adds a mobile bottom tab bar (Programs, Cases, Runs, Admin). +- Cases page now has explicit mobile card rows with compact employee/measure/status/chevron navigation. +- Case detail page now exposes mobile-first accordion sections (summary, actions, evidence, timeline) for 375px workflows while preserving the full desktop detail layout. +- Studio measure editor route now shows a mobile notice ("Studio requires a larger screen") and hides the heavy authoring surface on small screens. + +**Docs** +- `README.md` API highlights updated for new Sprint 7 endpoints. + +### Verification + +- Backend targeted tests: + - `.\gradlew.bat test --tests com.workwell.web.AiControllerTest --tests com.workwell.web.ProgramControllerTest --tests com.workwell.web.MeasureControllerTest` → `BUILD SUCCESSFUL` +- Frontend: + - `npm run lint` → success (1 existing warning in `frontend/test/mocks/next-font.ts`) + - `npm run build` → success +- Note: Full backend suite `.\gradlew.bat test` exceeded local timeout windows in this run; targeted controller coverage above passed for all touched backend API surfaces. + ## 2026-05-22 — Sprint 7.1: AI Draft CQL + PR #52 review resolved ### PR #52 closeout diff --git a/frontend/app/(dashboard)/cases/[id]/page.tsx b/frontend/app/(dashboard)/cases/[id]/page.tsx index 6504d00..aeb98f8 100644 --- a/frontend/app/(dashboard)/cases/[id]/page.tsx +++ b/frontend/app/(dashboard)/cases/[id]/page.tsx @@ -404,7 +404,123 @@ export default function CaseDetailPage() { {error ?

Error: {error}

: null} {caseDetail ? ( -
+ <> +
+
+ Case Summary +
+

{caseDetail.employeeName}

+

{caseDetail.employeeId}

+

{caseDetail.measureName}

+
+ + {labelFor(CASE_STATUS_LABELS, caseDetail.status)} + + + {labelFor(OUTCOME_LABELS, caseDetail.currentOutcomeStatus)} + +
+

Period: {caseDetail.evaluationPeriod}

+

{caseDetail.nextAction}

+ + Open Employee Profile + +
+
+ +
+ Actions +
+ + +
+ + + + +
+
+ setAssigneeInput(e.target.value)} + placeholder="Assignee email or handle" + /> + +
+
+
+ +
+ Why Flagged Evidence +
+ {(caseDetail.evidenceJson.expressionResults ?? []).map((row, index) => ( +
+

{String(row.define ?? "define")}

+

{String(row.result)}

+
+ ))} +
+
+ +
+ Timeline +
+ {caseDetail.timeline.map((event) => ( +
+

{formatEventType(event.eventType)}

+

{new Date(event.occurredAt).toLocaleString()} • {event.actor}

+
+ ))} +
+
+
+ +
@@ -843,6 +959,7 @@ export default function CaseDetailPage() {
+ ) : null} ); diff --git a/frontend/app/(dashboard)/cases/page.tsx b/frontend/app/(dashboard)/cases/page.tsx index b5be972..ce927c3 100644 --- a/frontend/app/(dashboard)/cases/page.tsx +++ b/frontend/app/(dashboard)/cases/page.tsx @@ -18,6 +18,7 @@ import { useApi } from "@/lib/api/hooks"; import { SkeletonRow } from "@/components/skeleton-loader"; import { useAuth } from "@/components/auth-provider"; import { SlaChip } from "@/components/SlaChip"; +import { ChevronRight } from "lucide-react"; type CaseSummary = { caseId: string; @@ -525,13 +526,37 @@ export default function CasesPage() { ) : null} {filteredCases.length > 0 ? ( -
+
Select all in current results
) : null} -
+
+ {filteredCases.map((item) => { + const outcomeLabel = labelFor(OUTCOME_LABELS, item.currentOutcomeStatus); + return ( + +
+

{item.employeeName}

+

{item.measureName}

+
+
+ + {outcomeLabel} + + +
+ + ); + })} +
+ +
{filteredCases.map((item) => { const caseStatus = normalizeEnumValue(item.status); const caseStatusLabel = labelFor(CASE_STATUS_LABELS, item.status); diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx index b59fc92..b1f1eb4 100644 --- a/frontend/app/(dashboard)/layout.tsx +++ b/frontend/app/(dashboard)/layout.tsx @@ -112,7 +112,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { {/* ── Mobile sidebar backdrop ──────────────────────────────────── */} {sidebarOpen && ( ); diff --git a/frontend/app/(dashboard)/programs/[measureId]/page.tsx b/frontend/app/(dashboard)/programs/[measureId]/page.tsx index d3c04eb..9874b62 100644 --- a/frontend/app/(dashboard)/programs/[measureId]/page.tsx +++ b/frontend/app/(dashboard)/programs/[measureId]/page.tsx @@ -38,6 +38,36 @@ type TopDrivers = { byOutcomeReason: Array<{ reason: string; count: number; pct: number }>; }; +type RiskOutlook = { + upcomingNonCompliantCount: number; + upcomingExpirations: Array<{ + externalId: string; + name: string; + site: string; + measureName: string; + lastExamDate: string; + complianceWindowDays: number; + daysSinceLastExam: number; + daysUntilDueSoon: number; + predictedDueSoonDate: string; + }>; + repeatNonCompliers: Array<{ + externalId: string; + name: string; + site: string; + measureName: string; + streakCount: number; + }>; + siteComplianceRates: Array<{ + site: string; + total: number; + compliant: number; + upcomingExpirations: number; + currentComplianceRate: number; + predictedComplianceRate: number; + }>; +}; + const OUTCOME_COLORS: Record = { COMPLIANT: "#059669", DUE_SOON: "#d97706", @@ -60,6 +90,7 @@ export default function ProgramDetailPage() { const [program, setProgram] = useState(null); const [trend, setTrend] = useState([]); const [drivers, setDrivers] = useState({ bySite: [], byRole: [], byOutcomeReason: [] }); + const [riskOutlook, setRiskOutlook] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -80,6 +111,12 @@ export default function ProgramDetailPage() { } catch { setDrivers({ bySite: [], byRole: [], byOutcomeReason: [] }); } + try { + const outlook = await api.get(`/api/programs/${measureId}/risk-outlook?horizonDays=30`); + setRiskOutlook(outlook); + } catch { + setRiskOutlook(null); + } } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } @@ -164,6 +201,90 @@ export default function ProgramDetailPage() {
+
+

Risk outlook (next 30 days)

+
+
+

Upcoming due soon

+

+ {riskOutlook?.upcomingNonCompliantCount ?? 0} +

+
+
+

Repeat non-compliers

+

+ {riskOutlook?.repeatNonCompliers.length ?? 0} +

+
+
+

Highest-risk site

+

+ {riskOutlook?.siteComplianceRates?.[0]?.site ?? "—"} +

+
+
+ + {riskOutlook?.repeatNonCompliers && riskOutlook.repeatNonCompliers.length > 0 ? ( +
+

Repeat non-compliers

+
+ + + + + + + + + + {riskOutlook.repeatNonCompliers.map((item) => ( + + + + + + ))} + +
EmployeeSiteStreak
+ + {item.name} + + {item.site}{item.streakCount}x
+
+
+ ) : ( +

No repeat non-compliers detected at the moment.

+ )} + + {riskOutlook?.siteComplianceRates && riskOutlook.siteComplianceRates.length > 0 ? ( +
+

Site risk heatmap

+
+ + + + + + + + + + + {riskOutlook.siteComplianceRates.map((site) => ( + + + + + + + ))} + +
SiteCurrent ratePredicted 30dExpiring
{site.site}{site.currentComplianceRate.toFixed(1)}%{site.predictedComplianceRate.toFixed(1)}%{site.upcomingExpirations}
+
+
+ ) : null} +
+

Top sites

diff --git a/frontend/app/(dashboard)/studio/[id]/page.tsx b/frontend/app/(dashboard)/studio/[id]/page.tsx index f6cccb1..7b74aa9 100644 --- a/frontend/app/(dashboard)/studio/[id]/page.tsx +++ b/frontend/app/(dashboard)/studio/[id]/page.tsx @@ -87,6 +87,15 @@ export default function StudioMeasurePage() { return (
+
+

Studio

+

Studio requires a larger screen

+

+ Open this page on a desktop or laptop to author CQL, manage value sets, and run release checks. +

+
+ +
Back to Measures @@ -221,6 +230,7 @@ export default function StudioMeasurePage() { {measureId && tab === "traceability" ? ( ) : null} +
); } diff --git a/frontend/features/studio/components/ReleaseApprovalTab.tsx b/frontend/features/studio/components/ReleaseApprovalTab.tsx index bb23359..3453aa5 100644 --- a/frontend/features/studio/components/ReleaseApprovalTab.tsx +++ b/frontend/features/studio/components/ReleaseApprovalTab.tsx @@ -39,6 +39,7 @@ export function ReleaseApprovalTab({ const [showActivateConfirm, setShowActivateConfirm] = useState(false); const [showDeprecateConfirm, setShowDeprecateConfirm] = useState(false); const [deprecateReason, setDeprecateReason] = useState(""); + const [exportingMat, setExportingMat] = useState(false); const measureVersionId = versionHistory.find(v => v.version === measure.version)?.id ?? ""; @@ -92,6 +93,34 @@ export function ReleaseApprovalTab({ } } + async function exportMatBundle() { + if (!measureVersionId) { + onError("No measure version selected for MAT export."); + return; + } + setExportingMat(true); + onError(""); + try { + const blob = await api.downloadBlob( + `/api/measures/${measureId}/versions/${measureVersionId}/export/mat?format=xml` + ); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const safeName = (measure.name || "measure").replace(/[^A-Za-z0-9_-]+/g, "-"); + anchor.href = url; + anchor.download = `${safeName}-${measure.version}-mat.xml`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(url); + emitToast("MAT export downloaded"); + } catch (err) { + onError(err instanceof Error ? err.message : "MAT export failed"); + } finally { + setExportingMat(false); + } + } + return ( <>
@@ -144,7 +173,7 @@ export function ReleaseApprovalTab({ )} {measureVersionId ? ( -
+
onError(message)} /> + {canApprove ? ( + + ) : null}
) : null} diff --git a/frontend/features/studio/components/TestsTab.tsx b/frontend/features/studio/components/TestsTab.tsx index 96f666d..bf4a3e9 100644 --- a/frontend/features/studio/components/TestsTab.tsx +++ b/frontend/features/studio/components/TestsTab.tsx @@ -6,6 +6,18 @@ import { emitToast } from "@/lib/toast"; import type { ApiClient } from "@/lib/api/client"; import type { TestFixture } from "../types"; +type GeneratedFixture = { + name: string; + inputData: { + examDate: string | null; + programEnrolled: boolean; + hasExemption: boolean; + role?: string; + site?: string; + }; + expectedOutcome: string; +}; + type Props = { measureId: string; api: ApiClient; @@ -16,8 +28,10 @@ type Props = { export function TestsTab({ measureId, api, initialFixtures, onSaved, onError }: Props) { const [fixtures, setFixtures] = useState(initialFixtures); + const [generatedFixtures, setGeneratedFixtures] = useState([]); const [testFailures, setTestFailures] = useState([]); const [isValidating, setIsValidating] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); function update(index: number, field: keyof TestFixture, value: string) { setFixtures((current) => current.map((f, i) => (i === index ? { ...f, [field]: value } : f))); @@ -31,6 +45,42 @@ export function TestsTab({ measureId, api, initialFixtures, onSaved, onError }: setFixtures((current) => current.filter((_, i) => i !== index)); } + function fixtureFromGenerated(generated: GeneratedFixture, index: number): TestFixture { + const inputDataText = JSON.stringify(generated.inputData); + const employeeId = `AI-${generated.expectedOutcome}-${index + 1}`; + return { + fixtureName: generated.name, + employeeExternalId: employeeId, + expectedOutcome: generated.expectedOutcome, + notes: `AI generated inputData: ${inputDataText}` + }; + } + + function addGeneratedFixture(generated: GeneratedFixture, index: number) { + setFixtures((current) => [...current, fixtureFromGenerated(generated, index)]); + } + + function addAllGeneratedFixtures() { + setFixtures((current) => [ + ...current, + ...generatedFixtures.map((fixture, index) => fixtureFromGenerated(fixture, index)) + ]); + } + + async function generateFixtures() { + setIsGenerating(true); + onError(""); + try { + const payload = await api.post(`/api/measures/${measureId}/ai/generate-test-fixtures`); + setGeneratedFixtures(payload ?? []); + emitToast("AI draft fixtures generated"); + } catch (err) { + onError(err instanceof Error ? err.message : "Fixture generation failed"); + } finally { + setIsGenerating(false); + } + } + async function save() { onError(""); try { @@ -62,6 +112,13 @@ export function TestsTab({ measureId, api, initialFixtures, onSaved, onError }:

Fixture Validation

+
+ {generatedFixtures.length > 0 ? ( +
+
+

AI-generated fixtures

+ +
+

+ AI-generated fixtures — verify expected outcomes match your CQL logic before running. +

+
+ {generatedFixtures.map((fixture, index) => ( +
+
+
+

{fixture.name}

+

Expected: {labelFor(OUTCOME_LABELS, fixture.expectedOutcome)}

+
+ +
+
+                  {JSON.stringify(fixture.inputData, null, 2)}
+                
+
+ ))} +
+
+ ) : null} + {fixtures.length === 0 ?

No fixtures yet. Add at least one before activation.

: null} {fixtures.map((fixture, index) => (