Customizing Paperwork
Paperwork is a key component of every medical practice's intake process. The process varies widely from one practice to another but is a consistent challenge for patients, providers, and health tech developers. Ottehr utilizes features of the FHIR standard and the Oystehr platform to make this process easier, allowing you to achieve a custom intake flow tailored to the needs of your practice while hopefully simplifying the work of developers, front desk employees, and providers.
The Pieces
Overview
The Questionnaire Resource
This documentation goes into detail on FHIR resources. If you do not have any experience with FHIR, that's ok -- you don't need to have experience to customize the paperwork. If you would like to read about FHIR you can check out our documentation about FHIR.
Every intake flow begins with a FHIR Questionnaire (opens in a new tab) resource. It defines the form that is presented to your patients who will then fill out and submit a QuestionnaireResponse (opens in a new tab).
If you explore the Questionnaire spec, you might interpret it to be generic and minimalist by design. This is both good and bad; there's not much it can't be used for, but there's little chance the default properties are going to be sufficient to power those slick features your product manager is asking for. Ottehr fills in these gaps with some nifty extensions that we think will give you the tools to build most of those features -- and the ability to add additional features required.
The React Components
By convention, the Ottehr paperwork system divides your Questionnaire into pages with each top-level object on the "item" field (opens in a new tab) representing a page, and its child elements defining the contents of that page.
The heart of the UI is the PagedQuestionnaire component, which renders the form defined by the item the user is viewing. This component gets wrapped by the PaperworkPage in Ottehr, which passes in the Questionnaire items, initial values, and some handlers that are invoked to save progress at the end of each page and submit the completed QuestionnaireResponse at the end of the flow.
<PagedQuestionnaire
onSubmit={finishPaperworkPage}
pageId={currentPage?.linkId ?? ''}
options={{ controlButtons }}
items={questionnaireItems}
defaultValues={paperworkGroupDefaults}
saveProgress={(data) => {
const pageId = currentPage?.linkId;
if (pageId) {
saveProgress(pageId, data);
}
}}
/>
The PagedQuestionnaire handles the task of translating the custom extensions on an Ottehr Questionnaire into dynamic behaviors, from the simple—like validating required fields, to the more complex—like conditionally rendering inputs, to the downright fancy—like asynchronously verifying a patient's insurance information with the Oystehr eligibility service.
The Harvest Module
As the user fills out the form, a patch-paperwork zambda is invoked as each page is completed, while a submit-paperwork zambda is invoked at the end, changing the paperwork status from 'in-progress', to 'complete'. During this whole process, Oystehr abstains from writing to any FHIR resources outside of the QuestionnaireResponse the patient is populating with answers. How then, do the users answers find their way into the more interesting resources, like Patient, RelatedPerson, Coverage, etc? The answer to that is something we've dubbed "The Harvest Module."
The Harvest Module is the locus of the complex bits of business logic where you translate the input from patients via your intake form(s) into the structured data that powers your practice. If we've done our jobs right, this is where you're likely to concentrate your own dev hours in adapting Ottehr to your use case. Outside of the Questionnaire resource itself, it's the layer you'll most likely want to customize.
But what is it? Under the hood, this module is implemented as an Oystehr subscription Zambda, and gets run each time a QuestionnaireResponse is updated with a status of 'completed' or 'amended'.
But why? Decoupling the generic work of saving user progress from the specialized work of translating any given completed form into other FHIR resources ensures that only the latter task requires new development work each time you change or add on to your paperwork flows. This allows you to go back to the well again and again, defining a Questionnaire resource, feeding it into the frontend React components, and mapping the output QuestionnaireResponse to its own specialized subscription zambda for processing. So you might split a complex intake processes into multiple paperwork flows fed by multiple Questionnaires, or you might find some clinical application for a Questionnaire, such as allowing a physician to fill out a medication order form that triggers a call to some erx integration when it's harvested. It is our ambition that the limitations are imposed by the needs of your business and/or your imagination, and not the Ottehr tooling.
Form Pre-Population
The most common patient complaint about the intake process is needing to re-fill out paperwork with answers that haven't changed since their last visit. To address this pain point, the Ottehr intake system includes a pre-population module that is invoked at the time of appointment creation. You can think of this chunk of code as the harvest module in reverse: rather than populating structured FHIR resources with answers from the patient's QuestionnaireResponse, you're seeding the patient's QR with data culled from existing FHIR resources comprising that patient's record. The default Ottehr implementation (opens in a new tab) is a simple function that takes a few resources and some input from the call to the create-appointment zambda and outputs a list of response items that get incorporated into the QuestionnaireResponse that the patient sees when they open up the paperwork for that visit. Keep in mind that this is another area of code you'll want to customize with your own logic, as it depends on both the Questionnaire resource that feeds your flow, as well as what you do with user input in your harvest module.
Getting Started
Step 1 - Author Your Questionnaire
Step 2 - Provision Your environment
Step 3 - Customize Pre-Population
Update the pre-population function (opens in a new tab) (or just replace with your own implementation) to pre-fill any form values you want to pre-fill from existing resources.
Step 4 - Customize the Harvest Module
Update the harvest subscription zambda (opens in a new tab) (or create a new subscription zambda if you prefer) to pull values from patients' QuestionnaireResponse answers and write them to whatever FHIR resources makes sense for your use case.
Author Your Questionnaire
To create a your own custom paperwork flow, start by creating a Questionnaire in the FHIR DB, then populate the item
prop on that Questionnaire with your questions.
Create Your Questionnaire in the FHIR DB
You're a wizard, so of course you can always POST a new Questionnaire resource via the FHIR API (opens in a new tab), but the easiest way to stick a new resource in the DB is probably going to be the Oystehr console's GUI (opens in a new tab). The required inputs are pretty basic: The FHIR spec requires that the "status" (opens in a new tab) field be provided (go with 'active' here), while Ottehr requires that you populate the components of the "canonical url"—"url" (opens in a new tab) and "version" (opens in a new tab)—because these are the things that will be used to refer to your Questionnaire by other parts of the system, rather than the id
.
Some tips here:
- You don't need to worry too much about the
"url"
. It doesn't need to go anywhere; it's used primarily as a namespace.{some-domain-you-control}/{some-slug-you-make-up-for-your-form}
should do just fine. - Use semantic versioning (opens in a new tab) for
"version"
- Each time you make changes to your Questionnaire that will impact validation, it is strongly recommended that you increment
"version"
. The validation scheme Ottehr applies to a QuestionnaireResponse will be derived from the exact canonical url of its target Questionnaire, so dutifully bumping that version helps you avoid self-inflicted migration headaches when you make changes to your Questionnaire.
Populate the "item" Field on Your Questionnaire
Once your Questionnaire exists, you're ready to fill it up with some questions.
In the Ottehr system, every page of paperwork is represented by an item
on the Questionnaire
with type "group"
. Every question on a page of paperwork is represented by an item
on that page. Thus, Questionnaire.item (opens in a new tab) represents a page of paperwork, and every Questionnaire.item.item (opens in a new tab) represents a question on that page. Therefore, to add a page to the paperwork, you add an item to the Questionnaire (opens in a new tab), and to add a question to that page, you add an item to its Questionnaire.item (opens in a new tab).
At the moment, this is all accomplished in JSON, so find a good JSON editor and refer to our copyable examples and the default resources in /packages/utils/lib/deployed-resources/questionnaires
to guide you.
A common workflow here is to take the JSON from your Questionnaire and drop it in the /packages/utils/lib/deployed-resources/questionnaires
folder, allowing you to edit the JSON directly in your code editor and push the changes to Oystehr by restarting your local server. Change one of the PREVISIT_QUESTIONNAIRE
env vars in your local.json
file to point at your Questionnaire's canonical url and then test it on your local Ottehr app by navigating through the appropriate flow. As you fill out your form, pull up the corresponding QuestionnaireResponse in the Oystehr console (opens in a new tab) to see how the answers are getting stored.
Useful FHIR-native Props
There are a few properties on Questionnaire.item
that bear highlighting:
"required" and "readOnly"
The first are the required (opens in a new tab) and readOnly (opens in a new tab) fields. These are intuitive and implemented by the Ottehr paperwork components: items that have required = true
will have required validation enforced, while items that have readOnly = true
will either be hidden or renderd as disabled inputs depending on the configuration in the disabledDisplay.
"enableWhen"
The next property to know about is enableWhen (opens in a new tab). This property allows you to conditionally enable/disable an item based on the value of some other item(s) in the QuestionnaireResponse and is important not only in its own right, but also because the pattern it establishes for expressing conditional behavior is borrowed by a number of custom Ottehr extensions.
The enableWhen field takes a list of objects with three parts:
question
- this the linkId of some other item in the Questionnaire/QuestionnaireResponseoperator
-=
OR!=
in current implementation; this is the operator used to compare the current form-state value for the item identified byquestion
to the value sepcified in...answer[x]
- the value to check the current form value forquestion
against using the specified operator.
So if we have an item with the following as the sole entry on its enableWhen
field:
"enableWhen": [
{
"question": "current-medications-yes-no",
"operator": "=",
"answerString": "Patient takes medication currently"
}
]
the input for this item will be enabled whenever the value in the QuestionnaireResponse for the item with linkId "current-medications-yes-no"
contains a string answer equal to "Patient takes medication currently"
, otherwise it will be disabled.
You will see this same scheme for defining conditional behavior on the following custom extensions: requireWhen, textWhen, and filterWhen.
"enableBehavior"
Recall that enableWhen
takes a list of condition-defining objects, not just a single one. enableWhen
exists to define the logic that is applied to determine whether the overall condition is met when multiple entries are included. It is only relevant when an item has an enableWhen
property containing mutliple conditions, and must be specified on such items. It has two options:
any
- the condition is met when any condition in the list is satifiedall
- the condition is met when all conditions in the list are satisfied
Ottehr Extensions
Before your raw FHIR Questionnaire resource reaches the Ottehr intake app, it goes through a transformation pipeline that takes any recognized extensions inside your Questionnaire.item
field and turns them into properties that drive custom behaviors when the intake app renders the form to the user. You can check out that code here (opens in a new tab), but it is probably most important at the outset to get familiar with the extensions available and the specific behaviors they support:
Let the Ottehr validator know this item accepts a list of answers |
Any value found in this field will be stripped from the form prior to saving. |
Some config that tells the Ottehr paperwork components how to dynamically fetch up the options the user selects from |
Provide some context or instructions on components asking a user to upload media |
Set the answer item to a value referenced elsewhere in the form when disabled |
Tag an item as pertaining to a certain subcategory of its parent item |
Identifies the type of data this item expects to collect |
Specify how an input element behaves when disabled |
Conditionally filter this item out of the QuestionnaireResponse prior to submitting |
Identifies a special purpose a `group` type item is serving |
Helper text to be included on the item's input element |
Assign a width category for the input element for your item |
Specify the input element you want Ottehr to use for this item |
Let the Ottehr validator know this item is required, but only when the specified condition is met. |
Some additional helper text |
Change the text label on an item when a condition is met |
Let the Ottehr validator know that a minimum age restriction applies |
Provision Your Environment
TL;DR
Replace the resource
value on one or both of the json files in /packages/utils/lib/deployed-resources/questionnaires
with the your custom Questionnaire, or else add a new file in that folder with its own unique url and version on the resource
field, and give it its own envVarName
.
Under the Hood
When a patient creates an appointment in Ottehr, a QuestionnaireResponse resource gets created that references the same canonical url (opens in a new tab)—that is, the same unique url
and version
pair—as some specific Questionnaire resource in the FHIR DB via its questionnaire field (opens in a new tab). Ottehr comes with some default Questionnaires and is wired up to use them, but they are easily overridable.
If you open up the Ottehr project and navigate to /packages/utils/lib/deployed-resources
, you'll find a folder called questionnaires
that's home to a couple of json files. Adding a custom intake flow begins with updating the content of one of these files or else adding a new one. Crack one of these files open and you'll see an object with two top-level fields, envVarName
and resource
:
{
"envVarName": "IN_PERSON_PREVISIT_QUESTIONNAIRE",
"resource": {
"resourceType": "Questionnaire",
"url": "https://ottehr.com/FHIR/Questionnaire/intake-paperwork-inperson",
"version": "1.0.3"
...
}
}
The intake zambdas package contains a script, setup-questionnaires
, that is run each time you deploy or start up your local server, and acts to provision your project's library of Questionnaire resources using the contents of this folder. The script ensures that each Questionnaire is saved to the FHIR DB and then stores the canonical url in your project's secrets (and the .env/local.json file if you're running locally) so it will be available to reference throughout the codebase.
Have a look at this function that looks up the Questionnaire for a given intake flow:
export const getCanonicalUrlForPrevisitQuestionnaire = (
serviceMode: ServiceMode,
secrets: Secrets | null
): CanonicalUrl => {
let secretKey = '';
if (serviceMode === 'in-person') {
secretKey = SecretsKeys.IN_PERSON_PREVISIT_QUESTIONNAIRE;
} else if (appointmentType === 'virtual') {
secretKey = SecretsKeys.VIRTUAL_PREVISIT_QUESTIONNAIRE;
}
const questionnaireCanonURL = getSecret(secretKey, secrets);
const [questionnaireURL, questionnaireVersion] = questionnaireCanonURL.split('|');
if (!questionnaireURL || !questionnaireVersion) {
throw new Error('Questionnaire url secret missing or malformed');
}
return {
canonical: questionnaireCanonURL,
url: questionnaireURL,
version: questionnaireVersion,
};
};
The code finds the environment secret pointing to the canonical url for either of the two default Questionnaire resources, which are for the in-person and virtual service modes, respectively.
The values returned from that function are then used to form the canonical reference used on the questionnaire
field of the QuestionnaireResponse when a patient books an appointment:
const questionnaireResponseResource: QuestionnaireResponse = {
resourceType: 'QuestionnaireResponse',
questionnaire: `${questionnaire.url}|${questionnaire.version}`,
status: 'in-progress',
subject: { reference: patientRef },
encounter: { reference: encUrl },
item, // contains the pre-populated answers for the Patient
};
From there, the exact Questionnaire will be querried up from the reference on the QuestionnaireResponse and will be used to derive the validation logic and other special behaviors the patient sees when completing the form you've cooked up for them.
Questionnaire.item Examples
Simple, Two-page Questionnaire:
[
{
"linkId": "my-first-page",
"type": "group",
"item": [
{
"linkId": "some-label-on-my-first-page",
"type": "display",
"text": "This will be rendered as styled text, with no input to go with"
}
{
"linkId": "my-first-question-on-my-first-page",
"type": "string",
"text": "This is the string that will be displayed to the user",
"required": true
},
{
"linkId": "my-second-question-on-my-first-page",
"type": "string",
"test": "And here we'll ask another question question, but this one is optional to answer",
"required": false
}
]
},
{
"linkId": "my-second-page",
"type": "group",
"item": [
{
"linkId": "some-label-on-my-second-page",
"type": "display",
"text": "This page will show some choice-type questions"
}
{
"linkId": "my-first-choice-type-question",
"type": "choice",
"text": "Which language are you most comforable speaking?",
"required": true,
"answerOption": [
{
"valueString": "English"
},
{
"valueString": "Spanish"
},
{
"valueString": "Esperanto"
},
{
"valueString": "Klingon"
}
]
},
{
"linkId": "my-second-choice-type-question",
"type": "choice",
"test": "This is a canonical reference to a ValueSet resource that defines the options the user will choose from",
"required": false,
"answerValueSet": "http://www.somevalueseturl|1.1.0"
}
]
}
]
One-page Questionnaire with Conditionally Enabled Items:
[
{
"linkId": "my-page-with-conditional-items",
"type": "group",
"item": [
{
"linkId": "some-label",
"type": "display",
"text": "Here are some examples of conditional behavior"
},
{
"linkId": "question-one",
"type": "choice",
"text": "Should I ask more questions?",
"required": true,
"answerOption": [
{
"valueString": "Yes"
},
{
"valueString": "No"
},
{
"valueString": "Maybe"
}
]
},
{
"linkId": "question-two",
"type": "choice",
"text": "Should we ask a third question?",
"required": false,
"enableWhen": [
{
"question": "question-one",
"operator": "!=",
"answerString": "No"
}
],
"answerOption": [
{
"valueString": "Yes"
},
{
"valueString": "No"
}
]
},
{
"linkId": "question-three",
"type": "string",
"text": "This will be shown if either condition is true",
"required": false,
"enableWhen": [
{
"question": "question-one",
"operator": "=",
"answerString": "Yes"
},
{
"question": "question-two",
"operator": "=",
"answerString": "Yes"
}
],
"enableBehavior": "any"
}
]
}
]
One-page Questionnaire with Conditionally Required Items:
[
{
"linkId": "my-page-with-conditional-items",
"type": "group",
"item": [
{
"linkId": "some-label",
"type": "display",
"text": "Here are some examples of conditional behavior"
},
{
"linkId": "question-one",
"type": "choice",
"text": "Should I ask more questions?",
"required": true,
"answerOption": [
{
"valueString": "Yes"
},
{
"valueString": "No"
},
{
"valueString": "Maybe"
}
]
},
{
"linkId": "question-two",
"type": "choice",
"text": "This will start as optional but can become required depending on the answer to question-one",
"required": false,
"answerOption": [
{
"valueString": "Yes"
},
{
"valueString": "No"
}
],
"extension": [
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/require-when",
"extension": [
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/require-when-question",
"valueString": "question-one"
},
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/require-when-operator",
"valueString": "="
},
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/require-when-answer",
"valueString": "Yes"
}
]
},
]
}
]
}
]
Conditional Group Item
Save yourself some boilerplate by wrapping multiple items that should be conditionally displayed in a "group"
:
[
{
"linkId": "my-page-with-conditional-items",
"type": "group",
"item": [
{
"linkId": "some-label",
"type": "display",
"text": "Here are some examples of conditional behavior"
},
{
"linkId": "question-one",
"type": "choice",
"text": "Should I ask a bunch of additional quesitons?",
"required": true,
"answerOption": [
{
"valueString": "Yes"
},
{
"valueString": "No"
}
]
},
{
"linkId": "conditional-question-group",
"type": "group",
"text": "We'll let the parent group own the condition logic here to avoid putting it on each child item",
"enableWhen": [
{
"question": "question-one",
"operator": "=",
"answerString": "Yes"
}
],
"extension": [
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/filter-when",
"extension": [
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/require-when-question",
"valueString": "question-one"
},
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/require-when-operator",
"valueString": "="
},
{
"url": "https://fhir.zapehr.com/r4/StructureDefinitions/require-when-answer",
"valueString": "No"
}
]
},
],
"item": [
{
"linkId": "some-label",
"type": "display",
"text": "I only get displayed if my parent group is enabled"
},
{
"linkId": "condition-question-one",
"type": "text",
"text": "I'm only here if my parent is enabled"
},
{
"linkId": "condition-question-two",
"type": "choice",
"text": "What's your favorite color?",
"answerOption": [
{
"valueString": "Pink"
},
{
"valueString": "Brown"
},
{
"valueString": "Green"
}
]
}
]
}
]
}
]