Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
d7caee5
docs: VSAC value sets integration plan + Parthenon eCQM handoff spec
sudoshi Jun 12, 2026
07a7e1f
feat: VSAC value set reference tables + measure bridge (migration 050)
sudoshi Jun 12, 2026
a204b33
docs: renumber plan migrations to 050/051 (039/040 claimed by concurr…
sudoshi Jun 12, 2026
4d667ec
feat: VSAC data load script (parthenon -> medgnosis, 225k codes + bri…
sudoshi Jun 12, 2026
5355c3d
feat: Wilson 95% CI utility for measure rates
sudoshi Jun 12, 2026
b11f212
feat: VSAC service — value set queries + measure code resolution
sudoshi Jun 12, 2026
b246207
test: update stale comment in vsacService test (nested-fragment ratio…
sudoshi Jun 12, 2026
8ab0fe4
feat: value-sets transparency endpoints (/value-sets, /measure/:code,…
sudoshi Jun 12, 2026
7ae9266
feat: single-pass GROUPING SETS measure strata + Wilson CIs (migratio…
sudoshi Jun 12, 2026
9e1b043
feat: GET /measures/:id/strata — stratified rates with Wilson CIs
sudoshi Jun 12, 2026
77a5dda
feat: MeasureEvaluator seam — swappable sql/cql engines behind one in…
sudoshi Jun 12, 2026
7c7c9f0
fix: add dim_measure FK to fact_measure_strata (final-review finding)
sudoshi Jun 12, 2026
9ad4b06
fix: harden VSAC service docs + load-script verification (adversarial…
sudoshi Jun 13, 2026
e6a54dd
docs: clinical fidelity & delivery hardening plan (next iteration — p…
sudoshi Jun 13, 2026
1eec984
fix(ci): install dblink extension in migration 003 for fresh environm…
sudoshi Jun 13, 2026
a9485b6
feat: population roles on measure-value-set bridge (migration 052)
sudoshi Jun 13, 2026
4907552
fix(ci): make apps/web lint runnable (eslint missing — exit 127)
sudoshi Jun 13, 2026
7c771ca
fix(ci): typecheck depends on ^build; type solr CDC payload param
sudoshi Jun 13, 2026
81abf7c
feat: role-aware resolveMeasureCodes + getMeasureBridgeStatus
sudoshi Jun 13, 2026
3c58205
feat: clinical exclusion engine — VSAC-evidenced exclusions replace h…
sudoshi Jun 13, 2026
254b97b
Merge remote-tracking branch 'origin/main' into feature/clinical-fide…
sudoshi Jun 13, 2026
2817ab0
fix(ci): untrack tsconfig.tsbuildinfo — committed build-state made ts…
sudoshi Jun 13, 2026
3274375
fix(ci): guard 003 dblink ETL in DO/EXCEPTION block — skips gracefull…
sudoshi Jun 13, 2026
7a2027f
fix(ci): 012_clinical_notes — rewrite as ALTER TABLE ADD COLUMN IF NO…
sudoshi Jun 13, 2026
5810112
fix(ci): guard 015 demo seed — provider_id=2816 FK violation on empty DB
sudoshi Jun 13, 2026
a2f002c
fix(ci): guard 021 demo seed DO blocks — provider_id=2816 FK violatio…
sudoshi Jun 13, 2026
8909da4
feat: ACE/ARB safety flag driven by VSAC RxNorm value set + allergy s…
sudoshi Jun 13, 2026
e15feb5
fix(ci): 021 Part E — remove org_id from billing_claim INSERT (column…
sudoshi Jun 13, 2026
5ca392d
fix(ci): 021 Part F — fix medication_order column refs (sig→dosage, o…
sudoshi Jun 13, 2026
77cd0b8
fix(ci): guard 022 demo oncology seed — NULL patient_id on empty DB
sudoshi Jun 13, 2026
50dfa69
fix(ci): 023 — strip psql meta-commands (\echo, \i) that break Node.j…
sudoshi Jun 13, 2026
7754e14
fix(ci): 030 — replace CREATE INDEX CONCURRENTLY with CREATE INDEX
sudoshi Jun 13, 2026
62c624f
fix(ci): 030 — remove self-registration INSERT that double-inserts in…
sudoshi Jun 13, 2026
f6eee33
fix(ci): 041 — note_coded_diagnosis.note_id must be INT not UUID
sudoshi Jun 13, 2026
cbbbf5b
fix(ci): 044 — remove explicit note_id UUID from clinical_note INSERT
sudoshi Jun 13, 2026
012729f
fix(ci): 003 — rewrite address CTE as plain INSERT...SELECT (PL/pgSQL…
sudoshi Jun 13, 2026
91d8c3d
fix: pass vitest with no tests in apps/web
sudoshi Jun 13, 2026
fdd4843
feat: code-system contract DQ detector — EDW↔VSAC label drift surface…
sudoshi Jun 13, 2026
ab5dd4d
feat: surface version drift, small-cell flags, and bound value-set re…
sudoshi Jun 13, 2026
d4730d6
Merge remote-tracking branch 'origin/fix/ci-pipeline' into feature/cl…
sudoshi Jun 13, 2026
35b2529
fix: migration 053 warns instead of failing when VSAC data not yet lo…
sudoshi Jun 13, 2026
0ddcf26
docs: DEVLOG Session 20 — VSAC value sets + clinical fidelity hardeni…
sudoshi Jun 13, 2026
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: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ NGINX_PORT=3081
# Solr and Redis ports exposed to host (for debugging/admin)
SOLR_PORT=8984
REDIS_PORT=6379

# Measure evaluation engine: sql (star-schema aggregation) | cql (future cqf-ruler bridge)
MEASURE_EVALUATOR=sql
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ coverage/
/backend/public/build
/backend/storage/*.key

# TypeScript incremental build state — never commit; causes tsc to skip emission on fresh checkouts
*.tsbuildinfo

# Turbo
.turbo

Expand Down
21 changes: 21 additions & 0 deletions apps/api/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"env": {
"node": true,
"es2022": true
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
}
}
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
"@types/node": "^22.10.1",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^8.57.0",
"supertest": "^7.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
generateDeidentifiedCohort,
} from '../../services/omopExport.js';
import { sql } from '@medgnosis/db';
import { refreshMeasureResults } from '../../services/measureCalculatorV2.js';
import { getMeasureEvaluator } from '../../services/measureEvaluator.js';
import { getSolrClient, isSolrAvailable } from '../../plugins/solr.js';
import { config } from '../../config.js';

Expand Down Expand Up @@ -380,7 +380,7 @@ export default async function adminRoutes(app: FastifyInstance) {

// Also refresh measure results after mat views
try {
await refreshMeasureResults();
await getMeasureEvaluator().refresh();
results.push({ view: 'fact_measure_result', status: 'ok' });
} catch (err) {
results.push({ view: 'fact_measure_result', status: 'error', error: String(err) });
Expand All @@ -396,7 +396,7 @@ export default async function adminRoutes(app: FastifyInstance) {

app.post('/refresh-measures', async (req, reply) => {
try {
const result = await refreshMeasureResults();
const result = await getMeasureEvaluator().refresh();

await sql`
INSERT INTO public.audit_log (user_id, action, resource_type, details)
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import clinicalNoteRoutes from './clinical-notes/index.js';
import orderRoutes from './orders/index.js';
import cdsHooksRoutes from './cds-hooks/index.js';
import rulesRoutes from './rules/index.js';
import valueSetRoutes from './value-sets/index.js';
import problemListRoutes from './problem-list/index.js';
import populationFinderRoutes from './population-finder/index.js';
import closeTheLoopRoutes from './close-the-loop/index.js';
Expand Down Expand Up @@ -58,6 +59,7 @@ export async function registerRoutes(fastify: FastifyInstance): Promise<void> {
await api.register(clinicalNoteRoutes, { prefix: '/clinical-notes' });
await api.register(orderRoutes, { prefix: '/orders' });
await api.register(rulesRoutes, { prefix: '/rules' });
await api.register(valueSetRoutes, { prefix: '/value-sets' });
await api.register(problemListRoutes, { prefix: '/problem-list' });
await api.register(populationFinderRoutes, { prefix: '/population-finder' });
await api.register(closeTheLoopRoutes, { prefix: '/close-the-loop' });
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/routes/measures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { FastifyInstance } from 'fastify';
import { sql } from '@medgnosis/db';
import { measureFilterSchema } from '@medgnosis/shared';
import { wilsonCI } from '../../services/wilsonCI.js';

export default async function measureRoutes(fastify: FastifyInstance): Promise<void> {
fastify.addHook('preHandler', fastify.authenticate);
Expand Down Expand Up @@ -77,4 +78,48 @@ export default async function measureRoutes(fastify: FastifyInstance): Promise<v
},
});
});

// GET /measures/:id/strata — age/sex strata with Wilson 95% CIs.
// measure_id != measure_key: resolve through dim_measure (see GET /:id).
fastify.get<{ Params: { id: string } }>('/:id/strata', async (request, reply) => {
const { id } = request.params;

const rows = await sql<
{ dimension: string; stratum: string; denominator: number; numerator: number; excluded: number }[]
>`
SELECT fms.dimension, fms.stratum,
fms.denominator::int, fms.numerator::int, fms.excluded::int
FROM phm_star.fact_measure_strata fms
JOIN phm_star.dim_measure dm ON dm.measure_key = fms.measure_key
WHERE dm.measure_id = ${id}::int
ORDER BY fms.dimension, fms.stratum
`;

if (rows.length === 0) {
return reply.status(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'No strata for this measure (run a measure refresh first)' },
});
}

const data = rows.map((row) => {
// small_cell: display guidance for wide-CI / small-n strata (denominator
// 1–10). NOT suppression — this is an internal clinical tool; raw n stays
// visible. Consumers may render a warning indicator alongside the rate.
const small_cell = row.denominator > 0 && row.denominator < 11;
if (row.denominator <= 0) {
return { ...row, rate: null, ci_lower: null, ci_upper: null, small_cell };
}
const ci = wilsonCI(row.numerator, row.denominator);
return {
...row,
rate: Math.round((row.numerator / row.denominator) * 1000) / 10,
ci_lower: Math.round(ci.lower * 1000) / 10,
ci_upper: Math.round(ci.upper * 1000) / 10,
small_cell,
};
});

return reply.send({ success: true, data });
});
}
67 changes: 67 additions & 0 deletions apps/api/src/routes/value-sets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// =============================================================================
// Medgnosis API — VSAC value set transparency routes
// Show the authoritative CMS code lists behind any measure. Read-only.
// =============================================================================

import type { FastifyInstance } from 'fastify';
import {
listValueSets,
getValueSetCodes,
getMeasureValueSets,
getMeasureBridgeStatus,
} from '../../services/vsacService.js';

export default async function valueSetRoutes(fastify: FastifyInstance): Promise<void> {
fastify.addHook('preHandler', fastify.authenticate);

// GET /value-sets?search= — catalog with code counts
fastify.get<{ Querystring: { search?: string } }>('/', async (request, reply) => {
const valueSets = await listValueSets(request.query.search);
return reply.send({ success: true, data: valueSets });
});

// GET /value-sets/measure/:measureCode — value sets bridged to a measure
// (registered before /:oid so "measure" is not swallowed as an OID)
// Response includes bridge status (version_drift, unclassified_count, role
// distribution) alongside the value set list. 404 when the measure has no
// bridge rows (status === null).
fastify.get<{ Params: { measureCode: string } }>(
'/measure/:measureCode',
async (request, reply) => {
const [status, value_sets] = await Promise.all([
getMeasureBridgeStatus(request.params.measureCode),
getMeasureValueSets(request.params.measureCode),
]);
if (status === null) {
return reply.status(404).send({
success: false,
error: {
code: 'NOT_FOUND',
message: `No value sets bridged to measure ${request.params.measureCode}`,
},
});
}
return reply.send({ success: true, data: { status, value_sets } });
},
);

// GET /value-sets/:oid/codes?code_system= — the flattened expansion
fastify.get<{
Params: { oid: string };
Querystring: { code_system?: string };
}>('/:oid/codes', async (request, reply) => {
const codes = await getValueSetCodes(request.params.oid, request.query.code_system);
if (codes.length === 0) {
return reply.status(404).send({
success: false,
error: {
code: 'NOT_FOUND',
message: `No codes for value set ${request.params.oid}${
request.query.code_system ? ` in ${request.query.code_system}` : ''
}`,
},
});
}
return reply.send({ success: true, data: codes });
});
}
68 changes: 66 additions & 2 deletions apps/api/src/services/__tests__/cohortFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Unit tests — Cohort high-risk flags + cohort matching (pure)
// =============================================================================

import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';

const { mockSql } = vi.hoisted(() => {
const fn = vi.fn();
Expand All @@ -13,7 +13,7 @@ vi.mock('@medgnosis/db', () => ({
sql: Object.assign(mockSql, { unsafe: vi.fn(), json: (v: unknown) => v }),
}));

import { flagHyperkalemia, flagGfrLow, flagNewAceArbNoBmp, matchesCohort } from '../cohortFlags.js';
import { flagHyperkalemia, flagGfrLow, flagNewAceArbNoBmp, matchesCohort, runCohortFlags } from '../cohortFlags.js';

describe('flagHyperkalemia', () => {
it('flags K >= 5.5', () => {
Expand Down Expand Up @@ -54,3 +54,67 @@ describe('matchesCohort', () => {
expect(matchesCohort({ conditions: ['N18.4'], flags: [] }, { conditions: ['N18.4'] })).toBe(true);
});
});

describe('runCohortFlags (VSAC-driven ACE/ARB)', () => {
const ACE_OID = '2.16.840.1.113883.3.526.2.39';
const SUPPRESS_OID_1 = '2.16.840.1.113883.3.526.2.1256';
const SUPPRESS_OID_2 = '2.16.840.1.113883.3.526.2.1257';

beforeEach(() => {
vi.clearAllMocks();
mockSql.mockResolvedValue([]);
});

it('throws when ACEARB_RXNORM_VALUE_SET_OID rule row is missing', async () => {
// acearbOid lookup returns empty → no rule row → must throw
mockSql.mockResolvedValueOnce([]);
await expect(runCohortFlags()).rejects.toThrow('ACEARB_RXNORM_VALUE_SET_OID missing');
});

it('queries the cohort with the OID from clinical_rule and returns flag counts', async () => {
// Call[0]: acearbOid lookup
mockSql.mockResolvedValueOnce([{ value_text: ACE_OID }]);
// Call[1]: suppressOids lookup (two suppress sets)
mockSql.mockResolvedValueOnce([{ value_text: SUPPRESS_OID_1 }, { value_text: SUPPRESS_OID_2 }]);
// Call[2]: suppress fragment sql`` interpolation (always fires — empty or populated)
// Call[3]: cohort query — one patient on ACE/ARB, no recent BMP
mockSql.mockResolvedValueOnce([]); // fragment
mockSql.mockResolvedValueOnce([
{ patient_id: 1, latest_k: null, latest_gfr: null, on_acearb: true, has_recent_bmp: false },
]);
// remaining setFlag calls → default []
mockSql.mockResolvedValue([]);

const result = await runCohortFlags();
expect(result.cohort).toBe(1);
expect(result.byFlag['NEW_ACEARB_NO_BMP']).toBe(1);
expect(result.byFlag['HYPERKALEMIA']).toBe(0);
expect(result.byFlag['GFR_LOW']).toBe(0);
// Verify acearbOid reached the cohort query (call[3], values start at slice(1))
const cohortCallValues = (mockSql.mock.calls[3] ?? []).slice(1) as unknown[];
expect(cohortCallValues).toContain(ACE_OID);
});

it('passes suppress OIDs to the suppression fragment when present', async () => {
mockSql.mockResolvedValueOnce([{ value_text: ACE_OID }]);
mockSql.mockResolvedValueOnce([{ value_text: SUPPRESS_OID_1 }]);
mockSql.mockResolvedValue([]);

await runCohortFlags();
// Call[2] is the suppress fragment; its values include the suppress array
const fragmentValues = (mockSql.mock.calls[2] ?? []).slice(1) as unknown[];
expect(fragmentValues).toContainEqual([SUPPRESS_OID_1]);
});

it('still runs when suppress OID list is empty (no allergy/intolerance rules)', async () => {
mockSql.mockResolvedValueOnce([{ value_text: ACE_OID }]);
mockSql.mockResolvedValueOnce([]); // empty suppress list
mockSql.mockResolvedValue([]);

// Must not throw — empty suppress list = no suppression fragment, no error
await expect(runCohortFlags()).resolves.toMatchObject({ cohort: 0 });
// Verify acearbOid still reached call[3]
const cohortCallValues = (mockSql.mock.calls[3] ?? []).slice(1) as unknown[];
expect(cohortCallValues).toContain(ACE_OID);
});
});
Loading
Loading