From 5dd9e966fc2b4a2bc7b787cdd86d189c32937f9b Mon Sep 17 00:00:00 2001 From: Taleef Date: Thu, 21 May 2026 10:40:43 -0400 Subject: [PATCH 1/2] feat(deploy): add ecqm and twh instance support - Add 4 wellness CQL measures (hypertension, diabetes_hba1c, obesity_bmi, cholesterol_ldl) - Add WORKWELL_INSTANCE env var with instance-aware seeding in MeasureService - Add 4 new measureSeedSpecFor() cases in CqlEvaluationService - Add 10 wellness value sets in ValueSetGovernanceService (b000... UUID range) - Add NEXT_PUBLIC_APP_NAME/NEXT_PUBLIC_APP_TAGLINE branding to Dockerfile and all public frontend surfaces - Add deploy-ecqm-mieweb.yml and deploy-twh-mieweb.yml GitHub Actions workflows - Add docs/ECQM_TWH_DEPLOYMENT_PLAN.md and JOURNAL.md entry Owner actions needed before deploy: create two Neon projects and add DATABASE_URL_ECQM, DATABASE_URL_TWH, WORKWELL_AUTH_JWT_SECRET_ECQM, WORKWELL_AUTH_JWT_SECRET_TWH as GitHub repository secrets. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/deploy-ecqm-mieweb.yml | 192 ++++++++++++++ .github/workflows/deploy-twh-mieweb.yml | 192 ++++++++++++++ .../compile/CqlEvaluationService.java | 40 +++ .../com/workwell/measure/MeasureService.java | 249 +++++++++++++++++- .../measure/ValueSetGovernanceService.java | 56 ++++ backend/src/main/resources/application.yml | 5 + .../resources/measures/cholesterol_ldl.cql | 62 +++++ .../resources/measures/diabetes_hba1c.cql | 62 +++++ .../main/resources/measures/hypertension.cql | 62 +++++ .../main/resources/measures/obesity_bmi.cql | 62 +++++ docs/ECQM_TWH_DEPLOYMENT_PLAN.md | 154 +++++++++++ docs/JOURNAL.md | 60 +++++ frontend/Dockerfile | 4 + frontend/app/(dashboard)/layout.tsx | 10 +- frontend/app/layout.tsx | 7 +- frontend/app/login/page.tsx | 8 +- frontend/app/page.tsx | 17 +- frontend/app/sandbox/layout.tsx | 4 +- frontend/app/sandbox/page.tsx | 4 +- 19 files changed, 1227 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/deploy-ecqm-mieweb.yml create mode 100644 .github/workflows/deploy-twh-mieweb.yml create mode 100644 backend/src/main/resources/measures/cholesterol_ldl.cql create mode 100644 backend/src/main/resources/measures/diabetes_hba1c.cql create mode 100644 backend/src/main/resources/measures/hypertension.cql create mode 100644 backend/src/main/resources/measures/obesity_bmi.cql create mode 100644 docs/ECQM_TWH_DEPLOYMENT_PLAN.md diff --git a/.github/workflows/deploy-ecqm-mieweb.yml b/.github/workflows/deploy-ecqm-mieweb.yml new file mode 100644 index 0000000..4babd6d --- /dev/null +++ b/.github/workflows/deploy-ecqm-mieweb.yml @@ -0,0 +1,192 @@ +name: Deploy eCQM OS MIEWeb + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + replace_existing: + description: Delete and recreate existing MIE containers if the hostnames already exist. + required: true + default: "false" + type: choice + options: + - "false" + - "true" + +concurrency: + group: deploy-ecqm-mieweb-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + BACKEND_IMAGE: ghcr.io/taleef7/workwell-api + FRONTEND_IMAGE: ghcr.io/taleef7/workwell-ecqm-frontend + FRONTEND_URL: https://ecqm.os.mieweb.org + BACKEND_URL: https://ecqm-api.os.mieweb.org + API_HOSTNAME: ecqm-api + FRONTEND_HOSTNAME: ecqm + SITE_ID: 1 + APP_NAME: "WorkWell eCQM Studio" + APP_TAGLINE: "clinical quality measures for the workforce." + +jobs: + build-backend: + name: Build backend image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ${{ env.BACKEND_IMAGE }}:latest + ${{ env.BACKEND_IMAGE }}:sha-${{ github.sha }} + + build-frontend: + name: Build ecqm frontend image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + build-args: | + NEXT_PUBLIC_API_URL=${{ env.BACKEND_URL }} + NEXT_PUBLIC_APP_NAME=${{ env.APP_NAME }} + NEXT_PUBLIC_APP_TAGLINE=${{ env.APP_TAGLINE }} + tags: | + ${{ env.FRONTEND_IMAGE }}:latest + ${{ env.FRONTEND_IMAGE }}:sha-${{ github.sha }} + + deploy-backend: + name: Deploy ecqm backend container + runs-on: ubuntu-latest + needs: build-backend + env: + MIEWEB_API_URL: ${{ secrets.LAUNCHPAD_API_URL }} + MIEWEB_API_KEY: ${{ secrets.LAUNCHPAD_API_KEY }} + DATABASE_URL: ${{ secrets.DATABASE_URL_ECQM }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + WORKWELL_AUTH_JWT_SECRET: ${{ secrets.WORKWELL_AUTH_JWT_SECRET_ECQM }} + REPLACE_EXISTING: ${{ github.event_name == 'push' || inputs.replace_existing == 'true' }} + steps: + - uses: actions/checkout@v4 + + - name: Validate required secrets + run: | + missing=() + [ -z "$MIEWEB_API_URL" ] && missing+=("LAUNCHPAD_API_URL") + [ -z "$MIEWEB_API_KEY" ] && missing+=("LAUNCHPAD_API_KEY") + [ -z "$DATABASE_URL" ] && missing+=("DATABASE_URL_ECQM") + [ -z "$OPENAI_API_KEY" ] && missing+=("OPENAI_API_KEY") + [ -z "$WORKWELL_AUTH_JWT_SECRET" ] && missing+=("WORKWELL_AUTH_JWT_SECRET_ECQM") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing required secret(s): ${missing[*]}" + exit 1 + fi + + - name: Prepare backend environment variables + id: backend-env + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Deploy backend through Create-a-Container API + env: + CONTAINER_HOSTNAME: ${{ env.API_HOSTNAME }} + CONTAINER_IMAGE: ${{ env.BACKEND_IMAGE }}:sha-${{ github.sha }} + INTERNAL_PORT: 8080 + CONTAINER_ENV_VARS_JSON: ${{ steps.backend-env.outputs.json }} + run: bash .github/scripts/deploy-mieweb-container.sh + + deploy-frontend: + name: Deploy ecqm frontend container + runs-on: ubuntu-latest + needs: [build-frontend, deploy-backend] + env: + MIEWEB_API_URL: ${{ secrets.LAUNCHPAD_API_URL }} + MIEWEB_API_KEY: ${{ secrets.LAUNCHPAD_API_KEY }} + REPLACE_EXISTING: ${{ github.event_name == 'push' || inputs.replace_existing == 'true' }} + steps: + - uses: actions/checkout@v4 + + - name: Validate required secrets + run: | + missing=() + [ -z "$MIEWEB_API_URL" ] && missing+=("LAUNCHPAD_API_URL") + [ -z "$MIEWEB_API_KEY" ] && missing+=("LAUNCHPAD_API_KEY") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing required secret(s): ${missing[*]}" + exit 1 + fi + + - name: Prepare frontend environment variables + id: frontend-env + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Deploy frontend through Create-a-Container API + env: + CONTAINER_HOSTNAME: ${{ env.FRONTEND_HOSTNAME }} + CONTAINER_IMAGE: ${{ env.FRONTEND_IMAGE }}:sha-${{ github.sha }} + INTERNAL_PORT: 3000 + CONTAINER_ENV_VARS_JSON: ${{ steps.frontend-env.outputs.json }} + run: bash .github/scripts/deploy-mieweb-container.sh diff --git a/.github/workflows/deploy-twh-mieweb.yml b/.github/workflows/deploy-twh-mieweb.yml new file mode 100644 index 0000000..e0dbff0 --- /dev/null +++ b/.github/workflows/deploy-twh-mieweb.yml @@ -0,0 +1,192 @@ +name: Deploy TWH OS MIEWeb + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + replace_existing: + description: Delete and recreate existing MIE containers if the hostnames already exist. + required: true + default: "false" + type: choice + options: + - "false" + - "true" + +concurrency: + group: deploy-twh-mieweb-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + BACKEND_IMAGE: ghcr.io/taleef7/workwell-api + FRONTEND_IMAGE: ghcr.io/taleef7/workwell-twh-frontend + FRONTEND_URL: https://twh.os.mieweb.org + BACKEND_URL: https://twh-api.os.mieweb.org + API_HOSTNAME: twh-api + FRONTEND_HOSTNAME: twh + SITE_ID: 1 + APP_NAME: "WorkWell TWH" + APP_TAGLINE: "Total Worker Health — safety and wellness unified." + +jobs: + build-backend: + name: Build backend image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ${{ env.BACKEND_IMAGE }}:latest + ${{ env.BACKEND_IMAGE }}:sha-${{ github.sha }} + + build-frontend: + name: Build twh frontend image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + build-args: | + NEXT_PUBLIC_API_URL=${{ env.BACKEND_URL }} + NEXT_PUBLIC_APP_NAME=${{ env.APP_NAME }} + NEXT_PUBLIC_APP_TAGLINE=${{ env.APP_TAGLINE }} + tags: | + ${{ env.FRONTEND_IMAGE }}:latest + ${{ env.FRONTEND_IMAGE }}:sha-${{ github.sha }} + + deploy-backend: + name: Deploy twh backend container + runs-on: ubuntu-latest + needs: build-backend + env: + MIEWEB_API_URL: ${{ secrets.LAUNCHPAD_API_URL }} + MIEWEB_API_KEY: ${{ secrets.LAUNCHPAD_API_KEY }} + DATABASE_URL: ${{ secrets.DATABASE_URL_TWH }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + WORKWELL_AUTH_JWT_SECRET: ${{ secrets.WORKWELL_AUTH_JWT_SECRET_TWH }} + REPLACE_EXISTING: ${{ github.event_name == 'push' || inputs.replace_existing == 'true' }} + steps: + - uses: actions/checkout@v4 + + - name: Validate required secrets + run: | + missing=() + [ -z "$MIEWEB_API_URL" ] && missing+=("LAUNCHPAD_API_URL") + [ -z "$MIEWEB_API_KEY" ] && missing+=("LAUNCHPAD_API_KEY") + [ -z "$DATABASE_URL" ] && missing+=("DATABASE_URL_TWH") + [ -z "$OPENAI_API_KEY" ] && missing+=("OPENAI_API_KEY") + [ -z "$WORKWELL_AUTH_JWT_SECRET" ] && missing+=("WORKWELL_AUTH_JWT_SECRET_TWH") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing required secret(s): ${missing[*]}" + exit 1 + fi + + - name: Prepare backend environment variables + id: backend-env + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Deploy backend through Create-a-Container API + env: + CONTAINER_HOSTNAME: ${{ env.API_HOSTNAME }} + CONTAINER_IMAGE: ${{ env.BACKEND_IMAGE }}:sha-${{ github.sha }} + INTERNAL_PORT: 8080 + CONTAINER_ENV_VARS_JSON: ${{ steps.backend-env.outputs.json }} + run: bash .github/scripts/deploy-mieweb-container.sh + + deploy-frontend: + name: Deploy twh frontend container + runs-on: ubuntu-latest + needs: [build-frontend, deploy-backend] + env: + MIEWEB_API_URL: ${{ secrets.LAUNCHPAD_API_URL }} + MIEWEB_API_KEY: ${{ secrets.LAUNCHPAD_API_KEY }} + REPLACE_EXISTING: ${{ github.event_name == 'push' || inputs.replace_existing == 'true' }} + steps: + - uses: actions/checkout@v4 + + - name: Validate required secrets + run: | + missing=() + [ -z "$MIEWEB_API_URL" ] && missing+=("LAUNCHPAD_API_URL") + [ -z "$MIEWEB_API_KEY" ] && missing+=("LAUNCHPAD_API_KEY") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing required secret(s): ${missing[*]}" + exit 1 + fi + + - name: Prepare frontend environment variables + id: frontend-env + run: | + { + echo 'json<> "$GITHUB_OUTPUT" + + - name: Deploy frontend through Create-a-Container API + env: + CONTAINER_HOSTNAME: ${{ env.FRONTEND_HOSTNAME }} + CONTAINER_IMAGE: ${{ env.FRONTEND_IMAGE }}:sha-${{ github.sha }} + INTERNAL_PORT: 3000 + CONTAINER_ENV_VARS_JSON: ${{ steps.frontend-env.outputs.json }} + run: bash .github/scripts/deploy-mieweb-container.sh diff --git a/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java b/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java index 0d57a00..f22b3f2 100644 --- a/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java +++ b/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java @@ -525,6 +525,46 @@ private MeasureSeedSpec measureSeedSpecFor(String measureName) { "urn:workwell:vs:flu-vaccines", true ); + case "Hypertension BP Screening" -> new MeasureSeedSpec( + "hypertension", + "wellness-enrolled", + "urn:workwell:vs:wellness-enrollment", + "wellness-exempt", + "urn:workwell:vs:wellness-exemption", + "bp-screen", + "urn:workwell:vs:bp-screening", + false + ); + case "Diabetes HbA1c Monitoring" -> new MeasureSeedSpec( + "diabetes_hba1c", + "diabetes-enrolled", + "urn:workwell:vs:diabetes-program", + "diabetes-exempt", + "urn:workwell:vs:diabetes-exemption", + "hba1c-lab", + "urn:workwell:vs:hba1c-labs", + false + ); + case "BMI Screening & Counseling" -> new MeasureSeedSpec( + "obesity_bmi", + "wellness-enrolled", + "urn:workwell:vs:wellness-enrollment", + "wellness-exempt", + "urn:workwell:vs:wellness-exemption", + "bmi-screen", + "urn:workwell:vs:bmi-screening", + false + ); + case "Cholesterol LDL Screening" -> new MeasureSeedSpec( + "cholesterol_ldl", + "cholesterol-enrolled", + "urn:workwell:vs:cholesterol-program", + "cholesterol-exempt", + "urn:workwell:vs:cholesterol-exemption", + "ldl-lab", + "urn:workwell:vs:ldl-labs", + false + ); default -> null; }; } diff --git a/backend/src/main/java/com/workwell/measure/MeasureService.java b/backend/src/main/java/com/workwell/measure/MeasureService.java index b3f371f..20b2233 100644 --- a/backend/src/main/java/com/workwell/measure/MeasureService.java +++ b/backend/src/main/java/com/workwell/measure/MeasureService.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; @@ -28,6 +29,9 @@ public class MeasureService { private static final String SEEDED_AUDIOGRAM_NAME = "Audiogram"; private static final String SEEDED_AUDIOGRAM_VERSION = "v1.0"; + @Value("${workwell.instance:workwell}") + private String workwellInstance; + private final JdbcTemplate jdbcTemplate; private final ObjectMapper objectMapper; private final CqlCompileValidationService cqlCompileValidationService; @@ -42,11 +46,23 @@ public MeasureService( this.cqlCompileValidationService = cqlCompileValidationService; } + private void ensureInstanceSeeds() { + if ("workwell".equals(workwellInstance) || "twh".equals(workwellInstance)) { + ensureAudiogramSeed(); + ensureTbSeed(); + ensureHazwoperSeed(); + ensureFluSeed(); + } + if ("ecqm".equals(workwellInstance) || "twh".equals(workwellInstance)) { + ensureHypertensionSeed(); + ensureDiabetesHbA1cSeed(); + ensureObesityBmiSeed(); + ensureCholesterolLdlSeed(); + } + } + public List listMeasures(String statusFilter, String search) { - ensureAudiogramSeed(); - ensureTbSeed(); - ensureHazwoperSeed(); - ensureFluSeed(); + ensureInstanceSeeds(); StringBuilder sql = new StringBuilder(""" SELECT m.id, @@ -160,10 +176,7 @@ public UUID createMeasure(String name, String policyRef, String owner) { } public MeasureDetail getMeasure(UUID id) { - ensureAudiogramSeed(); - ensureTbSeed(); - ensureHazwoperSeed(); - ensureFluSeed(); + ensureInstanceSeeds(); String sql = """ SELECT m.id, @@ -1022,6 +1035,226 @@ private void ensureFluSeed() { ); } + private void ensureHypertensionSeed() { + UUID measureId; + try { + measureId = jdbcTemplate.queryForObject( + "SELECT id FROM measures WHERE name = ?", + UUID.class, + "Hypertension BP Screening" + ); + } catch (EmptyResultDataAccessException ex) { + measureId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO measures (id, name, policy_ref, owner, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?::text[], NOW(), NOW())", + measureId, + "Hypertension BP Screening", + "HEDIS BPC / JPMC Wellness Rewards", + "WorkWell Studio", + "{wellness,hypertension,cardiovascular}" + ); + } + + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM measure_versions WHERE measure_id = ? AND version = ?", + Integer.class, measureId, "v1.0" + ); + if (existing != null && existing > 0) { + jdbcTemplate.update( + "UPDATE measure_versions SET cql_text = ?, compile_status = 'COMPILED', compile_result = ?::jsonb WHERE measure_id = ? AND version = ?", + loadSeedCql("hypertension.cql"), + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + measureId, "v1.0" + ); + return; + } + + Map spec = new LinkedHashMap<>(); + spec.put("description", "Annual blood pressure screening for employees enrolled in the wellness program."); + spec.put("eligibilityCriteria", Map.of( + "roleFilter", "All", + "siteFilter", "All Sites", + "programEnrollmentText", "Wellness Program" + )); + spec.put("exclusions", List.of(Map.of("label", "Medical Exemption", "criteriaText", "Documented medical exemption on file"))); + spec.put("complianceWindow", "Annual"); + spec.put("requiredDataElements", List.of("Last BP screening date", "Program enrollment", "Exemption status")); + spec.put("testFixtures", List.of()); + + jdbcTemplate.update( + "INSERT INTO measure_versions (id, measure_id, version, status, spec_json, cql_text, compile_status, compile_result, change_summary, approved_by, activated_at, created_at) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?::jsonb, ?, ?, NOW(), NOW())", + UUID.randomUUID(), measureId, "v1.0", "Active", + toJson(spec), loadSeedCql("hypertension.cql"), "COMPILED", + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + "Seeded active Hypertension BP Screening measure for demo", "system" + ); + } + + private void ensureDiabetesHbA1cSeed() { + UUID measureId; + try { + measureId = jdbcTemplate.queryForObject( + "SELECT id FROM measures WHERE name = ?", + UUID.class, + "Diabetes HbA1c Monitoring" + ); + } catch (EmptyResultDataAccessException ex) { + measureId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO measures (id, name, policy_ref, owner, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?::text[], NOW(), NOW())", + measureId, + "Diabetes HbA1c Monitoring", + "HEDIS HBD / JPMC Wellness Rewards", + "WorkWell Studio", + "{wellness,diabetes,hba1c}" + ); + } + + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM measure_versions WHERE measure_id = ? AND version = ?", + Integer.class, measureId, "v1.0" + ); + if (existing != null && existing > 0) { + jdbcTemplate.update( + "UPDATE measure_versions SET cql_text = ?, compile_status = 'COMPILED', compile_result = ?::jsonb WHERE measure_id = ? AND version = ?", + loadSeedCql("diabetes_hba1c.cql"), + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + measureId, "v1.0" + ); + return; + } + + Map spec = new LinkedHashMap<>(); + spec.put("description", "Biannual HbA1c lab monitoring for employees enrolled in the diabetes management program."); + spec.put("eligibilityCriteria", Map.of( + "roleFilter", "All", + "siteFilter", "All Sites", + "programEnrollmentText", "Diabetes Management Program" + )); + spec.put("exclusions", List.of(Map.of("label", "Medical Exemption", "criteriaText", "Documented medical exemption on file"))); + spec.put("complianceWindow", "Biannual (180 days)"); + spec.put("requiredDataElements", List.of("Last HbA1c lab date", "Program enrollment", "Exemption status")); + spec.put("testFixtures", List.of()); + + jdbcTemplate.update( + "INSERT INTO measure_versions (id, measure_id, version, status, spec_json, cql_text, compile_status, compile_result, change_summary, approved_by, activated_at, created_at) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?::jsonb, ?, ?, NOW(), NOW())", + UUID.randomUUID(), measureId, "v1.0", "Active", + toJson(spec), loadSeedCql("diabetes_hba1c.cql"), "COMPILED", + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + "Seeded active Diabetes HbA1c Monitoring measure for demo", "system" + ); + } + + private void ensureObesityBmiSeed() { + UUID measureId; + try { + measureId = jdbcTemplate.queryForObject( + "SELECT id FROM measures WHERE name = ?", + UUID.class, + "BMI Screening & Counseling" + ); + } catch (EmptyResultDataAccessException ex) { + measureId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO measures (id, name, policy_ref, owner, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?::text[], NOW(), NOW())", + measureId, + "BMI Screening & Counseling", + "HEDIS WCC / Cigna Healthcare Wellness", + "WorkWell Studio", + "{wellness,bmi,obesity}" + ); + } + + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM measure_versions WHERE measure_id = ? AND version = ?", + Integer.class, measureId, "v1.0" + ); + if (existing != null && existing > 0) { + jdbcTemplate.update( + "UPDATE measure_versions SET cql_text = ?, compile_status = 'COMPILED', compile_result = ?::jsonb WHERE measure_id = ? AND version = ?", + loadSeedCql("obesity_bmi.cql"), + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + measureId, "v1.0" + ); + return; + } + + Map spec = new LinkedHashMap<>(); + spec.put("description", "Annual BMI screening and counseling for employees enrolled in the wellness program."); + spec.put("eligibilityCriteria", Map.of( + "roleFilter", "All", + "siteFilter", "All Sites", + "programEnrollmentText", "Wellness Program" + )); + spec.put("exclusions", List.of(Map.of("label", "Medical Exemption", "criteriaText", "Documented medical exemption on file"))); + spec.put("complianceWindow", "Annual"); + spec.put("requiredDataElements", List.of("Last BMI screening date", "Program enrollment", "Exemption status")); + spec.put("testFixtures", List.of()); + + jdbcTemplate.update( + "INSERT INTO measure_versions (id, measure_id, version, status, spec_json, cql_text, compile_status, compile_result, change_summary, approved_by, activated_at, created_at) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?::jsonb, ?, ?, NOW(), NOW())", + UUID.randomUUID(), measureId, "v1.0", "Active", + toJson(spec), loadSeedCql("obesity_bmi.cql"), "COMPILED", + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + "Seeded active BMI Screening & Counseling measure for demo", "system" + ); + } + + private void ensureCholesterolLdlSeed() { + UUID measureId; + try { + measureId = jdbcTemplate.queryForObject( + "SELECT id FROM measures WHERE name = ?", + UUID.class, + "Cholesterol LDL Screening" + ); + } catch (EmptyResultDataAccessException ex) { + measureId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO measures (id, name, policy_ref, owner, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?::text[], NOW(), NOW())", + measureId, + "Cholesterol LDL Screening", + "HEDIS CBP / JPMC Wellness Rewards", + "WorkWell Studio", + "{wellness,cholesterol,cardiovascular}" + ); + } + + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM measure_versions WHERE measure_id = ? AND version = ?", + Integer.class, measureId, "v1.0" + ); + if (existing != null && existing > 0) { + jdbcTemplate.update( + "UPDATE measure_versions SET cql_text = ?, compile_status = 'COMPILED', compile_result = ?::jsonb WHERE measure_id = ? AND version = ?", + loadSeedCql("cholesterol_ldl.cql"), + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + measureId, "v1.0" + ); + return; + } + + Map spec = new LinkedHashMap<>(); + spec.put("description", "Annual LDL cholesterol lab screening for employees enrolled in the cardiovascular risk program."); + spec.put("eligibilityCriteria", Map.of( + "roleFilter", "All", + "siteFilter", "All Sites", + "programEnrollmentText", "Cholesterol Risk Program" + )); + spec.put("exclusions", List.of(Map.of("label", "Medical Exemption", "criteriaText", "Documented medical exemption on file"))); + spec.put("complianceWindow", "Annual"); + spec.put("requiredDataElements", List.of("Last LDL lab date", "Program enrollment", "Exemption status")); + spec.put("testFixtures", List.of()); + + jdbcTemplate.update( + "INSERT INTO measure_versions (id, measure_id, version, status, spec_json, cql_text, compile_status, compile_result, change_summary, approved_by, activated_at, created_at) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?::jsonb, ?, ?, NOW(), NOW())", + UUID.randomUUID(), measureId, "v1.0", "Active", + toJson(spec), loadSeedCql("cholesterol_ldl.cql"), "COMPILED", + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + "Seeded active Cholesterol LDL Screening measure for demo", "system" + ); + } + private List readSqlArray(Array array) { if (array == null) { return List.of(); diff --git a/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java b/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java index 5d4ac04..90dd039 100644 --- a/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java +++ b/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java @@ -352,6 +352,62 @@ private void ensureDemoValueSets() { ensureLink("Flu Vaccine", DEMO_VS_FLU); ensureLink("Flu Vaccine", VS_FLU_ENROLL); ensureLink("Flu Vaccine", VS_FLU_WAIVER); + + // 4. Wellness measure value sets (ecqm / twh instances) + UUID VS_WELLNESS_ENROLL = UUID.fromString("b0000001-0000-0000-0000-000000000001"); + UUID VS_WELLNESS_WAIVER = UUID.fromString("b0000001-0000-0000-0000-000000000002"); + UUID VS_BP_SCREEN = UUID.fromString("b0000001-0000-0000-0000-000000000003"); + UUID VS_DIABETES_ENROLL = UUID.fromString("b0000001-0000-0000-0000-000000000004"); + UUID VS_DIABETES_WAIVER = UUID.fromString("b0000001-0000-0000-0000-000000000005"); + UUID VS_HBA1C_LABS = UUID.fromString("b0000001-0000-0000-0000-000000000006"); + UUID VS_BMI_SCREEN = UUID.fromString("b0000001-0000-0000-0000-000000000007"); + UUID VS_CHOLESTEROL_ENROLL = UUID.fromString("b0000001-0000-0000-0000-000000000008"); + UUID VS_CHOLESTEROL_WAIVER = UUID.fromString("b0000001-0000-0000-0000-000000000009"); + UUID VS_LDL_LABS = UUID.fromString("b0000001-0000-0000-0000-000000000010"); + + ensureValueSet(VS_WELLNESS_ENROLL, "urn:workwell:vs:wellness-enrollment", "Wellness Program Enrollment", "2025-demo", + "[{\"code\":\"wellness-enrolled\",\"display\":\"Wellness Program Enrollment\",\"system\":\"urn:workwell:vs:wellness-enrollment\"}]"); + ensureValueSet(VS_WELLNESS_WAIVER, "urn:workwell:vs:wellness-exemption", "Wellness Exemption Conditions", "2025-demo", + "[{\"code\":\"wellness-exempt\",\"display\":\"Wellness Exemption Conditions\",\"system\":\"urn:workwell:vs:wellness-exemption\"}]"); + ensureValueSet(VS_BP_SCREEN, "urn:workwell:vs:bp-screening", "BP Screening Procedures", "2025-demo", + "[{\"code\":\"bp-screen\",\"display\":\"Blood Pressure Screening\",\"system\":\"urn:workwell:vs:bp-screening\"}," + + "{\"code\":\"99213\",\"display\":\"Office visit established patient\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]"); + + ensureValueSet(VS_DIABETES_ENROLL, "urn:workwell:vs:diabetes-program", "Diabetes Management Enrollment", "2025-demo", + "[{\"code\":\"diabetes-enrolled\",\"display\":\"Diabetes Management Enrollment\",\"system\":\"urn:workwell:vs:diabetes-program\"}]"); + ensureValueSet(VS_DIABETES_WAIVER, "urn:workwell:vs:diabetes-exemption", "Diabetes Program Exemption", "2025-demo", + "[{\"code\":\"diabetes-exempt\",\"display\":\"Diabetes Program Exemption\",\"system\":\"urn:workwell:vs:diabetes-exemption\"}]"); + ensureValueSet(VS_HBA1C_LABS, "urn:workwell:vs:hba1c-labs", "HbA1c Lab Procedures", "2025-demo", + "[{\"code\":\"hba1c-lab\",\"display\":\"HbA1c Lab\",\"system\":\"urn:workwell:vs:hba1c-labs\"}," + + "{\"code\":\"83036\",\"display\":\"Glycosylated hemoglobin test\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]"); + + ensureValueSet(VS_BMI_SCREEN, "urn:workwell:vs:bmi-screening", "BMI Screening Procedures", "2025-demo", + "[{\"code\":\"bmi-screen\",\"display\":\"BMI Screening\",\"system\":\"urn:workwell:vs:bmi-screening\"}," + + "{\"code\":\"99401\",\"display\":\"Preventive medicine counseling\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]"); + + ensureValueSet(VS_CHOLESTEROL_ENROLL, "urn:workwell:vs:cholesterol-program", "Cholesterol Risk Program Enrollment", "2025-demo", + "[{\"code\":\"cholesterol-enrolled\",\"display\":\"Cholesterol Risk Program Enrollment\",\"system\":\"urn:workwell:vs:cholesterol-program\"}]"); + ensureValueSet(VS_CHOLESTEROL_WAIVER, "urn:workwell:vs:cholesterol-exemption", "Cholesterol Program Exemption", "2025-demo", + "[{\"code\":\"cholesterol-exempt\",\"display\":\"Cholesterol Program Exemption\",\"system\":\"urn:workwell:vs:cholesterol-exemption\"}]"); + ensureValueSet(VS_LDL_LABS, "urn:workwell:vs:ldl-labs", "LDL Cholesterol Lab Procedures", "2025-demo", + "[{\"code\":\"ldl-lab\",\"display\":\"LDL Cholesterol Lab\",\"system\":\"urn:workwell:vs:ldl-labs\"}," + + "{\"code\":\"83721\",\"display\":\"LDL cholesterol direct measurement\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]"); + + ensureLink("Hypertension BP Screening", VS_BP_SCREEN); + ensureLink("Hypertension BP Screening", VS_WELLNESS_ENROLL); + ensureLink("Hypertension BP Screening", VS_WELLNESS_WAIVER); + + ensureLink("Diabetes HbA1c Monitoring", VS_HBA1C_LABS); + ensureLink("Diabetes HbA1c Monitoring", VS_DIABETES_ENROLL); + ensureLink("Diabetes HbA1c Monitoring", VS_DIABETES_WAIVER); + + ensureLink("BMI Screening & Counseling", VS_BMI_SCREEN); + ensureLink("BMI Screening & Counseling", VS_WELLNESS_ENROLL); + ensureLink("BMI Screening & Counseling", VS_WELLNESS_WAIVER); + + ensureLink("Cholesterol LDL Screening", VS_LDL_LABS); + ensureLink("Cholesterol LDL Screening", VS_CHOLESTEROL_ENROLL); + ensureLink("Cholesterol LDL Screening", VS_CHOLESTEROL_WAIVER); } private void ensureValueSet(UUID id, String oid, String name, String version, String codesJson) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 3bca76d..8a1abac 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -30,6 +30,7 @@ management: include: health workwell: + instance: ${WORKWELL_INSTANCE:workwell} auth: enabled: ${WORKWELL_AUTH_ENABLED:true} jwt-secret: ${WORKWELL_AUTH_JWT_SECRET:workwell-demo-secret-change-me} @@ -52,6 +53,10 @@ workwell: tb_surveillance: 0.91 hazwoper: 0.65 flu_vaccine: 0.84 + hypertension: 0.72 + diabetes_hba1c: 0.68 + obesity_bmi: 0.81 + cholesterol_ldl: 0.74 scheduler: enabled: ${WORKWELL_SCHEDULER_ENABLED:true} cron: ${WORKWELL_SCHEDULER_CRON:0 0 2 * * *} diff --git a/backend/src/main/resources/measures/cholesterol_ldl.cql b/backend/src/main/resources/measures/cholesterol_ldl.cql new file mode 100644 index 0000000..473df74 --- /dev/null +++ b/backend/src/main/resources/measures/cholesterol_ldl.cql @@ -0,0 +1,62 @@ +library CholesterolLDLScreeningCQL version '1.0.0' +using FHIR version '4.0.1' +include FHIRHelpers version '4.0.1' called FHIRHelpers + +valueset "LDL Lab Procedures": 'urn:workwell:vs:ldl-labs' +valueset "Cholesterol Program Enrollment": 'urn:workwell:vs:cholesterol-program' +valueset "Cholesterol Program Exemption": 'urn:workwell:vs:cholesterol-exemption' + +parameter "Measurement Period" Interval +context Patient + +define "In Cholesterol Program": + exists([Condition]) + +define "Has Medical Exemption": + Count([Condition]) > 1 + +define "Most Recent LDL Screening Date": + Last( + [Procedure] P + sort by (performed as FHIR.dateTime) + ).performed as FHIR.dateTime + +define "Days Since Last LDL Screening": + difference in days between + Coalesce("Most Recent LDL Screening Date", @1900-01-01T00:00:00.0) + and Now() + +define "Compliant": + "In Cholesterol Program" + and not "Has Medical Exemption" + and "Days Since Last LDL Screening" <= 335 + +define "Due Soon": + "In Cholesterol Program" + and not "Has Medical Exemption" + and "Days Since Last LDL Screening" > 335 + and "Days Since Last LDL Screening" <= 365 + +define "Overdue": + "In Cholesterol Program" + and not "Has Medical Exemption" + and "Days Since Last LDL Screening" > 365 + +define "Missing Data": + "In Cholesterol Program" + and not "Has Medical Exemption" + and "Most Recent LDL Screening Date" is null + +define "Excluded": + "Has Medical Exemption" + +define "Initial Population": + "In Cholesterol Program" or "Has Medical Exemption" + +define "Outcome Status": + if "Excluded" then 'EXCLUDED' + else if "Missing Data" then 'MISSING_DATA' + else if "Overdue" then 'OVERDUE' + else if "Due Soon" then 'DUE_SOON' + else if "Compliant" then 'COMPLIANT' + else 'MISSING_DATA' diff --git a/backend/src/main/resources/measures/diabetes_hba1c.cql b/backend/src/main/resources/measures/diabetes_hba1c.cql new file mode 100644 index 0000000..ea40be9 --- /dev/null +++ b/backend/src/main/resources/measures/diabetes_hba1c.cql @@ -0,0 +1,62 @@ +library DiabetesHbA1cMonitoringCQL version '1.0.0' +using FHIR version '4.0.1' +include FHIRHelpers version '4.0.1' called FHIRHelpers + +valueset "HbA1c Lab Procedures": 'urn:workwell:vs:hba1c-labs' +valueset "Diabetes Program Enrollment": 'urn:workwell:vs:diabetes-program' +valueset "Diabetes Program Exemption": 'urn:workwell:vs:diabetes-exemption' + +parameter "Measurement Period" Interval +context Patient + +define "In Diabetes Program": + exists([Condition]) + +define "Has Medical Exemption": + Count([Condition]) > 1 + +define "Most Recent HbA1c Date": + Last( + [Procedure] P + sort by (performed as FHIR.dateTime) + ).performed as FHIR.dateTime + +define "Days Since Last HbA1c": + difference in days between + Coalesce("Most Recent HbA1c Date", @1900-01-01T00:00:00.0) + and Now() + +define "Compliant": + "In Diabetes Program" + and not "Has Medical Exemption" + and "Days Since Last HbA1c" <= 160 + +define "Due Soon": + "In Diabetes Program" + and not "Has Medical Exemption" + and "Days Since Last HbA1c" > 160 + and "Days Since Last HbA1c" <= 180 + +define "Overdue": + "In Diabetes Program" + and not "Has Medical Exemption" + and "Days Since Last HbA1c" > 180 + +define "Missing Data": + "In Diabetes Program" + and not "Has Medical Exemption" + and "Most Recent HbA1c Date" is null + +define "Excluded": + "Has Medical Exemption" + +define "Initial Population": + "In Diabetes Program" or "Has Medical Exemption" + +define "Outcome Status": + if "Excluded" then 'EXCLUDED' + else if "Missing Data" then 'MISSING_DATA' + else if "Overdue" then 'OVERDUE' + else if "Due Soon" then 'DUE_SOON' + else if "Compliant" then 'COMPLIANT' + else 'MISSING_DATA' diff --git a/backend/src/main/resources/measures/hypertension.cql b/backend/src/main/resources/measures/hypertension.cql new file mode 100644 index 0000000..131cda5 --- /dev/null +++ b/backend/src/main/resources/measures/hypertension.cql @@ -0,0 +1,62 @@ +library HypertensionBPScreeningCQL version '1.0.0' +using FHIR version '4.0.1' +include FHIRHelpers version '4.0.1' called FHIRHelpers + +valueset "BP Screening Procedures": 'urn:workwell:vs:bp-screening' +valueset "Wellness Program Enrollment": 'urn:workwell:vs:wellness-enrollment' +valueset "Wellness Exemption": 'urn:workwell:vs:wellness-exemption' + +parameter "Measurement Period" Interval +context Patient + +define "In Wellness Program": + exists([Condition]) + +define "Has Medical Exemption": + Count([Condition]) > 1 + +define "Most Recent BP Screening Date": + Last( + [Procedure] P + sort by (performed as FHIR.dateTime) + ).performed as FHIR.dateTime + +define "Days Since Last BP Screening": + difference in days between + Coalesce("Most Recent BP Screening Date", @1900-01-01T00:00:00.0) + and Now() + +define "Compliant": + "In Wellness Program" + and not "Has Medical Exemption" + and "Days Since Last BP Screening" <= 335 + +define "Due Soon": + "In Wellness Program" + and not "Has Medical Exemption" + and "Days Since Last BP Screening" > 335 + and "Days Since Last BP Screening" <= 365 + +define "Overdue": + "In Wellness Program" + and not "Has Medical Exemption" + and "Days Since Last BP Screening" > 365 + +define "Missing Data": + "In Wellness Program" + and not "Has Medical Exemption" + and "Most Recent BP Screening Date" is null + +define "Excluded": + "Has Medical Exemption" + +define "Initial Population": + "In Wellness Program" or "Has Medical Exemption" + +define "Outcome Status": + if "Excluded" then 'EXCLUDED' + else if "Missing Data" then 'MISSING_DATA' + else if "Overdue" then 'OVERDUE' + else if "Due Soon" then 'DUE_SOON' + else if "Compliant" then 'COMPLIANT' + else 'MISSING_DATA' diff --git a/backend/src/main/resources/measures/obesity_bmi.cql b/backend/src/main/resources/measures/obesity_bmi.cql new file mode 100644 index 0000000..3e858db --- /dev/null +++ b/backend/src/main/resources/measures/obesity_bmi.cql @@ -0,0 +1,62 @@ +library ObesityBMIScreeningCQL version '1.0.0' +using FHIR version '4.0.1' +include FHIRHelpers version '4.0.1' called FHIRHelpers + +valueset "BMI Screening Procedures": 'urn:workwell:vs:bmi-screening' +valueset "Wellness Program Enrollment": 'urn:workwell:vs:wellness-enrollment' +valueset "Wellness Exemption": 'urn:workwell:vs:wellness-exemption' + +parameter "Measurement Period" Interval +context Patient + +define "In Wellness Program": + exists([Condition]) + +define "Has Medical Exemption": + Count([Condition]) > 1 + +define "Most Recent BMI Screening Date": + Last( + [Procedure] P + sort by (performed as FHIR.dateTime) + ).performed as FHIR.dateTime + +define "Days Since Last BMI Screening": + difference in days between + Coalesce("Most Recent BMI Screening Date", @1900-01-01T00:00:00.0) + and Now() + +define "Compliant": + "In Wellness Program" + and not "Has Medical Exemption" + and "Days Since Last BMI Screening" <= 335 + +define "Due Soon": + "In Wellness Program" + and not "Has Medical Exemption" + and "Days Since Last BMI Screening" > 335 + and "Days Since Last BMI Screening" <= 365 + +define "Overdue": + "In Wellness Program" + and not "Has Medical Exemption" + and "Days Since Last BMI Screening" > 365 + +define "Missing Data": + "In Wellness Program" + and not "Has Medical Exemption" + and "Most Recent BMI Screening Date" is null + +define "Excluded": + "Has Medical Exemption" + +define "Initial Population": + "In Wellness Program" or "Has Medical Exemption" + +define "Outcome Status": + if "Excluded" then 'EXCLUDED' + else if "Missing Data" then 'MISSING_DATA' + else if "Overdue" then 'OVERDUE' + else if "Due Soon" then 'DUE_SOON' + else if "Compliant" then 'COMPLIANT' + else 'MISSING_DATA' diff --git a/docs/ECQM_TWH_DEPLOYMENT_PLAN.md b/docs/ECQM_TWH_DEPLOYMENT_PLAN.md new file mode 100644 index 0000000..693650a --- /dev/null +++ b/docs/ECQM_TWH_DEPLOYMENT_PLAN.md @@ -0,0 +1,154 @@ +# eCQM and TWH Instance Deployment Plan + +**Date:** 2026-05-21 +**Branch:** `feat/ecqm-twh-instances` +**Status:** Implementation complete — pending owner actions and deploy + +--- + +## Context + +Doug requested two additional WorkWell-stack deployments alongside `workwell.os.mieweb.org`: + +- **ecqm.os.mieweb.org** — Clinical quality / wellness measures (hypertension, diabetes HbA1c, obesity BMI, cholesterol LDL). Audience: clinical quality teams and JPMC/Cigna-style wellness programs. +- **twh.os.mieweb.org** — Total Worker Health (NIOSH). Combines both OSHA safety measures and wellness measures in one instance. Audience: integrated EHS + wellness programs. + +The workwell instance keeps its existing 4 OSHA measures (Audiogram, TB, HAZWOPER, Flu Vaccine) unchanged. + +--- + +## Architecture + +``` +Same repo + → One backend image (ghcr.io/taleef7/workwell-api) + Runtime env: WORKWELL_INSTANCE=ecqm|twh|workwell + Seeds: instance-aware (ecqm=wellness only, twh=both, workwell=OSHA only) + + → Three frontend images (separate build per instance) + ghcr.io/taleef7/workwell-frontend (workwell, existing) + ghcr.io/taleef7/workwell-ecqm-frontend (ecqm — new) + ghcr.io/taleef7/workwell-twh-frontend (twh — new) + Build args: NEXT_PUBLIC_API_URL, NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_APP_TAGLINE + + → Three Neon databases (one per instance, independent) + Existing: DATABASE_URL (workwell) + New: DATABASE_URL_ECQM, DATABASE_URL_TWH (Taleef provisions) + + → Three MIE hostname pairs + workwell / workwell-api (existing) + ecqm / ecqm-api (new) + twh / twh-api (new) +``` + +## Measure Assignment Per Instance + +| Measure | workwell | ecqm | twh | +|---|---|---|---| +| Audiogram (OSHA) | ✓ | — | ✓ | +| TB Surveillance (CDC) | ✓ | — | ✓ | +| HAZWOPER Surveillance | ✓ | — | ✓ | +| Flu Vaccine | ✓ | — | ✓ | +| Hypertension Control | — | ✓ | ✓ | +| Diabetes HbA1c | — | ✓ | ✓ | +| Obesity BMI Screening | — | ✓ | ✓ | +| Cholesterol LDL | — | ✓ | ✓ | + +--- + +## Implementation Completed + +### Task 1 — Wellness CQL measure files ✅ +- `backend/src/main/resources/measures/hypertension.cql` — Annual BP screening (365-day window) +- `backend/src/main/resources/measures/diabetes_hba1c.cql` — Biannual HbA1c (180-day window) +- `backend/src/main/resources/measures/obesity_bmi.cql` — Annual BMI screening (365-day window) +- `backend/src/main/resources/measures/cholesterol_ldl.cql` — Annual LDL screening (365-day window) + +All 4 follow the exact same pattern as audiogram.cql: FHIR R4 Procedure resources, valueset declarations, Compliant/DueSoon/Overdue/MissingData/Excluded defines, and a final `Outcome Status` string define. + +### Task 2 — `WORKWELL_INSTANCE` config property ✅ +- `backend/src/main/resources/application.yml` — Added `workwell.instance: ${WORKWELL_INSTANCE:workwell}` and 4 new compliance rates + +### Task 3 — Instance-aware seeding in `MeasureService` ✅ +- Added `@Value("${workwell.instance:workwell}") private String workwellInstance` +- Added `ensureInstanceSeeds()` gating OSHA seeds on `workwell|twh`, wellness seeds on `ecqm|twh` +- Added `ensureHypertensionSeed()`, `ensureDiabetesHbA1cSeed()`, `ensureObesityBmiSeed()`, `ensureCholesterolLdlSeed()` + +### Task 4 — New cases in `CqlEvaluationService.measureSeedSpecFor()` ✅ +- Added 4 cases (Hypertension BP Screening, Diabetes HbA1c Monitoring, BMI Screening & Counseling, Cholesterol LDL Screening) — all `useImmunization=false` + +### Task 5 — Wellness value sets in `ValueSetGovernanceService` ✅ +- Added 10 value sets with `b0000001-...` UUIDs: + - wellness-enrollment, wellness-exemption, bp-screening (CPT 99213) + - diabetes-program, diabetes-exemption, hba1c-labs (CPT 83036) + - bmi-screening (CPT 99401), cholesterol-program, cholesterol-exemption, ldl-labs (CPT 83721) +- Added `ensureLink()` calls for all 4 wellness measures + +### Task 6 — Frontend branding env vars ✅ +- `frontend/Dockerfile` — Added `NEXT_PUBLIC_APP_NAME` and `NEXT_PUBLIC_APP_TAGLINE` build args +- Updated `app/layout.tsx`, `app/page.tsx`, `app/(dashboard)/layout.tsx`, `app/login/page.tsx`, `app/sandbox/page.tsx`, `app/sandbox/layout.tsx` to use the env vars + +### Task 7 — `deploy-ecqm-mieweb.yml` ✅ +- `.github/workflows/deploy-ecqm-mieweb.yml` — Builds `workwell-ecqm-frontend` image with eCQM branding, deploys to `ecqm-api`/`ecqm` hostnames, uses `DATABASE_URL_ECQM` + `WORKWELL_AUTH_JWT_SECRET_ECQM`, sets `WORKWELL_INSTANCE=ecqm` + +### Task 8 — `deploy-twh-mieweb.yml` ✅ +- `.github/workflows/deploy-twh-mieweb.yml` — Builds `workwell-twh-frontend` image with TWH branding, deploys to `twh-api`/`twh` hostnames, uses `DATABASE_URL_TWH` + `WORKWELL_AUTH_JWT_SECRET_TWH`, sets `WORKWELL_INSTANCE=twh` + +--- + +## Owner Actions Required (Taleef) + +These require manual steps outside the codebase — complete before triggering the workflows. + +### 1. Create Neon databases +In your Neon account, create two projects: +- `workwell-ecqm` (Postgres 16, region us-east) +- `workwell-twh` (Postgres 16, region us-east) + +Copy the **pooled** connection string for each (used as `DATABASE_URL` by the app). + +### 2. Add GitHub repository secrets +Settings → Secrets and variables → Actions → New repository secret: + +| Secret name | Value | +|---|---| +| `DATABASE_URL_ECQM` | Neon pooled connection string for workwell-ecqm | +| `DATABASE_URL_TWH` | Neon pooled connection string for workwell-twh | +| `WORKWELL_AUTH_JWT_SECRET_ECQM` | Strong random string ≥32 chars | +| `WORKWELL_AUTH_JWT_SECRET_TWH` | Strong random string ≥32 chars | + +Existing shared secrets (`LAUNCHPAD_API_URL`, `LAUNCHPAD_API_KEY`, `OPENAI_API_KEY`) are reused — no new additions needed. + +### 3. Make GHCR packages public +After the first workflow run pushes the new images, go to: +- GitHub → Packages → `workwell-ecqm-frontend` → Package settings → Make public +- GitHub → Packages → `workwell-twh-frontend` → Package settings → Make public + +--- + +## Verification + +### Local backend verification +```bash +cd backend + +# ecqm: expect Hypertension, Diabetes, BMI, Cholesterol (no OSHA measures) +WORKWELL_INSTANCE=ecqm ./gradlew bootRun + +# twh: expect all 8 measures +WORKWELL_INSTANCE=twh ./gradlew bootRun + +# workwell (default): expect 4 OSHA measures +./gradlew bootRun +``` + +### Post-deploy smoke checks +``` +GET https://ecqm-api.os.mieweb.org/actuator/health → {"status":"UP"} +GET https://ecqm.os.mieweb.org/ → 200, page title "WorkWell eCQM Studio" +GET https://ecqm-api.os.mieweb.org/api/measures → 4 wellness measures + +GET https://twh-api.os.mieweb.org/actuator/health → {"status":"UP"} +GET https://twh.os.mieweb.org/ → 200, page title "WorkWell TWH" +GET https://twh-api.os.mieweb.org/api/measures → 8 measures (OSHA + wellness) +``` diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 620fe32..8734a28 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -1,5 +1,65 @@ # Journal +## 2026-05-21 — eCQM and TWH instance support (feat/ecqm-twh-instances) + +**Goal:** Add `ecqm.os.mieweb.org` (clinical quality / wellness measures) and `twh.os.mieweb.org` (Total Worker Health — all 8 measures) as independent WorkWell instances. Same backend Docker image, instance-aware seeding via `WORKWELL_INSTANCE` env var, separate Neon databases, separate frontend Docker images with per-instance branding. + +**Branch:** `feat/ecqm-twh-instances` + +**What changed:** + +- `backend/src/main/resources/measures/hypertension.cql` — New CQL library `HypertensionBPScreeningCQL 1.0.0`. Annual BP screening (compliance window 365 days, DueSoon 336–365), wellness-enrollment/exemption value sets. +- `backend/src/main/resources/measures/diabetes_hba1c.cql` — New CQL library `DiabetesHbA1cMonitoringCQL 1.0.0`. Biannual HbA1c (compliance window 180 days, DueSoon 161–180), diabetes-program/exemption value sets. +- `backend/src/main/resources/measures/obesity_bmi.cql` — New CQL library `ObesityBMIScreeningCQL 1.0.0`. Annual BMI screening (compliance window 365 days), wellness-enrollment/exemption value sets. +- `backend/src/main/resources/measures/cholesterol_ldl.cql` — New CQL library `CholesterolLDLScreeningCQL 1.0.0`. Annual LDL screening (compliance window 365 days), cholesterol-program/exemption value sets. +- `backend/src/main/resources/application.yml` — Added `workwell.instance: ${WORKWELL_INSTANCE:workwell}` property; added 4 new compliance rates (hypertension: 0.72, diabetes_hba1c: 0.68, obesity_bmi: 0.81, cholesterol_ldl: 0.74). +- `backend/src/main/java/com/workwell/measure/MeasureService.java` — Added `@Value("${workwell.instance:workwell}") private String workwellInstance`; added `ensureInstanceSeeds()` that gates OSHA seeds on `workwell|twh` and wellness seeds on `ecqm|twh`; replaced direct seed calls in `listMeasures()`/`getMeasure()` with `ensureInstanceSeeds()`; added 4 new seed methods (`ensureHypertensionSeed`, `ensureDiabetesHbA1cSeed`, `ensureObesityBmiSeed`, `ensureCholesterolLdlSeed`). +- `backend/src/main/java/com/workwell/compile/CqlEvaluationService.java` — Added 4 new cases to `measureSeedSpecFor()` switch (Hypertension BP Screening, Diabetes HbA1c Monitoring, BMI Screening & Counseling, Cholesterol LDL Screening). All 4 use `useImmunization=false` (Procedure resources). +- `backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java` — Added 10 wellness value sets inside `ensureDemoValueSets()` using `b0000001-...` UUID range (non-colliding with existing `a000...` OSHA UUIDs): wellness-enrollment, wellness-exemption, bp-screening (CPT 99213), diabetes-program, diabetes-exemption, hba1c-labs (CPT 83036), bmi-screening (CPT 99401), cholesterol-program, cholesterol-exemption, ldl-labs (CPT 83721). Added `ensureLink()` calls for all 4 wellness measures. +- `frontend/Dockerfile` — Added `NEXT_PUBLIC_APP_NAME` and `NEXT_PUBLIC_APP_TAGLINE` build args (default to workwell values); `ENV` statements bake them into each per-instance image at build time. +- `frontend/app/layout.tsx` — Root metadata uses `NEXT_PUBLIC_APP_NAME`/`NEXT_PUBLIC_APP_TAGLINE` env vars. +- `frontend/app/page.tsx` — Landing page hero h1, header brand badge/subtitle, and footer copyright all driven by `NEXT_PUBLIC_APP_NAME`/`NEXT_PUBLIC_APP_TAGLINE` constants derived from env vars. +- `frontend/app/(dashboard)/layout.tsx` — Sidebar and mobile header "WorkWell"/"Measure Studio" spans driven by split of `NEXT_PUBLIC_APP_NAME`. +- `frontend/app/login/page.tsx` — Left-panel brand badge/subtitle driven by `NEXT_PUBLIC_APP_NAME` split. +- `frontend/app/sandbox/page.tsx` — "WorkWell Measure Studio" label driven by `NEXT_PUBLIC_APP_NAME`. +- `frontend/app/sandbox/layout.tsx` — Metadata description driven by `NEXT_PUBLIC_APP_NAME`. +- `.github/workflows/deploy-ecqm-mieweb.yml` — New workflow: builds same backend image + separate `workwell-ecqm-frontend` image (with eCQM branding build args), deploys to `ecqm-api`/`ecqm` hostnames with `WORKWELL_INSTANCE=ecqm`, uses `DATABASE_URL_ECQM` and `WORKWELL_AUTH_JWT_SECRET_ECQM` secrets. +- `.github/workflows/deploy-twh-mieweb.yml` — New workflow: builds same backend image + separate `workwell-twh-frontend` image (with TWH branding build args), deploys to `twh-api`/`twh` hostnames with `WORKWELL_INSTANCE=twh`, uses `DATABASE_URL_TWH` and `WORKWELL_AUTH_JWT_SECRET_TWH` secrets. +- `docs/ECQM_TWH_DEPLOYMENT_PLAN.md` — Full deployment plan committed for project-level visibility. + +**Measure assignment per instance:** + +| Measure | workwell | ecqm | twh | +|---|---|---|---| +| Audiogram (OSHA) | ✓ | — | ✓ | +| TB Surveillance | ✓ | — | ✓ | +| HAZWOPER Surveillance | ✓ | — | ✓ | +| Flu Vaccine | ✓ | — | ✓ | +| Hypertension Control | — | ✓ | ✓ | +| Diabetes HbA1c | — | ✓ | ✓ | +| Obesity BMI Screening | — | ✓ | ✓ | +| Cholesterol LDL | — | ✓ | ✓ | + +**Owner actions required (Taleef) before first deploy:** + +1. Create two Neon projects (`workwell-ecqm`, `workwell-twh`), copy pooled connection strings. +2. Add GitHub repository secrets: `DATABASE_URL_ECQM`, `DATABASE_URL_TWH`, `WORKWELL_AUTH_JWT_SECRET_ECQM`, `WORKWELL_AUTH_JWT_SECRET_TWH`. +3. Make GHCR packages `workwell-ecqm-frontend` and `workwell-twh-frontend` public after first push. + +**Verification (local):** +```bash +# ecqm instance — expect Hypertension, Diabetes, BMI, Cholesterol only +WORKWELL_INSTANCE=ecqm ./gradlew bootRun + +# twh instance — expect all 8 measures +WORKWELL_INSTANCE=twh ./gradlew bootRun + +# workwell instance (default) — expect 4 OSHA measures only +./gradlew bootRun +``` + +--- + ## 2026-05-20 — UAT Sections 9-14: Add Mapping UI, Studio packet selector, Demo Reset gating (issue #30) **Goal:** Fix all reported Section 9 (Terminology Mappings), Section 11 (Audit Packets in Studio Release & Approval tab), and Section 14 (Reset Demo Data prod visibility) UAT bugs from GitHub issue #30, plus correct guide inaccuracies for Sections 9–14. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7db79b1..6c743e7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -10,8 +10,12 @@ WORKDIR /app RUN corepack enable && corepack prepare pnpm@10.17.1 --activate ARG NEXT_PUBLIC_API_URL=https://workwell-api.os.mieweb.org +ARG NEXT_PUBLIC_APP_NAME="WorkWell Measure Studio" +ARG NEXT_PUBLIC_APP_TAGLINE="occupational-health compliance." ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME +ENV NEXT_PUBLIC_APP_TAGLINE=$NEXT_PUBLIC_APP_TAGLINE ENV NEXT_TELEMETRY_DISABLED=1 COPY --from=deps /app/node_modules ./node_modules diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx index 780dc60..b59fc92 100644 --- a/frontend/app/(dashboard)/layout.tsx +++ b/frontend/app/(dashboard)/layout.tsx @@ -22,6 +22,10 @@ import { GlobalFilterProvider, useGlobalFilters } from "@/components/global-filt import { ROLE_LABELS, labelFor } from "@/lib/status"; import { GlobalSearch } from "@/components/GlobalSearch"; +const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? "WorkWell Measure Studio"; +const [APP_BADGE, ...appRest] = APP_NAME.split(" "); +const APP_SUBTITLE = appRest.join(" ") || "Measure Studio"; + const nav = [ { href: "/programs", label: "Programs", icon: BarChart3 }, { href: "/cases", label: "Cases", icon: Shield }, @@ -130,8 +134,8 @@ function DashboardShell({ children }: { children: React.ReactNode }) { WW - WorkWell - Measure Studio + {APP_BADGE} + {APP_SUBTITLE}