Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ WorkWell Measure Studio is a Spring Boot + Next.js monorepo for **Total Worker H

- Lifecycle-managed measures: `Draft -> Approved -> Active -> Deprecated`
- CQL compile + fixture validation gates before activation
- Scoped run pipeline: `ALL_PROGRAMS`, `MEASURE`, `CASE`
- Scoped run pipeline: `ALL_PROGRAMS`, `MEASURE`, `SITE`, `EMPLOYEE`, `CASE`
- Case operations: outreach, assign/escalate, rerun-to-verify, timeline audit
- AI assist for CQL drafting and test fixture generation (never compliance decisions)
- MAT-compatible FHIR R4 export for measure portability
Expand Down Expand Up @@ -104,6 +104,7 @@ npm run build
- `GET /api/programs/{measureId}/risk-outlook?horizonDays=30`
- `GET /api/measures/{measureId}/versions/{versionId}/export/mat?format=xml`
- `POST /api/runs/manual`
- `POST /api/runs/{id}/rerun`
- `GET /api/cases?status=open`
- `GET /api/exports/runs?format=csv`
- `GET /api/auditor/cases/{caseId}/packet?format=json|html`
Expand Down
165 changes: 112 additions & 53 deletions backend/src/main/java/com/workwell/run/AllProgramsRunService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
Expand All @@ -32,21 +33,24 @@ public class AllProgramsRunService {
private final CqlEvaluationService cqlEvaluationService;
private final CaseFlowService caseFlowService;
private final ObjectMapper objectMapper;
private final ObjectProvider<AllProgramsRunService> selfProvider;

public AllProgramsRunService(
RunPersistenceService runPersistenceService,
MeasureService measureService,
JdbcTemplate jdbcTemplate,
CqlEvaluationService cqlEvaluationService,
CaseFlowService caseFlowService,
ObjectMapper objectMapper
ObjectMapper objectMapper,
ObjectProvider<AllProgramsRunService> selfProvider
) {
this.runPersistenceService = runPersistenceService;
this.measureService = measureService;
this.jdbcTemplate = jdbcTemplate;
this.cqlEvaluationService = cqlEvaluationService;
this.caseFlowService = caseFlowService;
this.objectMapper = objectMapper;
this.selfProvider = selfProvider;
}

public ManualRunResponse run(ManualRunRequest request, String triggerActor) {
Expand All @@ -56,11 +60,8 @@ public ManualRunResponse run(ManualRunRequest request, String triggerActor) {

RunScopeType scopeType = request.scopeType();
return switch (scopeType) {
case ALL_PROGRAMS -> runAllPrograms("All Programs", triggerActor, effectiveEvaluationDate(request));
case MEASURE -> runMeasureScope(request, triggerActor);
case CASE -> runCaseScope(request, triggerActor);
case SITE -> throw new IllegalArgumentException("Scope SITE is not implemented yet");
case EMPLOYEE -> throw new IllegalArgumentException("Scope EMPLOYEE is not implemented yet");
case ALL_PROGRAMS, MEASURE, SITE, EMPLOYEE -> queueAsyncScopeRun(request, triggerActor);
};
}

Expand Down Expand Up @@ -169,7 +170,55 @@ public ManualRunResponse rerunSameScope(UUID sourceRunId, String triggerActor) {
RunPersistenceService.RerunScope scope = runPersistenceService.loadRerunScope(sourceRunId)
.orElseThrow(() -> new IllegalArgumentException("Source run not found: " + sourceRunId));
if ("all_programs".equalsIgnoreCase(scope.scopeType())) {
return runAllPrograms("All Programs", triggerActor, LocalDate.now());
return queueAsyncScopeRun(
new ManualRunRequest(
RunScopeType.ALL_PROGRAMS,
null,
null,
null,
null,
null,
null,
false
),
triggerActor
);
}
if ("site".equalsIgnoreCase(scope.scopeType())) {
if (scope.site() == null || scope.site().isBlank()) {
throw new IllegalArgumentException("Source site-scoped run is missing the site value");
}
return queueAsyncScopeRun(
new ManualRunRequest(
RunScopeType.SITE,
null,
null,
scope.site(),
null,
null,
null,
false
),
triggerActor
);
}
if ("employee".equalsIgnoreCase(scope.scopeType())) {
if (scope.employeeExternalId() == null || scope.employeeExternalId().isBlank()) {
throw new IllegalArgumentException("Source employee-scoped run is missing the employeeExternalId value");
}
return queueAsyncScopeRun(
new ManualRunRequest(
RunScopeType.EMPLOYEE,
null,
null,
null,
scope.employeeExternalId(),
null,
null,
false
),
triggerActor
);
}
if (!"measure".equalsIgnoreCase(scope.scopeType()) || scope.scopeId() == null) {
if ("case".equalsIgnoreCase(scope.scopeType())) {
Expand All @@ -188,52 +237,18 @@ public ManualRunResponse rerunSameScope(UUID sourceRunId, String triggerActor) {
throw new IllegalArgumentException("Unsupported run scope type: " + scope.scopeType());
}

Map<String, Object> row = jdbcTemplate.queryForMap(
"""
SELECT m.id AS measure_id,
m.name AS measure_name,
mv.version AS version,
mv.cql_text AS cql_text
FROM measure_versions mv
JOIN measures m ON m.id = mv.measure_id
WHERE mv.id = ?
""",
scope.scopeId()
);
UUID rerunId = UUID.randomUUID();
LocalDate evaluationDate = LocalDate.now();
DemoRunPayload payload;
try {
payload = cqlEvaluationService.evaluate(
rerunId.toString(),
String.valueOf(row.get("measure_name")),
String.valueOf(row.get("version")),
String.valueOf(row.get("cql_text")),
evaluationDate
);
} catch (Exception ex) {
payload = fallbackPayload(
rerunId,
String.valueOf(row.get("measure_name")),
String.valueOf(row.get("version")),
evaluationDate,
ex
);
}
runPersistenceService.persistMeasureRun(
payload,
"measure",
null,
"manual",
triggerActor,
requestedScope("MEASURE", (UUID) row.get("measure_id"), scope.scopeId(), null, null, null, evaluationDate, false),
false
);
return buildResponse(
rerunId,
RunScopeType.MEASURE.name(),
payload.measureName() + " " + payload.measureVersion(),
List.of(payload.measureName())
return queueAsyncScopeRun(
new ManualRunRequest(
RunScopeType.MEASURE,
null,
scope.scopeId(),
null,
null,
null,
null,
false
),
triggerActor
);
}

Expand Down Expand Up @@ -331,6 +346,43 @@ private ManualRunResponse runMeasureScope(ManualRunRequest request, String actor
);
}

private ManualRunResponse queueAsyncScopeRun(ManualRunRequest request, String actor) {
String scopeLabel = buildScopeLabel(request);
List<String> measuresExecuted = measuresExecutedForRequest(request);
UUID runId = createRunRecord(request, actor);
// Route through the proxied bean so Spring applies @Async even for internal dispatches.
selfProvider.getObject().executeRunAsync(runId, request, actor);
return new ManualRunResponse(
runId.toString(),
request.scopeType().name(),
scopeLabel,
"REQUESTED",
measuresExecuted.size(),
0L,
0L,
0L,
"Run queued for execution",
measuresExecuted
);
}

private List<String> measuresExecutedForRequest(ManualRunRequest request) {
return switch (request.scopeType()) {
case MEASURE -> {
try {
yield List.of(resolveMeasureTarget(request).measureName());
} catch (IllegalArgumentException ex) {
yield List.of();
}
}
case CASE -> List.of();
case ALL_PROGRAMS, SITE, EMPLOYEE -> runPersistenceService.loadActiveMeasureScopes().stream()
.map(DemoRunModels.ActiveMeasureScope::measureName)
.distinct()
.toList();
};
}

private ManualRunResponse runCaseScope(ManualRunRequest request, String actor) {
if (request.caseId() == null) {
throw new IllegalArgumentException("caseId is required for CASE scope");
Expand Down Expand Up @@ -601,7 +653,14 @@ private List<DemoRunPayload> evaluateForScopeAsync(ManualRunRequest request, UUI
private String buildScopeLabel(ManualRunRequest request) {
return switch (request.scopeType()) {
case ALL_PROGRAMS -> "All Programs";
case MEASURE -> "Measure";
case MEASURE -> {
try {
ResolvedMeasureTarget target = resolveMeasureTarget(request);
yield target.measureName() + " " + target.measureVersion();
} catch (IllegalArgumentException ex) {
yield "Measure";
}
}
case SITE -> "Site: " + request.site();
case EMPLOYEE -> "Employee: " + request.employeeExternalId();
case CASE -> "Case";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,10 @@ public List<RunLogEntry> loadRunLogs(UUID runId, int limit) {

public Optional<RerunScope> loadRerunScope(UUID runId) {
String sql = """
SELECT scope_type, scope_id, site
SELECT scope_type,
scope_id,
COALESCE(NULLIF(site, ''), NULLIF(requested_scope_json->>'site', '')) AS site,
NULLIF(requested_scope_json->>'employeeExternalId', '') AS employee_external_id
FROM runs
WHERE id = ?
""";
Expand All @@ -739,7 +742,8 @@ public Optional<RerunScope> loadRerunScope(UUID runId) {
return Optional.of(new RerunScope(
row.get("scope_type") == null ? "" : row.get("scope_type").toString(),
(UUID) row.get("scope_id"),
row.get("site") == null ? "" : row.get("site").toString()
row.get("site") == null ? "" : row.get("site").toString(),
row.get("employee_external_id") == null ? "" : row.get("employee_external_id").toString()
));
} catch (EmptyResultDataAccessException ex) {
return Optional.empty();
Expand Down Expand Up @@ -1398,7 +1402,8 @@ public record RunOutcomeRow(
public record RerunScope(
String scopeType,
UUID scopeId,
String site
String site,
String employeeExternalId
) {
}

Expand Down
13 changes: 3 additions & 10 deletions backend/src/main/java/com/workwell/web/EvalController.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
Expand Down Expand Up @@ -108,7 +107,7 @@ public GenericDemoRun runFluVaccine() {

@Operation(
summary = "Trigger a manual run",
description = "Starts an evaluation run over the requested scope (ALL_PROGRAMS, MEASURE, or CASE). "
description = "Starts an evaluation run over the requested scope (ALL_PROGRAMS, MEASURE, SITE, EMPLOYEE, or CASE). "
+ "Rate limited to 5 triggers per user per hour."
)
@PostMapping("/api/runs/manual")
Expand All @@ -118,17 +117,11 @@ public ResponseEntity<Object> runAllPrograms(@RequestBody(required = false) Manu
}
String actor = SecurityActor.currentActor();
try {
ManualRunResponse result = allProgramsRunService.run(request, actor);
if (request.scopeType() == RunScopeType.CASE) {
ManualRunResponse result = allProgramsRunService.run(request, actor);
return ResponseEntity.ok(result);
}
UUID runId = allProgramsRunService.createRunRecord(request, actor);
allProgramsRunService.executeRunAsync(runId, request, actor);
return ResponseEntity.accepted().body(Map.of(
"runId", runId.toString(),
"status", "REQUESTED",
"message", "Run queued for execution. Poll GET /api/runs/" + runId + " for status."
));
return ResponseEntity.accepted().body(result);
} catch (IllegalArgumentException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage(), ex);
}
Expand Down
Loading
Loading