Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
vlad-ignatov committed Mar 12, 2024
1 parent f5b418d commit db8bb4f
Show file tree
Hide file tree
Showing 35 changed files with 15,180 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
PORT=0
HOST="127.0.0.1"
NODE_ENV="test"
JOB_THROTTLE=100
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,7 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store
test-jobs/*


13 changes: 13 additions & 0 deletions .mocharc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = {

extensions: ["ts"],

spec: ["./test/**/*.test.ts",],

timeout: 15000,
checkLeaks: true,
allowUncaught: true,
jobs: 1,
parallel: false,
retries: 0
}
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"cSpell.words": [
"Pfannerstill",
"Rosalina"
]
}
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,34 @@
# bulk-match-provider
Bulk Match Provider


## Matching

Patients can either be matched by identifier (SSN and MRN implemented), in which
case we have a "hard" match with 100% confidence, of by fuzzy matching combination
of parameter. The minimal required set of parameters to match includes `name` and
`birthDate` and provides a confidence of `0.6` (60%). Any additionally matched
parameter will further increase the confidence score.


### Comparing Names
1. We compare flattened versions of [human names](https://hl7.org/fhir/R4/datatypes.html#HumanName)
consisting of `given` and `family` name values concatenated with a space character.
2. Our comparison is case-insensitive
3. We ignore the `use`, `text`, `prefix`, `suffix`, and `period` properties if present.
4. The input resource and the source Patient that we compare with can both have multiple
names. The match is successful only if the re is one (or more) overlapping name
between the two name arrays.

Note that having a matching name is not sufficient match a patient and needs to
be combined with other matched properties.


### Comparing DOB

We compare birth dates with a **day precision**, meaning that we consider two birth
dates equal if they represent the same year month and day, ignoring the time part (if any).
Note that in FHIR the [birthDate](https://hl7.org/fhir/R4/patient-definitions.html#Patient.birthDate)
can also be loosely specified as `YYYY` or `YYYY-MM`. This means that, for example,
we would match all of the following dates as equal: `2020`, `2020-01`, `2020-01-01`, `2020-01-01T10:12:34`

234 changes: 234 additions & 0 deletions app/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { FormEvent, useReducer } from "react"
import PresetSelector from "./PresetSelector"
import type { Preset } from "./presets"
import Collapse from "./Collapse"
import Status from "./Status"
import MatchRequest from "./MatchRequest"
import MatchResults from "./MatchResults"
import { MatchManifest } from ".."


interface State {
error : Error | null
matchRequest: {
loading : boolean
baseUrl : string
onlySingleMatch : boolean
onlyCertainMatches: boolean
count ?: number
_outputFormat ?: string
resources : string//(Partial<fhir4.Patient>)[]
submittedAt ?: number
}
matchResponse: {
statusHeading: string,
text: string
}
statusURL: string
statusResponses: {
statusHeading: string,
text: string
}[]
manifest?: MatchManifest | null
snippet: Preset | null
}

const initialState: State = {
error : null,
snippet : null,
matchRequest : {
loading : false,
baseUrl : "http://127.0.0.1:3456/fhir/",
onlySingleMatch : false,
onlyCertainMatches: false,
count : 0,
submittedAt : 0,
resources : `[]`
},
matchResponse: {
statusHeading: "",
text: ""
},
statusURL : "",
statusResponses: [],
manifest : null

}

function reducer(state: State, payload: Partial<State>): State {
return { ...state, ...payload };
}



export default function App() {

const [state, dispatch] = useReducer(reducer, initialState);

async function sendMatchRequest(e: FormEvent) {

e.preventDefault()

const {
matchRequest: {
baseUrl,
resources,
onlySingleMatch,
onlyCertainMatches,
_outputFormat,
count
}
} = state

const url = new URL("Patient/$bulk-match", baseUrl + "")

const body: fhir4.Parameters = {
resourceType: "Parameters",
parameter: []
}

try {
var patients = JSON.parse(resources + "")
} catch (error) {
return dispatch({ error: error as Error })
}

if (patients) {
patients.forEach((p: any) => body.parameter!.push({ name: "resource", resource: p }))
}

body.parameter!.push({ name: "onlySingleMatch" , valueBoolean: onlySingleMatch })
body.parameter!.push({ name: "onlyCertainMatches", valueBoolean: onlyCertainMatches })

if (count) {
body.parameter!.push({ name: "count", valueInteger: count })
}

if (_outputFormat) {
body.parameter!.push({ name: "_outputFormat", valueString: _outputFormat })
}

dispatch({ error: null, matchRequest: { ...state.matchRequest, loading: true } })

try {
const res = await fetch(url, {
method : "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
accept: "application/fhir+ndjson"
}
})

const json = await res.json()

let txt = []
res.headers.forEach((value, key) => {
txt.push(`${key}: ${value}\n`)
})
txt.push("\n")
txt.push(JSON.stringify(json, null, 4))

dispatch({
matchResponse: {
statusHeading: res.status + " " + res.statusText,
text: txt.join("")
},
matchRequest: {
...state.matchRequest,
loading: false,
submittedAt: Date.now()
},
statusURL: res.headers.get("content-location") || ""
})

} catch (ex) {
dispatch({ error: ex as Error })
}
}

return (
<>
<nav className="navbar sticky-top navbar-expand-lg bg-primary">
<div className="container">
<a className="navbar-brand text-white" href="/">
<i className="bi bi-fire me-1" />
Bulk-Match <small className="opacity-50">Sample App</small>
</a>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav me-auto"></ul>
<PresetSelector value={state.snippet} onChange={s => {
const resources = JSON.stringify(s?.params.resources || [], null, 2)
dispatch({
...initialState,
matchRequest: {
...initialState.matchRequest,
// loading: false,
// baseUrl : initialState.matchRequest.baseUrl,
onlySingleMatch : s?.params.onlySingleMatch ?? initialState.matchRequest.onlySingleMatch,
onlyCertainMatches: s?.params.onlyCertainMatches ?? initialState.matchRequest.onlyCertainMatches,
count : s?.params.count ?? initialState.matchRequest.count,
resources
},
snippet: s,
// error: null,
// manifest: undefined,
// matchResponse: null
})
}} />
</div>
</div>
</nav>
<div className="container my-3">
<Collapse header={ <h5 className="m-0">Bulk-Match Request</h5> } open>
<MatchRequest
state={{
baseUrl : state.matchRequest.baseUrl,
onlyCertainMatches: state.matchRequest.onlyCertainMatches,
onlySingleMatch : state.matchRequest.onlySingleMatch,
count : state.matchRequest.count,
resources : state.matchRequest.resources,
}}
onChange={p => dispatch({ matchRequest: { ...state.matchRequest, ...p }})}
onSubmit={sendMatchRequest}
/>
</Collapse>
{ state.matchResponse.statusHeading &&
<Collapse header={ <h5 className="m-0">Bulk-Match Response</h5> }>
<hr className="my-1" />
{
state.matchRequest.loading ?
"Loading..." :
<>
<div className="row">
<div className="col"><b>{state.matchResponse.statusHeading}</b></div>
</div>
<div className="row mb-4">
<div className="col">
<pre>{state.matchResponse.text}</pre>
</div>
</div>
</>
}
</Collapse>
}
{ state.statusURL && <Status
statusURL={state.statusURL}
key={"status-" + state.matchRequest.submittedAt}
onComplete={ manifest => dispatch({ manifest }) }
/> }
{ state.manifest && <MatchResults manifest={state.manifest} key={"result-" + state.matchRequest.submittedAt} /> }
</div>
</>
)
}
17 changes: 17 additions & 0 deletions app/Collapse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ReactNode, useState } from "react"


export default function Collapse({ children, header, open }: { children?: ReactNode, header: ReactNode, open?: boolean }) {
const [isOpen, setIsOpen] = useState(!!open)
return (
<div>
<div className="d-flex align-items-center mb-1" style={{ cursor: "pointer" }} onClick={() => setIsOpen(!isOpen)}>
<i className={ isOpen ? "bi bi-caret-down-fill me-1" : "bi bi-caret-right-fill me-1" } />
{ header }
</div>
<div style={{ marginLeft: "1.3em" }}>
{ isOpen && children }
</div>
</div>
)
}
Loading

0 comments on commit db8bb4f

Please sign in to comment.