From 65617e92bedb61ab539324325500924cbcd2f630 Mon Sep 17 00:00:00 2001 From: Daniel Abrams Date: Fri, 12 Jun 2026 16:09:20 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20practice-managed=20custom=20forms=20?= =?UTF-8?q?=E2=80=94=20builder,=20scored=20renderer,=20send=20to=20patient?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Practices can author their own form questionnaires and send them to patients, independent of the canonical intake paperwork. Authoring (EHR admin): - Questionnaires admin tab: list/create/edit/delete practice-managed FHIR Questionnaires (tagged practice-managed), with import-JSON and soft delete (retired status; existing responses still render). The canonical url/name are preserved on edit so renaming a live form never orphans prior responses. - Form builder: paged items, answer options, conditional display (enableWhen), calculated expressions on hidden items for scoring, live preview + test dialog. - admin-{list,get,create,update,delete}-questionnaire zambdas (structured APIErrors for user-actionable failures; paginated catalog listing). Patient-facing: - send-patient-form zambda texts the patient a link (visit-scoped /forms/:appointmentId/:questionnaireId or patient-scoped /forms/patient/:patientId/:questionnaireId). - StandaloneFormPage (intake app) renders and submits the form outside any booking flow: required-field validation, per-page answers scoped correctly, in-progress responses resume, save/submit failures surface to the patient, answers to enableWhen-disabled items are pruned, and numeric scoring artifacts (option prefixes, live totals) never display to patients. Computed expression values save to the QuestionnaireResponse; finalize files a PDF into the patient's Paperwork folder and can spawn follow-up tasks (failures there are reported to Sentry, not swallowed). - get/save/finalize practice-managed-response zambdas enforce caller→patient authorization (EHR users pass implicitly); writes authorize against the QR's own subject. EHR visit details: - "Send Form" action in the top action row opens a filterable form picker (with distinct error vs empty states). - Completed forms render on the visit page and in the response viewer. Attaching forms to the intake paperwork itself is intentionally out of scope here — that lands separately as paperwork flows. Co-Authored-By: Claude Opus 4.8 --- apps/ehr/src/App.tsx | 3 + apps/ehr/src/api/api.ts | 53 ++ .../src/components/PatientEncountersGrid.tsx | 15 + .../src/components/dialogs/SendFormDialog.tsx | 173 ++++ .../visits/in-person/hooks/useTasks.ts | 36 + .../questionnaires/AnswerOptionEditor.tsx | 52 ++ .../questionnaires/QuestionnaireAdminPage.tsx | 412 +++++++++ .../questionnaires/QuestionnaireBuilder.tsx | 334 +++++++ .../QuestionnaireBuilderPage.tsx | 118 +++ .../QuestionnaireItemEditor.tsx | 355 +++++++ .../questionnaires/QuestionnairePreview.tsx | 569 ++++++++++++ .../QuestionnaireTestDialog.tsx | 187 ++++ .../questionnaires/questionnaire.reducer.ts | 126 +++ .../questionnaires/questionnaire.types.ts | 153 +++ apps/ehr/src/pages/AddPatient.tsx | 20 + apps/ehr/src/pages/AdminPage.tsx | 11 + .../pages/PatientDocumentsExplorerPage.tsx | 15 +- apps/ehr/src/pages/VisitDetailsPage.tsx | 71 ++ apps/intake/src/App.tsx | 36 +- .../features/paperwork/PagedQuestionnaire.tsx | 6 +- apps/intake/src/pages/PaperworkPage.tsx | 49 +- .../src/pages/PracticeManagedPaperwork.tsx | 234 +++++ apps/intake/src/pages/StandaloneFormPage.tsx | 268 ++++++ config/oystehr-core/zambdas.json | 65 +- package-lock.json | 4 + .../components/QuestionnaireFormFields.tsx | 872 ++++++++++++++++++ .../ui-components/lib/components/index.ts | 1 + packages/ui-components/package.json | 4 + packages/utils/lib/fhir/constants.ts | 19 + packages/utils/lib/fhir/questionnaires.ts | 9 +- packages/utils/lib/helpers/index.ts | 1 + .../utils/lib/helpers/paperwork/paperwork.ts | 3 +- packages/utils/lib/helpers/slugify.ts | 16 + packages/utils/lib/ottehr-config/index.ts | 1 + .../intake-paperwork-lite/index.ts | 206 +++++ .../prebook-create-appointment.types.ts | 8 +- packages/utils/lib/types/common.ts | 12 + packages/utils/lib/types/data/tasks/types.ts | 1 + .../ehr/admin-create-questionnaire/index.ts | 27 + .../ehr/admin-delete-questionnaire/index.ts | 29 + .../src/ehr/admin-get-questionnaire/index.ts | 25 + .../ehr/admin-list-questionnaires/index.ts | 77 ++ .../src/ehr/admin-questionnaires/helpers.ts | 10 + .../ehr/admin-update-questionnaire/index.ts | 28 + .../src/ehr/send-patient-form/index.ts | 65 ++ .../validateRequestParameters.ts | 33 + .../appointment/create-appointment/index.ts | 21 + .../validateRequestParameters.ts | 33 +- .../src/patient/appointment/helpers.ts | 19 +- .../index.ts | 387 ++++++++ .../index.ts | 294 ++++++ .../save-practice-managed-response/index.ts | 122 +++ packages/zambdas/src/shared/helpers.ts | 22 +- scripts/reprocess-vanderbilt-qr.ts | 211 +++++ scripts/update-vanderbilt-questionnaire.ts | 110 +++ 55 files changed, 5997 insertions(+), 34 deletions(-) create mode 100644 apps/ehr/src/components/dialogs/SendFormDialog.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/AnswerOptionEditor.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireAdminPage.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilder.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilderPage.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireItemEditor.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnairePreview.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireTestDialog.tsx create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.reducer.ts create mode 100644 apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.types.ts create mode 100644 apps/intake/src/pages/PracticeManagedPaperwork.tsx create mode 100644 apps/intake/src/pages/StandaloneFormPage.tsx create mode 100644 packages/ui-components/lib/components/QuestionnaireFormFields.tsx create mode 100644 packages/utils/lib/helpers/slugify.ts create mode 100644 packages/utils/lib/ottehr-config/intake-paperwork-lite/index.ts create mode 100644 packages/zambdas/src/ehr/admin-create-questionnaire/index.ts create mode 100644 packages/zambdas/src/ehr/admin-delete-questionnaire/index.ts create mode 100644 packages/zambdas/src/ehr/admin-get-questionnaire/index.ts create mode 100644 packages/zambdas/src/ehr/admin-list-questionnaires/index.ts create mode 100644 packages/zambdas/src/ehr/admin-questionnaires/helpers.ts create mode 100644 packages/zambdas/src/ehr/admin-update-questionnaire/index.ts create mode 100644 packages/zambdas/src/ehr/send-patient-form/index.ts create mode 100644 packages/zambdas/src/ehr/send-patient-form/validateRequestParameters.ts create mode 100644 packages/zambdas/src/patient/paperwork/finalize-practice-managed-response/index.ts create mode 100644 packages/zambdas/src/patient/paperwork/get-practice-managed-questionnaires/index.ts create mode 100644 packages/zambdas/src/patient/paperwork/save-practice-managed-response/index.ts create mode 100644 scripts/reprocess-vanderbilt-qr.ts create mode 100644 scripts/update-vanderbilt-questionnaire.ts diff --git a/apps/ehr/src/App.tsx b/apps/ehr/src/App.tsx index 68000af893..7bebe81bed 100644 --- a/apps/ehr/src/App.tsx +++ b/apps/ehr/src/App.tsx @@ -34,6 +34,7 @@ import InHouseMedicationQuickPickDetailPage from './features/visits/telemed/comp import AdminAddLabSet from './features/visits/telemed/components/admin/lab-sets/AdminAddLabSet'; import AdminLabSetDetails from './features/visits/telemed/components/admin/lab-sets/AdminLabSetDetails'; import ProcedureQuickPickDetailPage from './features/visits/telemed/components/admin/ProcedureQuickPickDetailPage'; +import { QuestionnaireBuilderPage } from './features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilderPage'; import RadiologyQuickPickDetailPage from './features/visits/telemed/components/admin/RadiologyQuickPickDetailPage'; import { useApiClients } from './hooks/useAppClients'; import useEvolveUser from './hooks/useEvolveUser'; @@ -278,6 +279,8 @@ function App(): ReactElement { } /> } /> } /> + } /> + } /> {FEATURE_FLAGS.LEGACY_DATA_ENABLED && } />} } /> } /> diff --git a/apps/ehr/src/api/api.ts b/apps/ehr/src/api/api.ts index 0f6ba2d00e..90a3035f72 100644 --- a/apps/ehr/src/api/api.ts +++ b/apps/ehr/src/api/api.ts @@ -313,6 +313,11 @@ const ADMIN_CREATE_TEMPLATE_ZAMBDA_ID = 'admin-create-template'; const ADMIN_RENAME_TEMPLATE_ZAMBDA_ID = 'admin-rename-template'; const ADMIN_DELETE_TEMPLATE_ZAMBDA_ID = 'admin-delete-template'; const ADMIN_GET_TEMPLATE_DETAIL_ZAMBDA_ID = 'admin-get-template-detail'; +const ADMIN_LIST_QUESTIONNAIRES_ZAMBDA_ID = 'admin-list-questionnaires'; +const ADMIN_GET_QUESTIONNAIRE_ZAMBDA_ID = 'admin-get-questionnaire'; +const ADMIN_CREATE_QUESTIONNAIRE_ZAMBDA_ID = 'admin-create-questionnaire'; +const ADMIN_UPDATE_QUESTIONNAIRE_ZAMBDA_ID = 'admin-update-questionnaire'; +const ADMIN_DELETE_QUESTIONNAIRE_ZAMBDA_ID = 'admin-delete-questionnaire'; const ADMIN_LIST_IN_HOUSE_LABS_ZAMBDA_ID = 'admin-list-in-house-labs'; const ADMIN_ADD_IN_HOUSE_LAB_ZAMBDA_ID = 'admin-add-in-house-lab'; const ADMIN_GET_IN_HOUSE_LAB_CONFIG_ZAMBDA_ID = 'admin-get-in-house-lab-config'; @@ -2844,6 +2849,28 @@ export const migrateExamData = async ( } }; +// ── Practice-Managed Questionnaires ── + +export const listPracticeManagedQuestionnaires = async ( + oystehr: Oystehr +): Promise<{ + questionnaires: any[]; + systemQuestionnaires: { id: string; url: string; title: string }[]; +}> => { + const response = await oystehr.zambda.execute({ id: ADMIN_LIST_QUESTIONNAIRES_ZAMBDA_ID }); + return chooseJson(response); +}; + +export const getPracticeManagedQuestionnaire = async ( + oystehr: Oystehr, + questionnaireId: string +): Promise<{ questionnaire: any }> => { + const response = await oystehr.zambda.execute({ + id: ADMIN_GET_QUESTIONNAIRE_ZAMBDA_ID, + questionnaireId, + }); + return chooseJson(response); +}; // ── Service Categories (FHIR-backed bookable appointment categories) ── export interface ServiceCategoryRuntimeConfig { @@ -2885,6 +2912,32 @@ export const createServiceCategory = async ( return chooseJson(response); }; +export const createPracticeManagedQuestionnaire = async ( + oystehr: Oystehr, + questionnaire: Record +): Promise<{ questionnaire: any }> => { + const response = await oystehr.zambda.execute({ id: ADMIN_CREATE_QUESTIONNAIRE_ZAMBDA_ID, questionnaire }); + return chooseJson(response); +}; + +export const updatePracticeManagedQuestionnaire = async ( + oystehr: Oystehr, + questionnaire: Record +): Promise<{ questionnaire: any }> => { + const response = await oystehr.zambda.execute({ id: ADMIN_UPDATE_QUESTIONNAIRE_ZAMBDA_ID, questionnaire }); + return chooseJson(response); +}; + +export const deletePracticeManagedQuestionnaire = async ( + oystehr: Oystehr, + questionnaireId: string +): Promise<{ message: string }> => { + const response = await oystehr.zambda.execute({ + id: ADMIN_DELETE_QUESTIONNAIRE_ZAMBDA_ID, + questionnaireId, + }); + return chooseJson(response); +}; export const updateServiceCategory = async ( oystehr: Oystehr, serviceCategory: ServiceCategory diff --git a/apps/ehr/src/components/PatientEncountersGrid.tsx b/apps/ehr/src/components/PatientEncountersGrid.tsx index 8a84053ed6..42b15978af 100644 --- a/apps/ehr/src/components/PatientEncountersGrid.tsx +++ b/apps/ehr/src/components/PatientEncountersGrid.tsx @@ -1,6 +1,7 @@ import AddIcon from '@mui/icons-material/Add'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import SendIcon from '@mui/icons-material/Send'; import SubdirectoryArrowRightIcon from '@mui/icons-material/SubdirectoryArrowRight'; import { Box, @@ -51,6 +52,7 @@ import { import { FEATURE_FLAGS } from '../constants/feature-flags'; import { formatISOStringToDateAndTime } from '../helpers/formatDateTime'; import { useApiClients } from '../hooks/useAppClients'; +import { SendFormDialog } from './dialogs/SendFormDialog'; import { RoundedButton } from './RoundedButton'; type PatientEncountersGridProps = { @@ -146,6 +148,7 @@ export const PatientEncountersGrid: FC = (props) => const [serviceCategory, setServiceCategory] = useState('all'); const [hideCancelled, setHideCancelled] = useState(false); const [hideNoShow, setHideNoShow] = useState(false); + const [sendFormOpen, setSendFormOpen] = useState(false); const [sortField, setSortField] = useState('dateTime'); const [sortDirection, setSortDirection] = useState('desc'); const [page, setPage] = useState(0); @@ -354,8 +357,20 @@ export const PatientEncountersGrid: FC = (props) => > Follow-up + } + onClick={() => setSendFormOpen(true)} + disabled={!patient?.id} + > + Send Form + + {patient?.id && ( + setSendFormOpen(false)} patientId={patient.id} /> + )} + setType(e.target.value)}> All diff --git a/apps/ehr/src/components/dialogs/SendFormDialog.tsx b/apps/ehr/src/components/dialogs/SendFormDialog.tsx new file mode 100644 index 0000000000..f207445064 --- /dev/null +++ b/apps/ehr/src/components/dialogs/SendFormDialog.tsx @@ -0,0 +1,173 @@ +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import SendIcon from '@mui/icons-material/Send'; +import { + Autocomplete, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { enqueueSnackbar } from 'notistack'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { listPracticeManagedQuestionnaires } from 'src/api/api'; +import { useApiClients } from 'src/hooks/useAppClients'; + +interface SendFormDialogProps { + open: boolean; + onClose: () => void; + /** Appointment-scoped send: link is tied to an encounter. */ + appointmentId?: string; + /** Patient-scoped send (no encounter): link is tied to the patient record only. */ + patientId?: string; +} + +const SEND_FORM_ZAMBDA = 'send-patient-form'; +const PATIENT_APP_URL = import.meta.env.VITE_APP_PATIENT_APP_URL || ''; + +export const SendFormDialog: FC = ({ open, onClose, appointmentId, patientId }) => { + const { oystehrZambda } = useApiClients(); + const [selectedId, setSelectedId] = useState(''); + const [sending, setSending] = useState(false); + const [questionnaires, setQuestionnaires] = useState<{ id: string; title: string }[]>([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(false); + + useEffect(() => { + if (!open || !oystehrZambda) return; + // Guard against out-of-order results from rapid close/reopen. + let cancelled = false; + setLoading(true); + setLoadError(false); + listPracticeManagedQuestionnaires(oystehrZambda) + .then((result) => { + if (cancelled) return; + setQuestionnaires( + (result.questionnaires || []) + .filter((q: any) => q.status === 'active') + .map((q: any) => ({ id: q.id, title: q.title || q.name || 'Untitled' })) + .sort((a: { title: string }, b: { title: string }) => a.title.localeCompare(b.title)) + ); + }) + .catch((err) => { + if (cancelled) return; + // A failed fetch must not masquerade as "no questionnaires available". + console.error('Failed to load questionnaires:', err); + setLoadError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, oystehrZambda]); + + const formUrl = useMemo(() => { + if (!selectedId || !PATIENT_APP_URL) return ''; + if (appointmentId) return `${PATIENT_APP_URL}/forms/${appointmentId}/${selectedId}`; + if (patientId) return `${PATIENT_APP_URL}/forms/patient/${patientId}/${selectedId}`; + return ''; + }, [selectedId, appointmentId, patientId]); + + const handleCopyUrl = useCallback(() => { + if (!formUrl) return; + void navigator.clipboard.writeText(formUrl).then(() => { + enqueueSnackbar('Form URL copied to clipboard', { variant: 'success' }); + }); + }, [formUrl]); + + const handleSend = useCallback(async () => { + if (!oystehrZambda || !selectedId) return; + + const selected = questionnaires.find((q) => q.id === selectedId); + if (!selected) return; + + setSending(true); + try { + await oystehrZambda.zambda.execute({ + id: SEND_FORM_ZAMBDA, + ...(appointmentId ? { appointmentId } : { patientId }), + questionnaireId: selectedId, + questionnaireName: selected.title, + }); + enqueueSnackbar('Form link sent to patient', { variant: 'success' }); + onClose(); + setSelectedId(''); + } catch (err) { + console.error('Failed to send form:', err); + enqueueSnackbar('Failed to send form link', { variant: 'error' }); + } finally { + setSending(false); + } + }, [oystehrZambda, selectedId, appointmentId, patientId, questionnaires, onClose]); + + return ( + + Send Form to Patient + + + Select a form to send to the patient via SMS, or copy the link to share directly. + + {loading ? ( + + ) : loadError ? ( + Could not load forms. Close and reopen to retry. + ) : questionnaires.length === 0 ? ( + No practice-managed questionnaires available. + ) : ( + <> + opt.title} + isOptionEqualToValue={(opt, val) => opt.id === val.id} + value={questionnaires.find((q) => q.id === selectedId) || null} + onChange={(_, value) => setSelectedId(value?.id || '')} + disabled={sending} + autoHighlight + renderInput={(params) => } + /> + {formUrl && ( + + + + + + + + + )} + + )} + + + + + + + ); +}; diff --git a/apps/ehr/src/features/visits/in-person/hooks/useTasks.ts b/apps/ehr/src/features/visits/in-person/hooks/useTasks.ts index 4e825ba61d..2cde181275 100644 --- a/apps/ehr/src/features/visits/in-person/hooks/useTasks.ts +++ b/apps/ehr/src/features/visits/in-person/hooks/useTasks.ts @@ -431,6 +431,42 @@ function fhirTaskToTask(task: FhirTask, encountersMap?: Map): const patientReference = getInputReference(MANUAL_TASK.input.patient, task); const appointmentId = getInputString(MANUAL_TASK.input.appointmentId, task); const orderId = getInputString(MANUAL_TASK.input.orderId, task); + const documentReferenceId = getInputString(MANUAL_TASK.input.documentReferenceId, task); + // Follow-up tasks emitted by completed practice-managed forms carry a + // document-reference-id; use it as a signal to route to the patient docs + // page with the Paperwork folder preselected, rather than the default + // manual-task behaviour. + const patientIdFromRef = patientReference?.reference?.split('/')?.[1]; + if (category === MANUAL_TASK.category.patientFollowUp && documentReferenceId && patientIdFromRef) { + title = + getInputString(MANUAL_TASK.input.title, task) || + `Patient follow-up for ${patientReference?.display?.replaceAll(',', '') ?? ''}`; + subtitle = `Form completed / ${task.location?.display ?? ''}`; + details = ''; + completable = true; + action = { + name: GO_TO_TASK, + link: `/patient/${patientIdFromRef}/docs?folder=Paperwork`, + }; + return { + id: task.id ?? '', + category, + createdDate: task.authoredOn ?? '', + title, + subtitle, + details, + status: task.status, + action, + assignee: task.owner + ? { + id: task.owner?.reference?.split('/')?.[1] ?? '', + name: task.owner?.display ?? '', + date: getExtension(task.owner, TASK_ASSIGNED_DATE_TIME_EXTENSION_URL)?.valueDateTime ?? '', + } + : undefined, + completable, + }; + } title = getInputString(MANUAL_TASK.input.title, task) + (patientReference ? ' for ' + patientReference.display?.replaceAll(',', '') : ''); diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/AnswerOptionEditor.tsx b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/AnswerOptionEditor.tsx new file mode 100644 index 0000000000..fa36d4f782 --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/AnswerOptionEditor.tsx @@ -0,0 +1,52 @@ +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { Box, IconButton, TextField, Typography } from '@mui/material'; +import { FC } from 'react'; +import { ItemAction } from './questionnaire.reducer'; +import { QuestionnaireAnswerOption } from './questionnaire.types'; + +interface AnswerOptionEditorProps { + itemKey: string; + options: QuestionnaireAnswerOption[]; + dispatch: React.Dispatch; +} + +export const AnswerOptionEditor: FC = ({ itemKey, options, dispatch }) => { + return ( + + + Answer Options + + {options.map((option, index) => { + const currentLabel = option.valueCoding?.display ?? option.valueString ?? ''; + const handleChange = (newLabel: string): void => { + const next = option.valueCoding + ? { ...option, valueCoding: { ...option.valueCoding, display: newLabel } } + : { ...option, valueString: newLabel }; + dispatch({ type: 'UPDATE_ANSWER_OPTION', key: itemKey, index, option: next }); + }; + return ( + + handleChange(e.target.value)} + placeholder={`Option ${index + 1}`} + fullWidth + /> + dispatch({ type: 'REMOVE_ANSWER_OPTION', key: itemKey, index })} + > + + + + ); + })} + dispatch({ type: 'ADD_ANSWER_OPTION', key: itemKey })}> + + + + ); +}; diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireAdminPage.tsx b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireAdminPage.tsx new file mode 100644 index 0000000000..831dcde5b6 --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireAdminPage.tsx @@ -0,0 +1,412 @@ +import AddIcon from '@mui/icons-material/Add'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import RestoreFromTrashIcon from '@mui/icons-material/RestoreFromTrash'; +import UploadIcon from '@mui/icons-material/Upload'; +import { + Box, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, + Paper, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { enqueueSnackbar } from 'notistack'; +import { FC, useCallback, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + createPracticeManagedQuestionnaire, + deletePracticeManagedQuestionnaire, + getPracticeManagedQuestionnaire, + listPracticeManagedQuestionnaires, + updatePracticeManagedQuestionnaire, +} from 'src/api/api'; +import { RoundedButton } from 'src/components/RoundedButton'; +import { ButtonRounded } from 'src/features/visits/in-person/components/RoundedButton'; +import { useApiClients } from 'src/hooks/useAppClients'; +import { FhirQuestionnaire, fromFhirResource } from './questionnaire.types'; + +const QUERY_KEY = ['practice-managed-questionnaires']; + +function countItems(items: FhirQuestionnaire['item']): number { + let count = 0; + for (const item of items || []) { + count++; + if (item.item) count += countItems(item.item); + } + return count; +} + +// A questionnaire is "deleted" when its FHIR status is retired (set by the Delete action). +// Deleted forms are soft-deleted so existing patient responses stay viewable; they are hidden +// from the list unless "Show deleted" is on, where they can be restored. +const isDeleted = (q: FhirQuestionnaire): boolean => q.status === 'retired'; + +const FileUploadArea: FC<{ onFileLoaded: (content: string) => void; disabled?: boolean }> = ({ + onFileLoaded, + disabled, +}) => { + const fileInputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [fileName, setFileName] = useState(null); + + const readFile = useCallback( + (file: File) => { + if (!file.name.endsWith('.json')) { + return; + } + setFileName(file.name); + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + if (content) onFileLoaded(content); + }; + reader.readAsText(file); + }, + [onFileLoaded] + ); + + return ( + { + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) readFile(file); + }} + onClick={() => !disabled && fileInputRef.current?.click()} + sx={{ + border: '2px dashed', + borderColor: dragOver ? 'primary.main' : '#E0E0E0', + borderRadius: '8px', + p: 3, + textAlign: 'center', + cursor: disabled ? 'default' : 'pointer', + bgcolor: dragOver ? 'action.hover' : 'transparent', + transition: 'all 0.2s', + '&:hover': disabled ? {} : { borderColor: 'primary.light', bgcolor: 'action.hover' }, + }} + > + { + const file = e.target.files?.[0]; + if (file) readFile(file); + e.target.value = ''; + }} + /> + + + {fileName ? `Loaded: ${fileName}` : 'Drop a .json file here or click to browse'} + + + ); +}; + +export const QuestionnaireAdminPage: FC = () => { + const { oystehrZambda } = useApiClients(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importJson, setImportJson] = useState(''); + const [importError, setImportError] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [showDeleted, setShowDeleted] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: QUERY_KEY, + queryFn: async () => { + if (!oystehrZambda) return { questionnaires: [] as FhirQuestionnaire[] }; + const result = await listPracticeManagedQuestionnaires(oystehrZambda); + return { + questionnaires: (result.questionnaires || []).map((r: any) => fromFhirResource(r)), + }; + }, + enabled: !!oystehrZambda, + }); + const questionnaires = (data?.questionnaires || []) + .slice() + .sort((a, b) => (a.title || a.name || '').localeCompare(b.title || b.name || '')); + const deletedCount = questionnaires.filter(isDeleted).length; + const visibleQuestionnaires = showDeleted ? questionnaires : questionnaires.filter((q) => !isDeleted(q)); + + const handleCreate = useCallback(() => { + navigate('/admin/questionnaires/new'); + }, [navigate]); + + const handleEdit = useCallback( + (q: FhirQuestionnaire) => { + navigate(`/admin/questionnaires/${q.id}`); + }, + [navigate] + ); + + const handleDelete = useCallback( + async (id: string) => { + if (!oystehrZambda || !window.confirm('Are you sure you want to delete this questionnaire?')) return; + try { + await deletePracticeManagedQuestionnaire(oystehrZambda, id); + void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + enqueueSnackbar('Questionnaire deleted', { variant: 'success' }); + } catch (err) { + console.error('Failed to delete questionnaire:', err); + enqueueSnackbar('Failed to delete questionnaire', { variant: 'error' }); + } + }, + [oystehrZambda, queryClient] + ); + + const handleRestore = useCallback( + async (id: string) => { + if (!oystehrZambda) return; + try { + // Fetch the full resource (the list view is a lossy projection) and flip status back to active. + const { questionnaire } = await getPracticeManagedQuestionnaire(oystehrZambda, id); + await updatePracticeManagedQuestionnaire(oystehrZambda, { ...questionnaire, status: 'active' }); + void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + enqueueSnackbar('Questionnaire restored', { variant: 'success' }); + } catch (err) { + console.error('Failed to restore questionnaire:', err); + enqueueSnackbar('Failed to restore questionnaire', { variant: 'error' }); + } + }, + [oystehrZambda, queryClient] + ); + + const handleImport = useCallback(async () => { + if (!oystehrZambda) return; + setImportError(null); + try { + const parsed = JSON.parse(importJson); + if (parsed.resourceType !== 'Questionnaire') { + setImportError('JSON must be a FHIR Questionnaire resource (resourceType: "Questionnaire")'); + return; + } + if (!parsed.title && !parsed.name) { + setImportError('Questionnaire must have a title or name'); + return; + } + setIsImporting(true); + await createPracticeManagedQuestionnaire(oystehrZambda, parsed); + void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + enqueueSnackbar(`Imported "${parsed.title || parsed.name}"`, { variant: 'success' }); + setImportDialogOpen(false); + setImportJson(''); + } catch (err) { + if (err instanceof SyntaxError) { + setImportError('Invalid JSON: ' + err.message); + } else { + console.error('Import failed:', err); + setImportError('Failed to save questionnaire to server'); + } + } finally { + setIsImporting(false); + } + }, [oystehrZambda, importJson, queryClient]); + + return ( + + + + Questionnaires + + + {deletedCount > 0 && ( + setShowDeleted(e.target.checked)} />} + label={`Show deleted (${deletedCount})`} + sx={{ mr: 1, '& .MuiFormControlLabel-label': { fontSize: 14, color: 'text.secondary' } }} + /> + )} + } + onClick={() => setImportDialogOpen(true)} + > + Import JSON + + } onClick={handleCreate}> + Create Questionnaire + + + + + {isLoading ? ( + + + + ) : visibleQuestionnaires.length === 0 ? ( + + {questionnaires.length === 0 + ? 'No questionnaires yet. Click "Create Questionnaire" to build one.' + : 'No active questionnaires. Turn on "Show deleted" to see deleted ones.'} + + ) : ( + + + + + Title + + Items + + + Actions + + + + + {visibleQuestionnaires.map((q) => { + const deleted = isDeleted(q); + return ( + !deleted && handleEdit(q)} + > + + + {q.title || '(untitled)'} + {deleted && ( + + )} + + + {countItems(q.item)} + e.stopPropagation()}> + {deleted ? ( + + q.id && handleRestore(q.id)}> + + + + ) : ( + <> + + handleEdit(q)}> + + + + + q.id && handleDelete(q.id)}> + + + + + )} + + + ); + })} + +
+
+ )} + + {/* Import JSON Dialog */} + !isImporting && setImportDialogOpen(false)} + maxWidth="md" + fullWidth + > + Import FHIR Questionnaire + + + Upload a JSON file or paste a FHIR R4 Questionnaire resource. The questionnaire will be saved as-is, + preserving all extensions, coded answer options, and scoring. You can import standardized instruments like + GAD-7, PHQ-9, or any valid FHIR Questionnaire. + + { + setImportJson(content); + setImportError(null); + }} + disabled={isImporting} + /> + { + setImportJson(e.target.value); + setImportError(null); + }} + multiline + minRows={10} + maxRows={18} + fullWidth + placeholder='{"resourceType": "Questionnaire", ...}' + error={!!importError} + helperText={importError} + sx={{ mt: 2, '& .MuiInputBase-root': { fontFamily: 'monospace', fontSize: 12 } }} + disabled={isImporting} + /> + + + { + setImportDialogOpen(false); + setImportJson(''); + setImportError(null); + }} + disabled={isImporting} + > + Cancel + + } + loadingPosition="start" + > + Import + + + +
+ ); +}; + +export default QuestionnaireAdminPage; diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilder.tsx b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilder.tsx new file mode 100644 index 0000000000..3506473aff --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilder.tsx @@ -0,0 +1,334 @@ +import AddIcon from '@mui/icons-material/Add'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import { Box, Button, Grid, Paper, TextField, Typography } from '@mui/material'; +import { enqueueSnackbar } from 'notistack'; +import { FC, useCallback, useMemo, useReducer, useState } from 'react'; +import { RoundedButton } from 'src/components/RoundedButton'; +import { slugify } from 'utils'; +import { itemsReducer } from './questionnaire.reducer'; +import { FhirQuestionnaire, QuestionnaireItem, QuestionnaireStatus } from './questionnaire.types'; +import { QuestionnaireItemEditor } from './QuestionnaireItemEditor'; +import { QuestionnairePreview } from './QuestionnairePreview'; +import { QuestionnaireTestDialog } from './QuestionnaireTestDialog'; + +interface QuestionnaireBuilderProps { + initial?: FhirQuestionnaire; + onSave: (questionnaire: FhirQuestionnaire) => Promise; + onCancel: () => void; +} + +const EXTENSION_BASE = 'https://fhir.zapehr.com/r4/StructureDefinitions'; + +// Internal fields that get stripped or converted to FHIR extensions +const INTERNAL_FIELDS = new Set(['_key', 'dataType', 'inputWidth']); +const OTTEHR_EXTENSION_URLS = new Set([`${EXTENSION_BASE}/data-type`, `${EXTENSION_BASE}/input-width`]); +// Boolean flags where false is the FHIR default and can be omitted. Other false values are +// meaningful data (e.g. enableWhen.answerBoolean: false) and must round-trip. +const OMITTABLE_FALSE_FLAGS = new Set(['required', 'repeats', 'readOnly', 'initialSelected']); + +function toFhirJson(obj: unknown): unknown { + if (Array.isArray(obj)) return obj.map(toFhirJson); + if (obj && typeof obj === 'object') { + const record = obj as Record; + + // Build FHIR extension[] from Ottehr fields, merged with any existing extensions + const ottehrExtensions: { url: string; valueString: string }[] = []; + if (record.dataType && typeof record.dataType === 'string') { + ottehrExtensions.push({ url: `${EXTENSION_BASE}/data-type`, valueString: record.dataType }); + } + if (record.inputWidth && typeof record.inputWidth === 'string') { + ottehrExtensions.push({ url: `${EXTENSION_BASE}/input-width`, valueString: record.inputWidth }); + } + + // Preserve non-Ottehr extensions from imported questionnaires + const existingExtensions = Array.isArray(record.extension) + ? (record.extension as { url: string }[]).filter((e) => !OTTEHR_EXTENSION_URLS.has(e.url)) + : []; + const mergedExtensions = [...existingExtensions, ...ottehrExtensions]; + + const entries = Object.entries(record) + .filter(([k]) => !INTERNAL_FIELDS.has(k)) + .filter(([k]) => k !== 'extension') // handled separately via merge + .filter(([, v]) => v !== undefined && v !== '') + .filter(([k, v]) => !(v === false && OMITTABLE_FALSE_FLAGS.has(k))) + .filter(([, v]) => !(Array.isArray(v) && v.length === 0)) + .map(([k, v]) => [k, toFhirJson(v)]); + + if (mergedExtensions.length > 0) { + entries.push(['extension', mergedExtensions]); + } + + return Object.fromEntries(entries); + } + return obj; +} + +// All slug derivation (linkIds and the canonical url) caps at 60 chars. +const toSlug = (text: string): string => slugify(text, { maxLength: 60 }); + +function ensureUniqueLinkIds( + items: QuestionnaireItem[], + seen = new Set(), + pageSlug?: string +): QuestionnaireItem[] { + return items.map((item) => { + let base = item.linkId; + if (!base && item.text) { + const textSlug = toSlug(item.text); + if (item.type === 'group') { + base = textSlug ? `${textSlug}-page` : ''; + } else if (item.type === 'display') { + base = pageSlug ? `${pageSlug}-${textSlug}-text` : `${textSlug}-text`; + } else { + base = pageSlug ? `${pageSlug}-${textSlug}` : textSlug; + } + } + if (!base) base = `item-${crypto.randomUUID().slice(0, 6)}`; + + let linkId = base; + let counter = 2; + while (seen.has(linkId)) { + linkId = `${base}-${counter}`; + counter++; + } + seen.add(linkId); + + const childPageSlug = item.type === 'group' ? toSlug(item.text || '') : undefined; + return { + ...item, + linkId, + item: item.item ? ensureUniqueLinkIds(item.item, seen, childPageSlug) : undefined, + }; + }); +} + +export const QuestionnaireBuilder: FC = ({ initial, onSave, onCancel }) => { + const [title, setTitle] = useState(initial?.title || ''); + // No user-facing status concept: a questionnaire is either live (active) or deleted (retired, + // set via the Delete action). New questionnaires are created active; edits preserve the existing + // status so editing a live form never changes it. + const [status] = useState(initial?.status || 'active'); + const [description, setDescription] = useState(initial?.description || ''); + const [items, dispatch] = useReducer(itemsReducer, initial?.item || []); + const [titleError, setTitleError] = useState(false); + const [saving, setSaving] = useState(false); + const [testDialogOpen, setTestDialogOpen] = useState(false); + + const questionnaire = useMemo(() => { + // The canonical url/name are identity: existing QuestionnaireResponses reference the + // questionnaire by canonical URL, so editing (even retitling) an existing resource must + // preserve them. Only brand-new questionnaires derive a slug from the title. + const canonicalFields = initial?.id + ? { ...(initial.url && { url: initial.url }), ...(initial.name && { name: initial.name }) } + : title + ? (() => { + const slug = toSlug(title); + return { url: `https://ottehr.com/FHIR/Questionnaire/${slug}`, name: slug }; + })() + : {}; + const q: FhirQuestionnaire = { + resourceType: 'Questionnaire', + ...(initial?.id && { id: initial.id }), + ...canonicalFields, + title: title || undefined, + status, + ...(description && { description }), + item: ensureUniqueLinkIds(items), + }; + return toFhirJson(q) as FhirQuestionnaire; + }, [initial?.id, initial?.url, initial?.name, title, status, description, items]); + + const jsonPreview = useMemo(() => JSON.stringify(questionnaire, null, 2), [questionnaire]); + + const handleCopyJson = useCallback(() => { + void navigator.clipboard.writeText(jsonPreview).then(() => { + enqueueSnackbar('JSON copied to clipboard', { variant: 'success' }); + }); + }, [jsonPreview]); + + const handleSave = useCallback(async () => { + if (!title.trim()) { + setTitleError(true); + return; + } + setSaving(true); + try { + await onSave({ + ...questionnaire, + id: initial?.id, + }); + } finally { + setSaving(false); + } + }, [title, questionnaire, initial?.id, onSave]); + + return ( + + + {/* Left column: Form — scrollable */} + + + + Questionnaire Properties + + + + { + setTitle(e.target.value); + setTitleError(false); + }} + error={titleError} + helperText={titleError ? 'Title is required' : undefined} + fullWidth + required + /> + + + setDescription(e.target.value)} + multiline + minRows={2} + fullWidth + /> + + + + + + + + Pages ({items.length}) + + } + onClick={() => dispatch({ type: 'ADD_PAGE' })} + > + Add Page + + + + Each page becomes a separate screen. Add items inside pages using the + button. + + + {items.length === 0 && ( + + No pages yet. Click "Add Page" to create your first page. + + )} + + {items.map((item: QuestionnaireItem) => ( + + ))} + + + {/* Spacer for floating buttons */} + + + + {/* Right column: Form Preview + JSON Preview — independent scroll */} + + + + + Form Preview + + } + onClick={() => setTestDialogOpen(true)} + disabled={!questionnaire.item?.length} + > + Test Form + + + + + setTestDialogOpen(false)} + questionnaire={questionnaire} + rawItems={items} + /> + + + + + JSON Preview + + + + + {jsonPreview} + + + + + + {/* Floating action bar */} + + + Cancel + + + Save Questionnaire + + + + ); +}; diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilderPage.tsx b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilderPage.tsx new file mode 100644 index 0000000000..2b5e0f1908 --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireBuilderPage.tsx @@ -0,0 +1,118 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { Box, Button, CircularProgress, IconButton, Typography, useTheme } from '@mui/material'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { enqueueSnackbar } from 'notistack'; +import { FC, useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + createPracticeManagedQuestionnaire, + getPracticeManagedQuestionnaire, + updatePracticeManagedQuestionnaire, +} from 'src/api/api'; +import { useApiClients } from 'src/hooks/useAppClients'; +import PageContainer from 'src/layout/PageContainer'; +import { FhirQuestionnaire, fromFhirResource } from './questionnaire.types'; +import { QuestionnaireBuilder } from './QuestionnaireBuilder'; + +const LIST_QUERY_KEY = ['practice-managed-questionnaires']; +const DETAIL_QUERY_KEY = 'practice-managed-questionnaire'; + +export const QuestionnaireBuilderPage: FC = () => { + const { questionnaireId } = useParams(); + const navigate = useNavigate(); + const theme = useTheme(); + const { oystehrZambda } = useApiClients(); + const queryClient = useQueryClient(); + const isNew = !questionnaireId || questionnaireId === 'new'; + + // Fetch the single questionnaire being edited. No placeholderData: the builder + // initializes its local state from `initial` exactly once (it isn't keyed), so + // mounting it with a stale list-cache copy would silently edit — and on save, + // overwrite — an outdated version. Wait for the authoritative fetch instead. + const { data: editing, isLoading: isEditingLoading } = useQuery({ + queryKey: [DETAIL_QUERY_KEY, questionnaireId], + queryFn: async () => { + if (!oystehrZambda || isNew || !questionnaireId) return null; + const result = await getPracticeManagedQuestionnaire(oystehrZambda, questionnaireId); + return result.questionnaire ? fromFhirResource(result.questionnaire) : null; + }, + enabled: !!oystehrZambda && !isNew, + }); + + const data = { editing }; + const isLoading = !isNew && isEditingLoading && editing === undefined; + + const handleSave = useCallback( + async (q: FhirQuestionnaire) => { + if (!oystehrZambda) return; + try { + if (!isNew) { + await updatePracticeManagedQuestionnaire(oystehrZambda, q as unknown as Record); + } else { + await createPracticeManagedQuestionnaire(oystehrZambda, q as unknown as Record); + } + void queryClient.invalidateQueries({ queryKey: LIST_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: [DETAIL_QUERY_KEY] }); + enqueueSnackbar('Questionnaire saved', { variant: 'success' }); + navigate('/admin/questionnaires'); + } catch (err) { + console.error('Failed to save questionnaire:', err); + enqueueSnackbar('Failed to save questionnaire', { variant: 'error' }); + } + }, + [oystehrZambda, isNew, queryClient, navigate] + ); + + const handleCancel = useCallback(() => { + navigate('/admin/questionnaires'); + }, [navigate]); + + if (isLoading) { + return ( + + + + + + ); + } + + if (!isNew && !data?.editing) { + return ( + + + + Questionnaire not found. + + + + + ); + } + + const headerTitle = isNew + ? 'Create Questionnaire' + : data?.editing?.title || data?.editing?.name || 'Edit Questionnaire'; + + return ( + + <> + + navigate('/admin/questionnaires')} + size="small" + aria-label="Back to questionnaires" + > + + + + {headerTitle} + + + + + + ); +}; diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireItemEditor.tsx b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireItemEditor.tsx new file mode 100644 index 0000000000..f832e0d414 --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireItemEditor.tsx @@ -0,0 +1,355 @@ +import AbcIcon from '@mui/icons-material/Abc'; +import AddIcon from '@mui/icons-material/Add'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import LinkIcon from '@mui/icons-material/Link'; +import NumbersIcon from '@mui/icons-material/Numbers'; +import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; +import ScheduleIcon from '@mui/icons-material/Schedule'; +import SubjectIcon from '@mui/icons-material/Subject'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Checkbox, + FormControlLabel, + Grid, + IconButton, + MenuItem, + Select, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { FC, useState } from 'react'; +import { AnswerOptionEditor } from './AnswerOptionEditor'; +import { ItemAction } from './questionnaire.reducer'; +import { + DATA_TYPES_BY_ITEM_TYPE, + OTTEHR_INPUT_WIDTHS, + OttehrDataType, + QUESTIONNAIRE_ITEM_TYPES, + QuestionnaireItem, +} from './questionnaire.types'; + +interface QuestionnaireItemEditorProps { + item: QuestionnaireItem; + dispatch: React.Dispatch; + depth?: number; +} + +const iconSx = { fontSize: 14 }; +const TYPE_ICONS: Record = { + string: , + text: , + boolean: , + choice: , + 'open-choice': , + integer: , + decimal: , + quantity: , + date: , + dateTime: , + time: , + url: , + attachment: , + display: , + reference: , +}; + +const ItemActions: FC<{ item: QuestionnaireItem; dispatch: React.Dispatch }> = ({ item, dispatch }) => ( + + + dispatch({ type: 'MOVE_ITEM_UP', key: item._key })}> + + + + + dispatch({ type: 'MOVE_ITEM_DOWN', key: item._key })}> + + + + + dispatch({ type: 'REMOVE_ITEM', key: item._key })}> + + + + +); + +const ItemFields: FC<{ item: QuestionnaireItem; dispatch: React.Dispatch }> = ({ item, dispatch }) => { + const isChoice = item.type === 'choice' || item.type === 'open-choice'; + const isGroup = item.type === 'group'; + const showMaxLength = item.type === 'string' || item.type === 'text' || item.type === 'url'; + const isDisplay = item.type === 'display'; + const availableDataTypes = (DATA_TYPES_BY_ITEM_TYPE[item.type] || []) as readonly OttehrDataType[]; + + return ( + + {!isGroup && ( + + + + )} + + dispatch({ type: 'UPDATE_ITEM', key: item._key, field: 'text', value: e.target.value })} + fullWidth + /> + + + {!isDisplay && !isGroup && ( + <> + + + + dispatch({ + type: 'UPDATE_ITEM', + key: item._key, + field: 'required', + value: e.target.checked || undefined, + }) + } + /> + } + label="Required" + /> + + dispatch({ + type: 'UPDATE_ITEM', + key: item._key, + field: 'repeats', + value: e.target.checked || undefined, + }) + } + /> + } + label="Repeats" + /> + + dispatch({ + type: 'UPDATE_ITEM', + key: item._key, + field: 'readOnly', + value: e.target.checked || undefined, + }) + } + /> + } + label="Read Only" + /> + {showMaxLength && ( + + dispatch({ + type: 'UPDATE_ITEM', + key: item._key, + field: 'maxLength', + value: e.target.value ? parseInt(e.target.value) : undefined, + }) + } + sx={{ width: 120 }} + /> + )} + + + {availableDataTypes.length > 0 && ( + + + + )} + + + + + )} + + {isChoice && ( + + + + )} + + ); +}; + +export const QuestionnaireItemEditor: FC = ({ item, dispatch, depth = 0 }) => { + const isGroup = item.type === 'group'; + const [expanded, setExpanded] = useState(false); + + // Child items render as plain boxes, no accordion + if (depth > 0) { + return ( + + + + + + + ); + } + + // Pages render with accordion + return ( + setExpanded(isExpanded)} + TransitionProps={{ unmountOnExit: true, mountOnEnter: true }} + sx={{ + '&:before': { display: 'none' }, + border: '1px solid #1976d2', + borderRadius: '8px !important', + boxShadow: 'none', + mb: 1.5, + }} + > + } + sx={{ '& .MuiAccordionSummary-content': { minWidth: 0, overflow: 'hidden' } }} + > + + {!expanded && ( + + + {item.text || '(untitled)'} + + {isGroup && (item.item?.length ?? 0) > 0 && ( + + {item.item!.map((child, i) => ( + + {TYPE_ICONS[child.type] || TYPE_ICONS.string} + {child.text || '(untitled)'} + + ))} + + )} + + )} + {expanded && } + e.stopPropagation()}> + + + + + + + {isGroup && ( + + + Page Content + dispatch({ type: 'ADD_CHILD_ITEM', key: item._key })} + > + + + + {(item.item || []).map((child) => ( + + ))} + {(!item.item || item.item.length === 0) && ( + + No items on this page yet. Click + to add one. + + )} + + )} + + + ); +}; diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnairePreview.tsx b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnairePreview.tsx new file mode 100644 index 0000000000..44118c1a53 --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnairePreview.tsx @@ -0,0 +1,569 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import CalculateIcon from '@mui/icons-material/Calculate'; +import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; +import { + Box, + Button, + Checkbox, + Chip, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Radio, + RadioGroup, + Select, + TextField, + Typography, +} from '@mui/material'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { getDisplayOptionPrefix, getItemControl, getOptionDisplay, isScoreItem } from 'ui-components'; +import { FhirQuestionnaire, QuestionnaireItem } from './questionnaire.types'; + +// Ottehr intake color palette +const COLORS = { + primaryMain: '#0F347C', + primaryDark: '#0A2143', + secondaryMain: '#2169F5', + textPrimary: '#212130', + textSecondary: '#4F4F4F', + selectedBg: '#E2F0FF', + cardBg: '#F7F8F9', + calloutBg: '#aed4fc', + focusShadow: 'rgba(77, 21, 183, 0.25)', + border: '#E0E0E0', + pageBg: '#FFFFFF', +}; + +interface QuestionnairePreviewProps { + questionnaire: FhirQuestionnaire; +} + +// Bold input label matching Ottehr's BoldPurpleInputLabel +const OttehrLabel: FC<{ text: string; required?: boolean }> = ({ text, required }) => ( + + {text} + {required && ( + + * + + )} + +); + +// Ottehr-styled radio option +const OttehrRadioOption: FC<{ label: string; description?: string; selected?: boolean }> = ({ + label, + description, + selected, +}) => ( + + } + checkedIcon={} + sx={{ p: 0.5, mr: 1 }} + /> + + + {label} + + {description && ( + + {description} + + )} + + +); + +// Ottehr-styled text field +const ottehrInputSx = { + '& .MuiOutlinedInput-root': { + borderRadius: '8px', + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + boxShadow: `0 -0.5px 0px 3px ${COLORS.focusShadow}`, + }, + }, +}; + +function getItemGridWidth(item: QuestionnaireItem): number { + const widthMap: Record = { s: 4, m: 6, l: 7 }; + // Check the builder's inputWidth field first + if (item.inputWidth && widthMap[item.inputWidth]) return widthMap[item.inputWidth]; + // Fall back to reading from FHIR extension array + const ext = (item as any).extension as { url: string; valueString?: string }[] | undefined; + const widthExt = ext?.find((e) => e.url?.includes('input-width'))?.valueString; + if (widthExt && widthMap[widthExt]) return widthMap[widthExt]; + return 12; +} + +const OpenChoiceSelectPreview: FC<{ item: QuestionnaireItem }> = ({ item }) => { + const containerRef = useRef(null); + const [menuWidth, setMenuWidth] = useState(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = (): void => setMenuWidth(el.getBoundingClientRect().width); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( + + + + + ); +}; + +const ItemPreview: FC<{ item: QuestionnaireItem }> = ({ item }) => { + switch (item.type) { + case 'group': + return ( + + + {item.text || item.linkId} + + + {(item.item || []).map((child, childIdx) => ( + + + + ))} + + + ); + + case 'display': + return ( + + + {item.text} + + + ); + + case 'boolean': + return ( + + } + label={ + + {item.text || item.linkId} + + } + /> + + ); + + case 'choice': { + // The local QuestionnaireItem type's `extension` field is loosely typed + // (`Record[]`) while the helpers in ui-components expect + // fhir4b.Extension with required `url`. Runtime shapes match — extensions + // imported from FHIR JSON always have url. Cast as needed for the helper. + const itemControl = getItemControl(item as unknown as Parameters[0]); + const useDropdown = itemControl === 'drop-down' || (item.answerOption?.length || 0) > 6; + const options = item.answerOption || []; + + return ( + + + {useDropdown ? ( + + ) : ( + + {options.map((opt, i) => { + const prefix = getDisplayOptionPrefix(opt); + const display = getOptionDisplay(opt); + const label = `${prefix !== undefined ? `${prefix}. ` : ''}${display}`; + return ; + })} + + )} + + ); + } + + case 'open-choice': + return ; + + case 'date': + case 'dateTime': + return ( + + + + + ); + + case 'time': + return ( + + + + + ); + + case 'integer': + case 'quantity': + return ( + + + + + ); + + case 'decimal': + if (isScoreItem(item as unknown as Parameters[0])) { + return ( + + + + + {item.text || item.linkId} + + + Auto-calculated from scored items above + + + + + ); + } + return ( + + + + + ); + + case 'text': + return ( + + + + + ); + + case 'url': + return ( + + + + + ); + + case 'attachment': + return ( + + + + + Click or drag to upload + + + + ); + + case 'reference': + return ( + + + + + ); + + // string is the default + default: + return ( + + + + + ); + } +}; + +export const QuestionnairePreview: FC = ({ questionnaire }) => { + // Treat top-level group items as pages (Ottehr convention) + const pages = useMemo(() => { + const items = questionnaire.item || []; + const topLevelGroups = items.filter((item) => item.type === 'group'); + // If there are top-level groups, treat them as pages; otherwise show all items as one page + if (topLevelGroups.length > 0) return topLevelGroups; + return items.length > 0 ? [{ linkId: 'all', text: questionnaire.title, type: 'group' as const, item: items }] : []; + }, [questionnaire]); + + const [currentPage, setCurrentPage] = useState(0); + + if (pages.length === 0) { + return ( + + Add items to see the form preview. + + ); + } + + const safeCurrentPage = Math.min(currentPage, pages.length - 1); + const page = pages[safeCurrentPage]; + const isFirst = safeCurrentPage === 0; + const isLast = safeCurrentPage === pages.length - 1; + + return ( + + + {/* Page progress indicator */} + {pages.length > 1 && ( + + {pages.map((_, idx) => ( + setCurrentPage(idx)} + sx={{ + flex: 1, + height: 4, + borderRadius: 2, + bgcolor: idx <= safeCurrentPage ? COLORS.secondaryMain : COLORS.border, + cursor: 'pointer', + transition: 'background-color 0.2s', + }} + /> + ))} + + )} + + {/* Card container — matches Ottehr's Container maxWidth="md" + Card pattern */} + + {/* Page title */} + + {page.text || page.linkId} + + {safeCurrentPage === 0 && questionnaire.description && ( + + {questionnaire.description} + + )} + + {/* Page items */} + + {(page.item || []).map((item, idx) => ( + + + + ))} + + + {/* Navigation buttons */} + + {!isFirst ? ( + + ) : ( + + )} + + + + {/* Page indicator */} + {pages.length > 1 && ( + + Page {safeCurrentPage + 1} of {pages.length} + + )} + + {/* end card */} + + + ); +}; diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireTestDialog.tsx b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireTestDialog.tsx new file mode 100644 index 0000000000..efc1c24e93 --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/QuestionnaireTestDialog.tsx @@ -0,0 +1,187 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { Box, Dialog, DialogContent, DialogTitle, IconButton, Typography } from '@mui/material'; +import { QuestionnaireItem } from 'fhir/r4b'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { buildQuestionnairePages, evaluateCalculatedExpressions, QuestionnaireFormPage } from 'ui-components'; +import { FhirQuestionnaire, QuestionnaireItem as BuilderItem } from './questionnaire.types'; + +/** Convert builder items (with _key, extension as Record) to fhir/r4b QuestionnaireItem shape */ +function toFhirItems(items: BuilderItem[]): QuestionnaireItem[] { + return items.map((item) => { + const fhirItem: any = { ...item }; + delete fhirItem._key; + delete fhirItem.dataType; + delete fhirItem.inputWidth; + // Ensure group items always have an item array (toFhirJson may strip empty arrays) + if (item.type === 'group') { + fhirItem.item = item.item ? toFhirItems(item.item) : []; + } else if (item.item) { + fhirItem.item = toFhirItems(item.item); + } + return fhirItem as QuestionnaireItem; + }); +} + +interface QuestionnaireTestDialogProps { + open: boolean; + onClose: () => void; + questionnaire: FhirQuestionnaire; + /** Raw builder items (before toFhirJson processing which may strip empty arrays) */ + rawItems?: BuilderItem[]; +} + +export const QuestionnaireTestDialog: FC = ({ + open, + onClose, + questionnaire, + rawItems, +}) => { + const fhirItems = useMemo(() => toFhirItems(rawItems || questionnaire.item || []), [rawItems, questionnaire.item]); + const pages = useMemo( + () => buildQuestionnairePages(fhirItems, questionnaire.title), + [fhirItems, questionnaire.title] + ); + + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [completed, setCompleted] = useState(false); + const [answers, setAnswers] = useState>({}); + const methods = useForm(); + + const currentPage = pages[currentPageIndex]; + const isLastPage = currentPageIndex >= pages.length - 1; + + const handleSubmit = useCallback( + (data: Record) => { + setAnswers((prev) => ({ ...prev, ...data })); + if (!isLastPage) { + setCurrentPageIndex((prev) => prev + 1); + } else { + setCompleted(true); + } + }, + [isLastPage] + ); + + const handleBack = useCallback(() => { + if (currentPageIndex > 0) { + setCurrentPageIndex((prev) => prev - 1); + } + }, [currentPageIndex]); + + // Restore previously-entered answers when the page changes (forward or back). + // Watched only on currentPageIndex — `answers` updates on every submit, but + // the form state we want to restore is whatever's been accumulated so far. + const answersRef = useRef(answers); + answersRef.current = answers; + useEffect(() => { + methods.reset(answersRef.current); + }, [currentPageIndex, methods]); + + const handleClose = useCallback(() => { + setCurrentPageIndex(0); + setCompleted(false); + setAnswers({}); + methods.reset(); + onClose(); + }, [methods, onClose]); + + return ( + + + + + + + + {/* Page progress */} + {pages.length > 1 && !completed && ( + + {pages.map((_, idx) => ( + + ))} + + )} + + {completed ? ( + + + Form Complete + + {(() => { + const computed = evaluateCalculatedExpressions(fhirItems, answers); + const computedEntries = Object.entries(computed).filter(([, v]) => v !== undefined); + return ( + <> + {computedEntries.length > 0 && ( + <> + + Computed scoring values (provider sees these in the QR): + + + {computedEntries.map(([k, v]) => ( + + {k}: {typeof v === 'string' ? `"${v}"` : String(v)} + + ))} + + + )} + + Patient answers submitted: + + + {JSON.stringify(answers, null, 2)} + + + ); + })()} + + ) : currentPage ? ( + 0 ? handleBack : undefined} + isLastPage={isLastPage} + submitLabel={isLastPage ? 'Submit' : 'Continue'} + /> + ) : ( + No items to display. + )} + + + ); +}; diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.reducer.ts b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.reducer.ts new file mode 100644 index 0000000000..0729f8bb35 --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.reducer.ts @@ -0,0 +1,126 @@ +import { + generateKey, + QuestionnaireAnswerOption, + QuestionnaireItem, + QuestionnaireItemType, +} from './questionnaire.types'; + +export type ItemAction = + | { type: 'ADD_PAGE' } + | { type: 'ADD_CHILD_ITEM'; key: string } + | { type: 'UPDATE_ITEM'; key: string; field: string; value: unknown } + | { type: 'REMOVE_ITEM'; key: string } + | { type: 'MOVE_ITEM_UP'; key: string } + | { type: 'MOVE_ITEM_DOWN'; key: string } + | { type: 'ADD_ANSWER_OPTION'; key: string } + | { type: 'UPDATE_ANSWER_OPTION'; key: string; index: number; option: QuestionnaireAnswerOption } + | { type: 'REMOVE_ANSWER_OPTION'; key: string; index: number } + | { type: 'SET_ITEMS'; items: QuestionnaireItem[] }; + +function updateItemInTree( + items: QuestionnaireItem[], + key: string, + updater: (item: QuestionnaireItem) => QuestionnaireItem +): QuestionnaireItem[] { + return items.map((item) => { + if (item._key === key) return updater(item); + if (item.item) return { ...item, item: updateItemInTree(item.item, key, updater) }; + return item; + }); +} + +function removeItemFromTree(items: QuestionnaireItem[], key: string): QuestionnaireItem[] { + return items + .filter((item) => item._key !== key) + .map((item) => (item.item ? { ...item, item: removeItemFromTree(item.item, key) } : item)); +} + +function moveItemInList(items: QuestionnaireItem[], key: string, direction: -1 | 1): QuestionnaireItem[] { + const index = items.findIndex((item) => item._key === key); + if (index >= 0) { + const newIndex = index + direction; + if (newIndex >= 0 && newIndex < items.length) { + const result = [...items]; + [result[index], result[newIndex]] = [result[newIndex], result[index]]; + return result; + } + return items; + } + return items.map((item) => (item.item ? { ...item, item: moveItemInList(item.item, key, direction) } : item)); +} + +export function itemsReducer(state: QuestionnaireItem[], action: ItemAction): QuestionnaireItem[] { + switch (action.type) { + case 'ADD_PAGE': + return [ + ...state, + { + _key: generateKey(), + linkId: '', + type: 'group' as QuestionnaireItemType, + text: '', + item: [ + { + _key: generateKey(), + linkId: '', + type: 'string' as QuestionnaireItemType, + text: '', + }, + ], + }, + ]; + + case 'ADD_CHILD_ITEM': + return updateItemInTree(state, action.key, (item) => ({ + ...item, + item: [ + ...(item.item || []), + { + _key: generateKey(), + linkId: '', + type: 'string' as QuestionnaireItemType, + text: '', + }, + ], + })); + + case 'UPDATE_ITEM': + return updateItemInTree(state, action.key, (item) => ({ + ...item, + [action.field]: action.value, + })); + + case 'REMOVE_ITEM': + return removeItemFromTree(state, action.key); + + case 'MOVE_ITEM_UP': + return moveItemInList(state, action.key, -1); + + case 'MOVE_ITEM_DOWN': + return moveItemInList(state, action.key, 1); + + case 'ADD_ANSWER_OPTION': + return updateItemInTree(state, action.key, (item) => ({ + ...item, + answerOption: [...(item.answerOption || []), { valueString: '' }], + })); + + case 'UPDATE_ANSWER_OPTION': + return updateItemInTree(state, action.key, (item) => ({ + ...item, + answerOption: (item.answerOption || []).map((opt, i) => (i === action.index ? action.option : opt)), + })); + + case 'REMOVE_ANSWER_OPTION': + return updateItemInTree(state, action.key, (item) => ({ + ...item, + answerOption: (item.answerOption || []).filter((_, i) => i !== action.index), + })); + + case 'SET_ITEMS': + return action.items; + + default: + return state; + } +} diff --git a/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.types.ts b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.types.ts new file mode 100644 index 0000000000..358a9acd7a --- /dev/null +++ b/apps/ehr/src/features/visits/telemed/components/admin/questionnaires/questionnaire.types.ts @@ -0,0 +1,153 @@ +export const QUESTIONNAIRE_ITEM_TYPES = [ + 'attachment', + 'boolean', + 'choice', + 'date', + 'dateTime', + 'decimal', + 'display', + 'group', + 'integer', + 'open-choice', + 'quantity', + 'reference', + 'string', + 'text', + 'time', + 'url', +] as const; + +export type QuestionnaireItemType = (typeof QUESTIONNAIRE_ITEM_TYPES)[number]; + +export type QuestionnaireStatus = 'draft' | 'active' | 'retired' | 'unknown'; + +export interface QuestionnaireAnswerOption { + valueString?: string; + valueCoding?: { system?: string; code: string; display?: string }; + valueInteger?: number; + initialSelected?: boolean; + /** Raw FHIR extensions on the option (e.g. itemWeight, optionPrefix) */ + extension?: Record[]; +} + +export interface QuestionnaireEnableWhen { + question: string; + operator: 'exists' | '=' | '!=' | '>' | '<' | '>=' | '<='; + answerBoolean?: boolean; + answerString?: string; + answerInteger?: number; + answerCoding?: { system?: string; code: string; display?: string }; +} + +export const OTTEHR_DATA_TYPES = [ + 'Phone Number', + 'Email', + 'ZIP', + 'DOB', + 'SSN', + 'Signature', + 'Image', + 'PDF', + 'Call Out', +] as const; + +export type OttehrDataType = (typeof OTTEHR_DATA_TYPES)[number]; + +// Which Ottehr dataTypes are valid for each FHIR item type +export const DATA_TYPES_BY_ITEM_TYPE: Partial> = { + string: ['Phone Number', 'Email', 'ZIP', 'SSN', 'Signature'], + date: ['DOB'], + attachment: ['Image', 'PDF'], + display: ['Call Out'], +}; + +export const OTTEHR_INPUT_WIDTHS = ['s', 'm', 'l'] as const; +export type OttehrInputWidth = (typeof OTTEHR_INPUT_WIDTHS)[number]; + +export interface QuestionnaireItem { + /** Internal key for stable identity in the builder UI — stripped from JSON output */ + _key: string; + linkId: string; + text?: string; + type: QuestionnaireItemType; + required?: boolean; + repeats?: boolean; + readOnly?: boolean; + maxLength?: number; + enableWhen?: QuestionnaireEnableWhen[]; + enableBehavior?: 'all' | 'any'; + answerOption?: QuestionnaireAnswerOption[]; + initial?: { valueString?: string; valueBoolean?: boolean; valueInteger?: number }[]; + item?: QuestionnaireItem[]; + /** Ottehr extensions — stored in extension[] in the FHIR JSON */ + dataType?: OttehrDataType; + inputWidth?: OttehrInputWidth; + /** Raw FHIR extensions preserved from imported questionnaires (for preview rendering) */ + extension?: Record[]; + /** LOINC or other code on the item */ + code?: { system?: string; code: string; display?: string }[]; +} + +export interface FhirQuestionnaire { + resourceType: 'Questionnaire'; + id?: string; + url?: string; + name?: string; + title?: string; + status: QuestionnaireStatus; + description?: string; + item: QuestionnaireItem[]; +} + +export function generateKey(): string { + return crypto.randomUUID().slice(0, 8); +} + +const EXTENSION_BASE = 'https://fhir.zapehr.com/r4/StructureDefinitions'; + +/** Convert a FHIR Questionnaire item (with extensions) back to builder format (with _key, dataType, inputWidth) */ +function itemFromFhir(fhirItem: Record): QuestionnaireItem { + const extensions = (fhirItem.extension || []) as { url: string; valueString?: string }[]; + const dataType = extensions.find((e) => e.url === `${EXTENSION_BASE}/data-type`)?.valueString as + | OttehrDataType + | undefined; + const inputWidth = extensions.find((e) => e.url === `${EXTENSION_BASE}/input-width`)?.valueString as + | OttehrInputWidth + | undefined; + const children = fhirItem.item as Record[] | undefined; + + return { + _key: generateKey(), + linkId: (fhirItem.linkId as string) || '', + text: (fhirItem.text as string) || undefined, + type: (fhirItem.type as QuestionnaireItemType) || 'string', + required: (fhirItem.required as boolean) || undefined, + repeats: (fhirItem.repeats as boolean) || undefined, + readOnly: (fhirItem.readOnly as boolean) || undefined, + maxLength: (fhirItem.maxLength as number) || undefined, + answerOption: fhirItem.answerOption as QuestionnaireAnswerOption[] | undefined, + enableWhen: fhirItem.enableWhen as QuestionnaireEnableWhen[] | undefined, + enableBehavior: fhirItem.enableBehavior as 'all' | 'any' | undefined, + item: children ? children.map(itemFromFhir) : undefined, + dataType, + inputWidth, + extension: extensions.length > 0 ? (extensions as Record[]) : undefined, + code: fhirItem.code as { system?: string; code: string; display?: string }[] | undefined, + }; +} + +/** Convert a FHIR Questionnaire resource into builder format */ +export function fromFhirResource(resource: Record): FhirQuestionnaire { + const items = (resource.item || []) as Record[]; + + return { + resourceType: 'Questionnaire', + id: resource.id as string | undefined, + url: resource.url as string | undefined, + name: resource.name as string | undefined, + title: resource.title as string | undefined, + status: (resource.status as QuestionnaireStatus) || 'draft', + description: resource.description as string | undefined, + item: items.map(itemFromFhir), + }; +} diff --git a/apps/ehr/src/pages/AddPatient.tsx b/apps/ehr/src/pages/AddPatient.tsx index 0287c9eb1e..d2e25848dc 100644 --- a/apps/ehr/src/pages/AddPatient.tsx +++ b/apps/ehr/src/pages/AddPatient.tsx @@ -25,6 +25,8 @@ import { AddVisitPatientInformationCard } from 'src/features/visits/shared/compo import { useReasonForVisitOptions } from 'src/features/visits/shared/hooks/useReasonForVisitOptions'; import { APIError, + APPOINTMENT_PAPERWORK_SUBTYPE, + AppointmentPaperworkSubtype, BOOKING_CONFIG, CopyableFollowupField, CreateAppointmentInputParams, @@ -162,6 +164,7 @@ export default function AddPatient(): JSX.Element { const [reasonForVisitAdditional, setReasonForVisitAdditional] = useState(''); const [visitType, setVisitType] = useState(); const [serviceCategory, setServiceCategory] = useState(defaultServiceCategory); + const [paperworkSubtype, setPaperworkSubtype] = useState(''); const [slot, setSlot] = useState(); const [loading, setLoading] = useState(false); const [errors, setErrors] = useState({ @@ -425,6 +428,7 @@ export default function AddPatient(): JSX.Element { }, slotId: persistedSlot.id!, ...(followUpOptions && { followUpOptions }), + paperworkSubtype: paperworkSubtype || undefined, }; let response; @@ -544,6 +548,22 @@ export default function AddPatient(): JSX.Element { {errors.serviceCategory && Service category is required} + + Visit paperwork + + + navigate(`/admin/${PageTab['lab-sets']}`)} /> + navigate(`/admin/${PageTab.questionnaires}`)} + /> + + + diff --git a/apps/ehr/src/pages/PatientDocumentsExplorerPage.tsx b/apps/ehr/src/pages/PatientDocumentsExplorerPage.tsx index bf9421ef74..ee1f917845 100644 --- a/apps/ehr/src/pages/PatientDocumentsExplorerPage.tsx +++ b/apps/ehr/src/pages/PatientDocumentsExplorerPage.tsx @@ -6,7 +6,7 @@ import { styled } from '@mui/material'; import { DateTime } from 'luxon'; import { enqueueSnackbar } from 'notistack'; import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { PatientDocumentFoldersColumn, PatientDocumentFoldersColumnSkeleton, @@ -43,6 +43,8 @@ const PatientDocumentsExplorerPage: FC = () => { const { id: patientId } = useParams(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const initialFolderName = searchParams.get('folder') || undefined; const { patient, loading: isLoadingPatientData } = useGetPatient(patientId); useEffect(() => { @@ -79,6 +81,17 @@ const PatientDocumentsExplorerPage: FC = () => { } }, [documentsFolders, pendingSelectInternalName]); + // If a ?folder= URL param is present, preselect that folder once the + // folder list has loaded. Used by the Patient Follow-up Task "Go To Task" + // deep link from completed practice-managed forms. + useEffect(() => { + if (!initialFolderName) return; + if (selectedFolder) return; + if (!documentsFolders.length) return; + const match = documentsFolders.find((f) => f.folderName?.toLowerCase() === initialFolderName.toLowerCase()); + if (match) setSelectedFolder(match); + }, [initialFolderName, documentsFolders, selectedFolder]); + const shouldShowClearFilters = searchDocNameFieldValue.trim().length > 0 || searchDocAddedDate || selectedFolder; const handleBackClickWithConfirmation = (): void => { diff --git a/apps/ehr/src/pages/VisitDetailsPage.tsx b/apps/ehr/src/pages/VisitDetailsPage.tsx index 1903e24251..7f3dbf23cb 100644 --- a/apps/ehr/src/pages/VisitDetailsPage.tsx +++ b/apps/ehr/src/pages/VisitDetailsPage.tsx @@ -42,6 +42,7 @@ import { updatePatientVisitDetails, updateVisitFiles, } from 'src/api/api'; +import { SendFormDialog } from 'src/components/dialogs/SendFormDialog'; import ImageCarousel, { ImageCarouselObject } from 'src/components/ImageCarousel'; import ImageUploader from 'src/components/ImageUploader'; import PatientBalances from 'src/components/PatientBalances'; @@ -52,6 +53,7 @@ import { useGetPatientAccount, useGetPatientCoverages } from 'src/hooks/useGetPa import { useGetPatientBalances } from 'src/hooks/useGetPatientBalances'; import { useGetPatientDocs } from 'src/hooks/useGetPatientDocs'; import { useGetPatientPaymentsList } from 'src/hooks/useGetPatientPaymentsList'; +import { QuestionnaireResponseViewer } from 'ui-components'; import { BOOKING_CONFIG, DocumentInfo, @@ -277,6 +279,31 @@ export default function VisitDetailsPage(): ReactElement { enabled: Boolean(oystehrZambda) && appointmentID !== undefined, }); + // Fetch practice-managed questionnaire responses for this visit + const { data: practiceManagedData } = useQuery({ + queryKey: ['practice-managed-questionnaires', appointmentID], + queryFn: async () => { + if (!oystehrZambda || !appointmentID) return { questionnaires: [] }; + const response = await oystehrZambda.zambda.execute({ + id: 'get-practice-managed-questionnaires', + appointmentId: appointmentID, + } as any); + const output = typeof response.output === 'string' ? JSON.parse(response.output) : response.output; + return output as { + questionnaires: { + id: string; + title: string; + questionnaireResponseId?: string; + questionnaireResponseStatus?: string; + item?: any[]; + }[]; + }; + }, + enabled: Boolean(oystehrZambda) && appointmentID !== undefined, + }); + + const [sendFormDialogOpen, setSendFormDialogOpen] = useState(false); + const { fullCardPdfs, consentPdfUrls } = imageFileData || { fullCardPdfs: [], consentPdfUrls: [], @@ -991,6 +1018,14 @@ export default function VisitDetailsPage(): ReactElement { Legacy Data )} + {/* page title row */} @@ -1362,6 +1397,35 @@ export default function VisitDetailsPage(): ReactElement { } /> + {(practiceManagedData?.questionnaires || []).length > 0 ? ( + (practiceManagedData?.questionnaires || []).map((pmQ: any) => ( + + + + {pmQ.title} + + {!pmQ.questionnaireResponseItems ? ( + + Not Started + + ) : ( + + )} + + + )) + ) : ( + + + + )} {!patientBalancesLoading && @@ -1652,6 +1716,13 @@ export default function VisitDetailsPage(): ReactElement { outputFormat="png" onScanComplete={handleScanComplete} /> + {appointmentID && ( + setSendFormDialogOpen(false)} + appointmentId={appointmentID} + /> + )} ); diff --git a/apps/intake/src/App.tsx b/apps/intake/src/App.tsx index 51becac08c..0228585ba8 100644 --- a/apps/intake/src/App.tsx +++ b/apps/intake/src/App.tsx @@ -19,10 +19,12 @@ import MyPatients from './pages/MyPatients'; import { PaperworkHome, PaperworkPage } from './pages/PaperworkPage'; import PastVisits from './pages/PastVisits'; import PatientInformation, { PatientInfoCollection } from './pages/PatientInformation'; +import PracticeManagedPaperwork from './pages/PracticeManagedPaperwork'; import PrebookVisit from './pages/PrebookVisit'; import Review from './pages/Review'; import ReviewPaperwork from './pages/ReviewPaperwork'; import SelectServiceCategoryPage from './pages/SelectServiceCategory'; +import StandaloneFormPage from './pages/StandaloneFormPage'; import StartVirtualVisit from './pages/StartVirtualVisit'; import ThankYou from './pages/ThankYou'; import VisitDetails from './pages/VisitDetails'; @@ -116,6 +118,18 @@ export const intakeFlowPageRoute = { path: `${paperworkBasePath}/:slug`, getPage: () => , }, + PracticeManagedPaperwork: { + path: `${paperworkBasePath}/custom/:questionnaireId/:returnSlug`, + getPage: () => , + }, + StandaloneForm: { + path: '/forms/:appointmentId/:questionnaireId', + getPage: () => , + }, + StandaloneFormPatient: { + path: '/forms/patient/:patientId/:questionnaireId', + getPage: () => , + }, ReviewPaperwork: { path: `${paperworkBasePath}/review`, getPage: () => , @@ -382,18 +396,32 @@ function App(): JSX.Element { path={intakeFlowPageRoute.Appointments.path} element={intakeFlowPageRoute.Appointments.getPage()} /> + {/* Practice-managed paperwork route is outside PaperworkHome to avoid + inheriting the intake PaperworkContext, which would leak the main + intake QR/validation state into the practice-managed form. */} + + - + {/* IMPORTANT: Specific path routes must come before the :slug catch-all. + The PaperworkInformation route uses :slug which matches any path segment, + so more specific routes (review) must be listed first. */} + diff --git a/apps/intake/src/features/paperwork/PagedQuestionnaire.tsx b/apps/intake/src/features/paperwork/PagedQuestionnaire.tsx index 3b0bf15eb5..7f97c0cba8 100644 --- a/apps/intake/src/features/paperwork/PagedQuestionnaire.tsx +++ b/apps/intake/src/features/paperwork/PagedQuestionnaire.tsx @@ -344,9 +344,9 @@ const PaperworkFormRoot: FC = ({ ...baseStuff, submitDisabled: baseStuff.loading || isLoading || saveButtonDisabled, // only use the continue label with inperson paperwork - submitLabel: questionnaireResponse?.questionnaire?.includes('intake-paperwork-inperson') - ? continueLabel - : undefined, + submitLabel: + baseStuff.submitLabel ?? + (questionnaireResponse?.questionnaire?.includes('intake-paperwork-inperson') ? continueLabel : undefined), onSubmit: submitHandler, }; }, [continueLabel, controlButtons, isLoading, questionnaireResponse, saveButtonDisabled, submitHandler]); diff --git a/apps/intake/src/pages/PaperworkPage.tsx b/apps/intake/src/pages/PaperworkPage.tsx index 512110bed3..c7d4c41c2d 100644 --- a/apps/intake/src/pages/PaperworkPage.tsx +++ b/apps/intake/src/pages/PaperworkPage.tsx @@ -71,6 +71,10 @@ interface PaperworkStateActions { clear: () => void; } +// Practice-managed insertion boundary per appointment — stable for the visit, so one +// fetch per session instead of one per page transition. +const pmInsertAfterCache = new Map(); + const PAPERWORK_STATE_INITIAL: PaperworkState = { updateTimestamp: undefined, paperworkInProgress: {}, @@ -450,9 +454,48 @@ export const PaperworkPage: FC = () => { saveProgress(currentPage.linkId, undefined); const nextPage = getNextPage(updatedPaperwork); - navigate( - `/paperwork/${appointmentID}/${nextPage !== undefined ? slugFromLinkId(nextPage.linkId) : 'review'}` - ); + if (nextPage !== undefined) { + // Practice-managed form insertion. The server is the single source of truth + // for the insertion boundary (insertAfterPageLinkId) — guessing it from page + // name substrings silently skips form insertion on deployments whose intake + // pages are named differently. The boundary is stable for the appointment so + // it's fetched once and cached; questionnaire completion statuses change as + // forms are filled, so those are re-fetched when actually at the boundary. + try { + const fetchPmData = async (): Promise => { + const pmResponse = await zambdaClient.execute('get-practice-managed-questionnaires', { + appointmentId: appointmentID, + }); + return typeof pmResponse.output === 'string' ? JSON.parse(pmResponse.output) : pmResponse.output; + }; + + let freshPmData: any | undefined; + let insertAfter: string | null | undefined = pmInsertAfterCache.get(appointmentID); + if (insertAfter === undefined) { + freshPmData = await fetchPmData(); + insertAfter = (freshPmData?.insertAfterPageLinkId as string | undefined) ?? null; + pmInsertAfterCache.set(appointmentID, insertAfter); + } + + if (insertAfter && currentPage.linkId === insertAfter) { + const pmData = freshPmData ?? (await fetchPmData()); + const pmQuestionnaires = pmData?.questionnaires || []; + const firstIncomplete = pmQuestionnaires.find( + (q: any) => q.questionnaireResponseStatus !== 'completed' + ); + if (firstIncomplete) { + const returnSlug = slugFromLinkId(nextPage.linkId); + navigate(`/paperwork/${appointmentID}/custom/${firstIncomplete.id}/${returnSlug}`); + return; + } + } + } catch (err) { + console.error('Failed to check practice-managed questionnaires:', err); + } + navigate(`/paperwork/${appointmentID}/${slugFromLinkId(nextPage.linkId)}`); + } else { + navigate(`/paperwork/${appointmentID}/review`); + } } catch (e) { // todo: handle this better console.error('error patching paperwork', e); diff --git a/apps/intake/src/pages/PracticeManagedPaperwork.tsx b/apps/intake/src/pages/PracticeManagedPaperwork.tsx new file mode 100644 index 0000000000..c8bedaefba --- /dev/null +++ b/apps/intake/src/pages/PracticeManagedPaperwork.tsx @@ -0,0 +1,234 @@ +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; +import { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4b'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + buildQuestionnairePages, + formDataToResponseItem, + QuestionnaireFormPage, + responseItemsToFormValues, +} from 'ui-components'; +import { PageContainer } from '../components'; +import { useUCZambdaClient, ZambdaClient } from '../hooks/useUCZambdaClient'; + +const GET_PM_ZAMBDA = 'get-practice-managed-questionnaires'; +const SAVE_PM_ZAMBDA = 'save-practice-managed-response'; + +interface PracticeManagedQ { + id: string; + url: string; + version?: string; + title: string; + item: QuestionnaireItem[]; + questionnaireResponseId?: string; + questionnaireResponseStatus?: string; + questionnaireResponseItems?: QuestionnaireResponseItem[]; +} + +export const PracticeManagedPaperwork: FC = () => { + const params = useParams(); + const appointmentId = params.id; + const questionnaireId = params.questionnaireId; + const returnSlug = params.returnSlug || 'review'; + const navigate = useNavigate(); + const zambdaClient = useUCZambdaClient({ tokenless: false }); + + const [questionnaire, setQuestionnaire] = useState(null); + const [allPmQuestionnaires, setAllPmQuestionnaires] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [allAnswers, setAllAnswers] = useState>({}); + const [qrId, setQrId] = useState(undefined); + const [encounterId, setEncounterId] = useState(''); + const [patientId, setPatientId] = useState(''); + + const methods = useForm(); + + // Fetch practice-managed questionnaires. + // Clear state up front so route transitions (custom/ → custom/) don't briefly + // render the previous PM Q's pages indexed by the new currentPageIndex, which would + // flash "Questionnaire not found" before the new fetch resolves. + useEffect(() => { + if (!zambdaClient || !appointmentId) return; + + setLoading(true); + setQuestionnaire(null); + setQrId(undefined); + setCurrentPageIndex(0); + setAllAnswers({}); + // Also clear the RHF store: the page-change reset effect won't fire when the index is + // already 0, so without this the previous questionnaire's values survive a + // custom/ → custom/ navigation and get submitted into the new QR. + methods.reset({}); + + const fetchData = async (): Promise => { + try { + const response = await (zambdaClient as ZambdaClient).execute(GET_PM_ZAMBDA, { appointmentId }); + const data = typeof response.output === 'string' ? JSON.parse(response.output) : response.output; + const questionnaires = (data?.questionnaires || []) as PracticeManagedQ[]; + setAllPmQuestionnaires(questionnaires); + setEncounterId(data?.encounterId || ''); + setPatientId(data?.patientId || ''); + + const current = questionnaires.find((q) => q.id === questionnaireId); + if (current) { + setQuestionnaire(current); + setQrId(current.questionnaireResponseId); + // Resume an in-progress response — without seeding, page saves would + // silently overwrite the previously-entered answers. + const resumed = responseItemsToFormValues(current.questionnaireResponseItems); + if (Object.keys(resumed).length > 0) { + setAllAnswers(resumed); + methods.reset(resumed); + } + } + } catch (err) { + console.error('Failed to fetch practice-managed questionnaires:', err); + } finally { + setLoading(false); + } + }; + + void fetchData(); + // methods is stable (useForm return identity) — included for lint completeness. + }, [zambdaClient, appointmentId, questionnaireId, methods]); + + const pages = useMemo( + () => buildQuestionnairePages(questionnaire?.item || [], questionnaire?.title), + [questionnaire] + ); + const currentPage = pages[currentPageIndex]; + const isLastPage = currentPageIndex >= pages.length - 1; + + // Restore previously-entered answers on page change so Back doesn't blank the form. + const allAnswersRef = useRef(allAnswers); + allAnswersRef.current = allAnswers; + useEffect(() => { + methods.reset(allAnswersRef.current); + }, [currentPageIndex, methods]); + + const handleSubmit = useCallback( + async (data: Record) => { + if (!zambdaClient || !questionnaire || !currentPage) return; + + setSaving(true); + setSaveError(false); + try { + setAllAnswers((prev) => ({ ...prev, ...data })); + const pageItem = formDataToResponseItem(data, currentPage); + + const response = await (zambdaClient as ZambdaClient).execute(SAVE_PM_ZAMBDA, { + questionnaireResponseId: qrId, + questionnaireUrl: questionnaire.url, + questionnaireVersion: questionnaire.version, + encounterId, + patientId, + pageIndex: currentPageIndex, + answers: pageItem, + // Final page completes the QR — the intake's insertion check keys off + // questionnaireResponseStatus === 'completed' to stop re-entering this form. + complete: isLastPage, + }); + const result = typeof response.output === 'string' ? JSON.parse(response.output) : response.output; + + if (result?.questionnaireResponseId) { + setQrId(result.questionnaireResponseId); + } + + if (!isLastPage) { + setCurrentPageIndex((prev) => prev + 1); + } else { + const currentIdx = allPmQuestionnaires.findIndex((q) => q.id === questionnaireId); + const nextPmQ = allPmQuestionnaires[currentIdx + 1]; + if (nextPmQ) { + navigate(`/paperwork/${appointmentId}/custom/${nextPmQ.id}/${returnSlug}`); + } else { + navigate(`/paperwork/${appointmentId}/${returnSlug}`); + } + } + } catch (err) { + // Stay on the page and tell the patient — a silent failure here looks like a + // dead Continue button and loses their answers if they close the tab. + console.error('Failed to save practice-managed response:', err); + setSaveError(true); + } finally { + setSaving(false); + } + }, + [ + zambdaClient, + questionnaire, + currentPage, + qrId, + encounterId, + patientId, + currentPageIndex, + isLastPage, + allPmQuestionnaires, + questionnaireId, + appointmentId, + returnSlug, + navigate, + ] + ); + + const handleBack = useCallback(() => { + if (currentPageIndex > 0) { + setCurrentPageIndex((prev) => prev - 1); + } else { + const currentIdx = allPmQuestionnaires.findIndex((q) => q.id === questionnaireId); + if (currentIdx > 0) { + const prevPmQ = allPmQuestionnaires[currentIdx - 1]; + navigate(`/paperwork/${appointmentId}/custom/${prevPmQ.id}/${returnSlug}`); + } else { + navigate(-1); + } + } + }, [currentPageIndex, allPmQuestionnaires, questionnaireId, appointmentId, returnSlug, navigate]); + + if (loading) { + return ( + + + + + + ); + } + + if (!questionnaire || !currentPage) { + return ( + + + Questionnaire not found. + + + ); + } + + return ( + + {saveError && ( + setSaveError(false)}> + We couldn't save your answers. Please check your connection and press Continue again. + + )} + + + ); +}; + +export default PracticeManagedPaperwork; diff --git a/apps/intake/src/pages/StandaloneFormPage.tsx b/apps/intake/src/pages/StandaloneFormPage.tsx new file mode 100644 index 0000000000..04a71d286d --- /dev/null +++ b/apps/intake/src/pages/StandaloneFormPage.tsx @@ -0,0 +1,268 @@ +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; +import { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4b'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useParams } from 'react-router-dom'; +import { + buildQuestionnairePages, + evaluateCalculatedExpressions, + formDataToResponseItem, + QuestionnaireFormPage, + responseItemsToFormValues, +} from 'ui-components'; +import { PageContainer } from '../components'; +import { useUCZambdaClient, ZambdaClient } from '../hooks/useUCZambdaClient'; + +const GET_PM_ZAMBDA = 'get-practice-managed-questionnaires'; +const SAVE_PM_ZAMBDA = 'save-practice-managed-response'; +const FINALIZE_PM_ZAMBDA = 'finalize-practice-managed-response'; + +interface PracticeManagedQ { + id: string; + url: string; + version?: string; + title: string; + item: QuestionnaireItem[]; + questionnaireResponseId?: string; + questionnaireResponseItems?: QuestionnaireResponseItem[]; +} + +export const StandaloneFormPage: FC = () => { + // Two route shapes converge here: + // /forms/:appointmentId/:questionnaireId (visit-scoped) + // /forms/patient/:patientId/:questionnaireId (patient-scoped, no encounter) + const { appointmentId, patientId: patientIdParam, questionnaireId } = useParams(); + const zambdaClient = useUCZambdaClient({ tokenless: false }); + + const [questionnaire, setQuestionnaire] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + const [completed, setCompleted] = useState(false); + const [accessDeniedMessage, setAccessDeniedMessage] = useState(null); + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [allAnswers, setAllAnswers] = useState>({}); + const [qrId, setQrId] = useState(undefined); + const [encounterId, setEncounterId] = useState(''); + const [patientId, setPatientId] = useState(''); + + const methods = useForm(); + + useEffect(() => { + if (!zambdaClient || (!appointmentId && !patientIdParam)) return; + + const fetchData = async (): Promise => { + try { + const response = await (zambdaClient as ZambdaClient).execute(GET_PM_ZAMBDA, { + ...(appointmentId ? { appointmentId } : { patientId: patientIdParam }), + questionnaireId, + }); + const data = typeof response.output === 'string' ? JSON.parse(response.output) : response.output; + + if (data?.error === 'ACCESS_DENIED') { + setAccessDeniedMessage(data.message); + return; + } + + const questionnaires = (data?.questionnaires || []) as PracticeManagedQ[]; + setEncounterId(data?.encounterId || ''); + setPatientId(data?.patientId || ''); + + const current = questionnaires.find((q) => q.id === questionnaireId); + if (current) { + setQuestionnaire(current); + setQrId(current.questionnaireResponseId); + // Resume an in-progress response — without seeding, page saves would + // silently overwrite the previously-entered answers. + const resumed = responseItemsToFormValues(current.questionnaireResponseItems); + if (Object.keys(resumed).length > 0) { + setAllAnswers(resumed); + methods.reset(resumed); + } + } + } catch (err: any) { + const errBody = err?.cause || err; + if (errBody?.error === 'ACCESS_DENIED') { + setAccessDeniedMessage(errBody.message); + } else { + console.error('Failed to fetch questionnaire:', err); + } + } finally { + setLoading(false); + } + }; + + void fetchData(); + }, [zambdaClient, appointmentId, patientIdParam, questionnaireId, methods]); + + const pages = useMemo( + () => buildQuestionnairePages(questionnaire?.item || [], questionnaire?.title), + [questionnaire] + ); + const currentPage = pages[currentPageIndex]; + const isLastPage = currentPageIndex >= pages.length - 1; + + // Restore previously-entered answers on page change so Back doesn't blank the form. + const allAnswersRef = useRef(allAnswers); + allAnswersRef.current = allAnswers; + useEffect(() => { + methods.reset(allAnswersRef.current); + }, [currentPageIndex, methods]); + + const handleSubmit = useCallback( + async (data: Record) => { + if (!zambdaClient || !questionnaire || !currentPage) return; + + setSaving(true); + setSaveError(false); + try { + const updatedAnswers = { ...allAnswers, ...data }; + setAllAnswers(updatedAnswers); + + const pageItem = formDataToResponseItem(data, currentPage); + + const response = await (zambdaClient as ZambdaClient).execute(SAVE_PM_ZAMBDA, { + questionnaireResponseId: qrId, + questionnaireUrl: questionnaire.url, + questionnaireVersion: questionnaire.version, + encounterId, + patientId, + pageIndex: currentPageIndex, + answers: pageItem, + }); + const result = typeof response.output === 'string' ? JSON.parse(response.output) : response.output; + + const currentQrId = result?.questionnaireResponseId || qrId; + if (currentQrId) { + setQrId(currentQrId); + } + + if (!isLastPage) { + setCurrentPageIndex((prev) => prev + 1); + } else { + // Evaluate calculated expressions and save computed values + const qItems = questionnaire.item || []; + const computedValues = evaluateCalculatedExpressions(qItems, updatedAnswers); + const computedEntries = Object.entries(computedValues).filter(([, v]) => v !== undefined); + + if (computedEntries.length > 0) { + const computedQrItems = computedEntries.map(([linkId, value]) => ({ + linkId, + answer: [ + typeof value === 'boolean' + ? { valueBoolean: value } + : typeof value === 'number' + ? { valueDecimal: value } + : { valueString: String(value) }, + ], + })); + + await (zambdaClient as ZambdaClient).execute(SAVE_PM_ZAMBDA, { + questionnaireResponseId: currentQrId, + questionnaireUrl: questionnaire.url, + questionnaireVersion: questionnaire.version, + encounterId, + patientId, + pageIndex: currentPageIndex + 1, + answers: { linkId: 'results', item: computedQrItems }, + }); + } + + // Finalize: mark complete, render PDF, and file into the patient's Paperwork folder. + // A finalize failure propagates to the outer catch — the patient must NOT see + // "Form Submitted" when the form never completed; Submit can be pressed again. + if (currentQrId) { + await (zambdaClient as ZambdaClient).execute(FINALIZE_PM_ZAMBDA, { + questionnaireResponseId: currentQrId, + }); + } + + setCompleted(true); + } + } catch (err) { + // Stay on the page and tell the patient — a silent failure looks like a dead + // Submit button and risks the patient closing the tab thinking they're done. + console.error('Failed to save response:', err); + setSaveError(true); + } finally { + setSaving(false); + } + }, + [zambdaClient, questionnaire, currentPage, qrId, encounterId, patientId, currentPageIndex, isLastPage, allAnswers] + ); + + if (loading) { + return ( + + + + + + ); + } + + if (accessDeniedMessage) { + return ( + + + + Access Denied + + + {accessDeniedMessage} + + + + ); + } + + if (!questionnaire || !currentPage) { + return ( + + + Form not found. + + + ); + } + + if (completed) { + return ( + + + + + Form Submitted + + + Thank you for completing {questionnaire.title}. You may close this page. + + + + ); + } + + return ( + + {saveError && ( + setSaveError(false)}> + We couldn't save your answers. Please check your connection and try again. + + )} + 0 ? () => setCurrentPageIndex((prev) => prev - 1) : undefined} + isLastPage={isLastPage} + saving={saving} + submitLabel={isLastPage ? 'Submit' : 'Continue'} + /> + + ); +}; + +export default StandaloneFormPage; diff --git a/config/oystehr-core/zambdas.json b/config/oystehr-core/zambdas.json index 4a4fe04f1b..006c524422 100644 --- a/config/oystehr-core/zambdas.json +++ b/config/oystehr-core/zambdas.json @@ -74,6 +74,13 @@ "src": "src/ehr/ai-assisted-encounters-report/index", "zip": ".dist/zips/ai-assisted-encounters-report.zip" }, + "SEND-PATIENT-FORM": { + "name": "send-patient-form", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/send-patient-form/index", + "zip": ".dist/zips/send-patient-form.zip" + }, "DAILY-PAYMENTS-REPORT": { "name": "daily-payments-report", "type": "http_auth", @@ -1461,6 +1468,62 @@ "src": "src/ehr/admin-get-template-detail/index", "zip": ".dist/zips/admin-get-template-detail.zip" }, + "SAVE-PRACTICE-MANAGED-RESPONSE": { + "name": "save-practice-managed-response", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/patient/paperwork/save-practice-managed-response/index", + "zip": ".dist/zips/save-practice-managed-response.zip" + }, + "GET-PRACTICE-MANAGED-QUESTIONNAIRES": { + "name": "get-practice-managed-questionnaires", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/patient/paperwork/get-practice-managed-questionnaires/index", + "zip": ".dist/zips/get-practice-managed-questionnaires.zip" + }, + "FINALIZE-PRACTICE-MANAGED-RESPONSE": { + "name": "finalize-practice-managed-response", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/patient/paperwork/finalize-practice-managed-response/index", + "zip": ".dist/zips/finalize-practice-managed-response.zip" + }, + "ADMIN-LIST-QUESTIONNAIRES": { + "name": "admin-list-questionnaires", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-list-questionnaires/index", + "zip": ".dist/zips/admin-list-questionnaires.zip" + }, + "ADMIN-GET-QUESTIONNAIRE": { + "name": "admin-get-questionnaire", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-get-questionnaire/index", + "zip": ".dist/zips/admin-get-questionnaire.zip" + }, + "ADMIN-CREATE-QUESTIONNAIRE": { + "name": "admin-create-questionnaire", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-create-questionnaire/index", + "zip": ".dist/zips/admin-create-questionnaire.zip" + }, + "ADMIN-UPDATE-QUESTIONNAIRE": { + "name": "admin-update-questionnaire", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-update-questionnaire/index", + "zip": ".dist/zips/admin-update-questionnaire.zip" + }, + "ADMIN-DELETE-QUESTIONNAIRE": { + "name": "admin-delete-questionnaire", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-delete-questionnaire/index", + "zip": ".dist/zips/admin-delete-questionnaire.zip" + }, "DELETE-PATIENT-DOCUMENT": { "name": "delete-patient-document", "type": "http_auth", @@ -2789,4 +2852,4 @@ } } } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index c75ea5f615..b70f95166b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30649,6 +30649,10 @@ "engines": { "node": ">=22.0.0", "npm": ">=10.0.0" + }, + "peerDependencies": { + "@types/fhir": ">=0.0.30", + "react-hook-form": ">=7.0.0" } }, "packages/utils": { diff --git a/packages/ui-components/lib/components/QuestionnaireFormFields.tsx b/packages/ui-components/lib/components/QuestionnaireFormFields.tsx new file mode 100644 index 0000000000..c60dcfa033 --- /dev/null +++ b/packages/ui-components/lib/components/QuestionnaireFormFields.tsx @@ -0,0 +1,872 @@ +import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; +import { + Box, + Button, + Checkbox, + CircularProgress, + FormControlLabel, + FormHelperText, + Grid, + MenuItem, + Radio, + RadioGroup, + Select, + TextField, + Typography, +} from '@mui/material'; +import { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4b'; +import { FC, useMemo } from 'react'; +import { Controller, FormProvider, UseFormReturn, useWatch } from 'react-hook-form'; + +// ── FHIR extension helpers ────────────────────────────────────────────────── + +function getExtension(extensions: any[] | undefined, url: string): any | undefined { + return extensions?.find((e: any) => e.url === url); +} + +// Evaluate an item's `enableWhen` against the current (flat) form values. Returns true when the +// item should be shown. Supports the operators the admin builder emits (=, !=, >, <, >=, <=, +// exists) and enableBehavior (all|any). Values here are the react-hook-form field values keyed by +// linkId — for `choice` items that is the selected option's code/valueString. +function evalItemEnableWhen(item: QuestionnaireItem, values: Record): boolean { + const enableWhen = (item as any).enableWhen as + | Array<{ + question: string; + operator: 'exists' | '=' | '!=' | '>' | '<' | '>=' | '<='; + answerBoolean?: boolean; + answerString?: string; + answerInteger?: number; + answerDecimal?: number; + answerCoding?: { code?: string }; + }> + | undefined; + if (!enableWhen || enableWhen.length === 0) return true; + const behavior = ((item as any).enableBehavior as 'all' | 'any' | undefined) ?? 'all'; + + const evalOne = (ew: (typeof enableWhen)[number]): boolean => { + const actual = values[ew.question]; + if (ew.operator === 'exists') { + const present = actual !== undefined && actual !== null && actual !== ''; + return ew.answerBoolean === false ? !present : present; + } + // Determine the expected value (whichever answer[x] is provided). + const expected = + ew.answerString ?? + ew.answerCoding?.code ?? + (ew.answerBoolean !== undefined ? ew.answerBoolean : undefined) ?? + ew.answerInteger ?? + ew.answerDecimal; + if (expected === undefined) return true; + + if (ew.operator === '=') return String(actual) === String(expected); + if (ew.operator === '!=') return String(actual) !== String(expected); + + // Numeric comparisons + const a = Number(actual); + const b = Number(expected); + if (Number.isNaN(a) || Number.isNaN(b)) return false; + if (ew.operator === '>') return a > b; + if (ew.operator === '<') return a < b; + if (ew.operator === '>=') return a >= b; + if (ew.operator === '<=') return a <= b; + return true; + }; + + return behavior === 'any' ? enableWhen.some(evalOne) : enableWhen.every(evalOne); +} + +export function isHiddenItem(item: QuestionnaireItem): boolean { + const ext = getExtension(item.extension, 'http://hl7.org/fhir/StructureDefinition/questionnaire-hidden'); + return ext?.valueBoolean === true; +} + +export function getCalculatedExpression(item: QuestionnaireItem): string | undefined { + const ext = getExtension( + item.extension, + 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression' + ); + if (ext?.valueExpression?.language === 'text/javascript') { + return ext.valueExpression.expression; + } + return undefined; +} + +/** + * Evaluates all calculated expressions in a questionnaire against the current answers. + * Returns a map of linkId → computed value for items with JavaScript calculated expressions. + * + * Choice answers come back from the form as strings (the option's valueCoding.code). + * For scoring expressions (sums, comparisons), numeric strings must be coerced to + * numbers — otherwise reduce((a,b)=>a+b) string-concatenates. + */ +export function evaluateCalculatedExpressions( + items: QuestionnaireItem[], + answers: Record +): Record { + // Coerce numeric-string answers to numbers. Non-numeric strings (e.g. "female") + // pass through unchanged so equality checks still work. + const normalizedAnswers: Record = {}; + for (const [k, v] of Object.entries(answers)) { + if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) { + normalizedAnswers[k] = Number(v); + } else { + normalizedAnswers[k] = v; + } + } + + const results: Record = {}; + const allItems: QuestionnaireItem[] = []; + + // Flatten all items (including nested groups) + const walk = (list: QuestionnaireItem[]): void => { + for (const item of list) { + allItems.push(item); + if (item.item) walk(item.item); + } + }; + walk(items); + + // First pass: evaluate expressions that depend only on answers + // Second pass: evaluate expressions that depend on other computed values + // Two passes handles one level of dependency (computed item referencing another computed item) + for (let pass = 0; pass < 2; pass++) { + for (const item of allItems) { + const expression = getCalculatedExpression(item); + if (!expression) continue; + + try { + // Build a combined context: normalized answers + previously computed results + const context = { ...normalizedAnswers, ...results }; + const fn = new Function('answers', `with(answers) { return (${expression}); }`); + results[item.linkId] = fn(context); + } catch (e) { + console.warn(`Failed to evaluate expression for ${item.linkId}:`, e); + } + } + } + + return results; +} + +export function getItemControl(item: QuestionnaireItem): string | undefined { + const ext = getExtension(item.extension, 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl'); + return ext?.valueCodeableConcept?.coding?.[0]?.code; +} + +export function getOptionWeight(option: any): number | undefined { + const ext = getExtension(option.extension, 'http://hl7.org/fhir/StructureDefinition/itemWeight'); + return ext?.valueDecimal; +} + +export function getOptionPrefix(option: any): string | undefined { + const ext = getExtension(option.extension, 'http://hl7.org/fhir/StructureDefinition/questionnaire-optionPrefix'); + return ext?.valueString; +} + +/** + * Prefix safe to show patients. Purely numeric prefixes are scoring artifacts on standard + * instruments (e.g. "0" on "Not at all") and must not display — patients must never see + * numeric scores on option labels. + */ +export function getDisplayOptionPrefix(option: any): string | undefined { + const prefix = getOptionPrefix(option); + if (prefix === undefined) return undefined; + return /^\d+(\.\d+)?$/.test(String(prefix).trim()) ? undefined : prefix; +} + +export function getOptionDisplay(option: any): string { + if (option.valueCoding?.display) return option.valueCoding.display; + if (option.valueString) return option.valueString; + return option.valueCoding?.code || 'Option'; +} + +export function isScoreItem(item: QuestionnaireItem): boolean { + const unitExt = getExtension(item.extension, 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit'); + return unitExt?.valueCoding?.code === '{score}'; +} + +// ── Styling constants ─────────────────────────────────────────────────────── + +const COLORS = { + primaryMain: '#0F347C', + secondaryMain: '#2169F5', + textPrimary: '#212130', + textSecondary: '#4F4F4F', + selectedBg: '#E2F0FF', + border: '#E0E0E0', + cardBg: '#F7F8F9', +}; + +const inputSx = { '& .MuiOutlinedInput-root': { borderRadius: '8px' } }; + +// ── QuestionnaireFormField ────────────────────────────────────────────────── + +export interface QuestionnaireFormFieldProps { + item: QuestionnaireItem; + control: any; +} + +export const QuestionnaireFormField: FC = ({ item, control }) => { + const label = ( + + {item.text} + {item.required && *} + + ); + // Enforced at submit by react-hook-form; the asterisk alone validates nothing. + // (Booleans are exempt: a required yes/no checkbox can't express an answered "no".) + const requiredRule = item.required ? { required: 'This field is required' } : undefined; + + switch (item.type) { + case 'boolean': + return ( + ( + } label={item.text} /> + )} + /> + ); + + case 'choice': { + const itemControl = getItemControl(item); + const useDropdown = itemControl === 'drop-down' || (item.answerOption?.length || 0) > 6; + const options = item.answerOption || []; + + if (useDropdown) { + return ( + + {label} + ( + <> + + {fieldState.error && {fieldState.error.message}} + + )} + /> + + ); + } + + return ( + + {label} + ( + + {options.map((opt: any, i: number) => { + const prefix = getDisplayOptionPrefix(opt); + const display = getOptionDisplay(opt); + const value = opt.valueCoding?.code || opt.valueString || ''; + const optLabel = `${prefix !== undefined ? `${prefix}. ` : ''}${display}`; + const isSelected = field.value === value; + return ( + field.onChange(value)} + > + } + checkedIcon={} + sx={{ p: 0.5, mr: 1 }} + value={value} + /> + + {optLabel} + + + ); + })} + {fieldState.error && {fieldState.error.message}} + + )} + /> + + ); + } + + case 'text': + return ( + + {label} + ( + + )} + /> + + ); + + case 'date': + return ( + + {label} + ( + + )} + /> + + ); + + case 'integer': + return ( + + {label} + ( + + )} + /> + + ); + + case 'decimal': + if (isScoreItem(item)) { + // Score items never render to the patient (no visible numeric scores). Their value + // is computed via calculatedExpressions and written to the QR on the final save. + return null; + } + return ( + + {label} + ( + + )} + /> + + ); + + case 'display': + return ( + + {item.text} + + ); + + case 'group': + return ( + + + {item.text} + + + {(item.item || []).map((child) => ( + + + + ))} + + + ); + + default: + return ( + + {label} + ( + + )} + /> + + ); + } +}; + +// ── Helper: build pages from questionnaire items ──────────────────────────── + +// Recursively true when a group/page has any descendant that would render to +// the patient. Used to skip pages that are pure scoring containers (e.g. a +// final `results` group whose items are all hidden computed expressions). +function hasVisibleContent(item: QuestionnaireItem): boolean { + if (isHiddenItem(item)) return false; + if (item.type === 'group') return (item.item || []).some(hasVisibleContent); + // display items only render if they have text + if (item.type === 'display') return !!item.text; + return true; +} + +export function buildQuestionnairePages(items: QuestionnaireItem[], title?: string): QuestionnaireItem[] { + const groups = items.filter((item) => item.type === 'group' && hasVisibleContent(item)); + if (groups.length > 0) return groups; + const visibleNonGroup = items.filter((item) => item.type !== 'group' && hasVisibleContent(item)); + if (visibleNonGroup.length > 0) { + return [{ linkId: '_all', type: 'group' as const, text: title, item: visibleNonGroup } as QuestionnaireItem]; + } + return []; +} + +// ── Helper: convert form data to QuestionnaireResponseItem ────────────────── + +/** + * Flatten a saved QuestionnaireResponse item tree back into form values keyed by linkId, + * so reopening an in-progress form resumes instead of silently overwriting prior pages. + */ +export function responseItemsToFormValues(items: QuestionnaireResponseItem[] | undefined): Record { + const values: Record = {}; + const walk = (list: QuestionnaireResponseItem[]): void => { + for (const it of list) { + const a = it.answer?.[0]; + if (a !== undefined) { + if (a.valueCoding?.code !== undefined) values[it.linkId] = a.valueCoding.code; + else if (a.valueBoolean !== undefined) values[it.linkId] = a.valueBoolean; + else if (a.valueDecimal !== undefined) values[it.linkId] = String(a.valueDecimal); + else if (a.valueInteger !== undefined) values[it.linkId] = String(a.valueInteger); + else if (a.valueString !== undefined) values[it.linkId] = a.valueString; + } + if (it.item) walk(it.item); + } + }; + walk(items ?? []); + return values; +} + +/** Recursively index every descendant item of a page by linkId (groups can nest). */ +function collectPageItems( + items: QuestionnaireItem[], + map: Map = new Map() +): Map { + for (const item of items) { + map.set(item.linkId, item); + if (item.item) collectPageItems(item.item, map); + } + return map; +} + +export function formDataToResponseItem(data: Record, page: QuestionnaireItem): QuestionnaireResponseItem { + // The RHF store holds values for EVERY page the patient has visited (values are + // deliberately retained across pages so Back/Continue doesn't lose answers). Only + // entries whose linkId belongs to THIS page may be written into this page's response + // item — otherwise answers duplicate across page items and, because the source item + // lookup fails for foreign linkIds, coded answers degrade to raw valueString codes. + const itemsByLinkId = collectPageItems(page.item || []); + return { + linkId: page.linkId, + item: Object.entries(data) + .filter(([linkId, value]) => { + const sourceItem = itemsByLinkId.get(linkId); + if (!sourceItem || value === undefined || value === '') return false; + // FHIR: answers to enableWhen-disabled items must not appear in the response — + // a patient may answer a conditional question, then flip the controlling answer. + return evalItemEnableWhen(sourceItem, data); + }) + .map(([linkId, value]) => { + const sourceItem = itemsByLinkId.get(linkId); + if (sourceItem?.type === 'choice' && typeof value === 'string') { + const matchingOpt = (sourceItem.answerOption || []).find((opt: any) => opt.valueCoding?.code === value); + if (matchingOpt?.valueCoding) { + return { linkId, answer: [{ valueCoding: matchingOpt.valueCoding }] }; + } + } + return { + linkId, + answer: [typeof value === 'boolean' ? { valueBoolean: value } : { valueString: String(value) }], + }; + }), + }; +} + +// ── QuestionnaireFormPage: renders a single page of items with nav ─────────── + +export interface QuestionnaireFormPageProps { + page: QuestionnaireItem; + title?: string; + subtitle?: string; + methods: UseFormReturn; + onSubmit: (data: Record) => void; + onBack?: () => void; + isLastPage?: boolean; + saving?: boolean; + submitLabel?: string; +} + +export const QuestionnaireFormPage: FC = ({ + page, + title, + subtitle, + methods, + onSubmit, + onBack, + isLastPage, + saving, + submitLabel, +}) => { + // Watch all answers on this page so enableWhen conditions re-evaluate live as the user fills it + // in. (Subscribing to the whole form is fine here — pages are small.) + const watchedValues = useWatch({ control: methods.control }); + + const items = (page.item || []).filter( + (item) => + !isHiddenItem(item) && + (item.type !== 'display' || item.text) && + evalItemEnableWhen(item, (watchedValues as Record) || {}) + ); + + return ( + +
+ {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} + + + {items.map((item) => ( + + + + ))} + + + + {onBack ? ( + + ) : ( + + )} + + + +
+ ); +}; + +// ── QuestionnaireResponseViewer: read-only display of completed responses ──── + +export interface QuestionnaireResponseViewerProps { + /** The questionnaire definition (with items, answerOptions, extensions) */ + questionnaire: { item?: QuestionnaireItem[]; title?: string }; + /** The completed response items */ + responseItems: QuestionnaireResponseItem[]; +} + +export const QuestionnaireResponseViewer: FC = ({ questionnaire, responseItems }) => { + // Build a flat map of linkId → answer from the response + const answerMap = useMemo(() => { + const map = new Map(); + const walkItems = (items: QuestionnaireResponseItem[]): void => { + for (const item of items) { + if (item.answer && item.answer.length > 0) { + map.set(item.linkId, item.answer[0]); + } + if (item.item) walkItems(item.item); + } + }; + walkItems(responseItems); + return map; + }, [responseItems]); + + // Flatten questionnaire items (handle both grouped and flat) + const allItems = useMemo(() => { + const items = questionnaire.item || []; + const flat: QuestionnaireItem[] = []; + const walk = (list: QuestionnaireItem[]): void => { + for (const item of list) { + if (item.type === 'group' && item.item) { + walk(item.item); + } else { + flat.push(item); + } + } + }; + walk(items); + return flat; + }, [questionnaire.item]); + + // Build raw answers for expression evaluation (linkId → numeric code) + const rawAnswers = useMemo(() => { + const raw: Record = {}; + answerMap.forEach((answer, linkId) => { + if (answer.valueCoding?.code) raw[linkId] = parseInt(answer.valueCoding.code, 10) || answer.valueCoding.code; + else if (answer.valueString) raw[linkId] = answer.valueString; + else if (answer.valueBoolean !== undefined) raw[linkId] = answer.valueBoolean; + else if (answer.valueInteger !== undefined) raw[linkId] = answer.valueInteger; + else if (answer.valueDecimal !== undefined) raw[linkId] = answer.valueDecimal; + }); + return raw; + }, [answerMap]); + + // Evaluate calculated expressions for computed items + const computedValues = useMemo( + () => evaluateCalculatedExpressions(questionnaire.item || [], rawAnswers), + [questionnaire.item, rawAnswers] + ); + + // Calculate total score if there are scored items (sum-based like GAD-7) + const totalScore = useMemo(() => { + let hasScoring = false; + let score = 0; + for (const item of allItems) { + if (isScoreItem(item)) { + hasScoring = true; + continue; + } + if (item.type !== 'choice' || !item.answerOption) continue; + const hasWeights = item.answerOption.some((o: any) => getOptionWeight(o) !== undefined); + if (!hasWeights) continue; + hasScoring = true; + + const answer = answerMap.get(item.linkId); + if (!answer) continue; + const selectedCode = answer.valueCoding?.code || answer.valueString; + if (!selectedCode) continue; + + const matchingOpt = item.answerOption.find((o: any) => (o.valueCoding?.code || o.valueString) === selectedCode); + if (matchingOpt) { + const weight = getOptionWeight(matchingOpt); + if (weight !== undefined) score += weight; + } + } + return hasScoring ? score : null; + }, [allItems, answerMap]); + + // Separate visible answer items from computed items. + // Rationale items (linkId ending in -rationale) are rendered inline beneath their parent result. + const answerItems = allItems.filter((item) => !isScoreItem(item) && !isHiddenItem(item)); + const computedItems = allItems.filter( + (item) => isHiddenItem(item) && getCalculatedExpression(item) && !item.linkId.endsWith('-rationale') + ); + const rationaleFor = (linkId: string): string | undefined => { + const val = computedValues[`${linkId}-rationale`]; + return typeof val === 'string' && val.length > 0 ? val : undefined; + }; + + return ( + + {answerItems.map((item) => { + const answer = answerMap.get(item.linkId); + if (!answer) return null; + + let displayValue = ''; + if (answer.valueCoding?.display) { + displayValue = answer.valueCoding.display; + } else if (answer.valueString) { + displayValue = answer.valueString; + } else if (answer.valueBoolean !== undefined) { + displayValue = answer.valueBoolean ? 'Yes' : 'No'; + } else if (answer.valueInteger !== undefined) { + displayValue = String(answer.valueInteger); + } else if (answer.valueDecimal !== undefined) { + displayValue = String(answer.valueDecimal); + } else if (answer.valueDate) { + displayValue = answer.valueDate; + } + + if (!displayValue) return null; + + return ( + + + + {item.text}: + {' '} + {displayValue} + + + ); + })} + {totalScore !== null && ( + + + Total Score: {totalScore} + + + )} + {computedItems.length > 0 && ( + + + Screening Results + + {computedItems.map((item) => { + const value = computedValues[item.linkId]; + if (value === undefined) return null; + const displayValue = typeof value === 'boolean' ? (value ? 'Positive' : 'Negative') : String(value); + const rationale = rationaleFor(item.linkId); + return ( + + + + {item.text}: + {' '} + + {displayValue} + + + {rationale && ( + + {rationale} + + )} + + ); + })} + + )} + + ); +}; diff --git a/packages/ui-components/lib/components/index.ts b/packages/ui-components/lib/components/index.ts index b4a0017362..4b05f4f154 100644 --- a/packages/ui-components/lib/components/index.ts +++ b/packages/ui-components/lib/components/index.ts @@ -2,3 +2,4 @@ export * from './AddCreditCardForm'; export * from './CreditCardBrandIcon'; export * from './PharmacyDisplay'; export * from './PharmacySearch'; +export * from './QuestionnaireFormFields'; diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index 2af313a43e..05edb53b0e 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -21,5 +21,9 @@ "heic-to": "^1.5.2", "tsconfig": "*", "utils": "*" + }, + "peerDependencies": { + "@types/fhir": ">=0.0.30", + "react-hook-form": ">=7.0.0" } } diff --git a/packages/utils/lib/fhir/constants.ts b/packages/utils/lib/fhir/constants.ts index d4abf3c3a3..633b827792 100644 --- a/packages/utils/lib/fhir/constants.ts +++ b/packages/utils/lib/fhir/constants.ts @@ -434,6 +434,25 @@ export const SERVICE_CATEGORY_SYSTEM = ottehrCodeSystemUrl('service-category'); /** Extension URL for the JSON-blob runtime config on service-category resources. */ export const SERVICE_CATEGORY_CONFIG_EXTENSION_URL = ottehrExtensionUrl('service-category-config'); +// ── Practice Paperwork Flows (OTR-2309) ───────────────────────────────────── +// A "paperwork flow" is a reusable pre-visit paperwork definition = a base intake +// (standard full intake, resolved by visit mode, or consent-only lite) + an ordered +// set of practice-managed form Questionnaires. Stored as a FHIR List; ServiceCategories +// reference one by id (in their config blob), and booked Appointments are stamped with +// the resolved flow id so the intake renderer pulls that flow's forms. + +/** meta.tag identifying a Questionnaire as practice-managed (admin-authored custom form). */ +export const PRACTICE_MANAGED_QUESTIONNAIRE_TAG = { + system: ottehrCodeSystemUrl('questionnaire-type'), + code: 'practice-managed', +}; + +/** meta.tag identifying a QuestionnaireResponse to a practice-managed form. */ +export const PRACTICE_MANAGED_QR_TAG = { + system: ottehrCodeSystemUrl('questionnaire-response-type'), + code: 'practice-managed', +}; + // ── Service-category characteristic systems (one per dimension) ───────────── /** Service-mode characteristic for a service-category HealthcareService. Codes match the ServiceMode enum. */ diff --git a/packages/utils/lib/fhir/questionnaires.ts b/packages/utils/lib/fhir/questionnaires.ts index 4926d3d3e8..e1ae45fdd0 100644 --- a/packages/utils/lib/fhir/questionnaires.ts +++ b/packages/utils/lib/fhir/questionnaires.ts @@ -4,6 +4,7 @@ import inPersonIntakeQuestionnaireArchive from '../../../../config/oystehr/in-pe import virtualIntakeQuestionnaireArchive from '../../../../config/oystehr/virtual-intake-questionnaire-archive.json' assert { type: 'json' }; import { IN_PERSON_INTAKE_PAPERWORK_QUESTIONNAIRE, + LITE_INTAKE_PAPERWORK_QUESTIONNAIRE, PATIENT_RECORD_QUESTIONNAIRE, VIRTUAL_INTAKE_PAPERWORK_QUESTIONNAIRE, } from '../ottehr-config'; @@ -79,8 +80,10 @@ export const selectIntakeQuestionnaireResponse = (resources: FhirResource[]): Qu if (!questionnaireUrl) { return false; } - return [IN_PERSON_INTAKE_PAPERWORK_QUESTIONNAIRE(), VIRTUAL_INTAKE_PAPERWORK_QUESTIONNAIRE()].some( - (questionnaire: Questionnaire) => questionnaireUrl.startsWith(questionnaire.url!) - ); + return [ + IN_PERSON_INTAKE_PAPERWORK_QUESTIONNAIRE(), + VIRTUAL_INTAKE_PAPERWORK_QUESTIONNAIRE(), + LITE_INTAKE_PAPERWORK_QUESTIONNAIRE(), + ].some((questionnaire: Questionnaire) => questionnaireUrl.startsWith(questionnaire.url!)); }) as QuestionnaireResponse | undefined; }; diff --git a/packages/utils/lib/helpers/index.ts b/packages/utils/lib/helpers/index.ts index 4ac3ccd21d..27ec964867 100644 --- a/packages/utils/lib/helpers/index.ts +++ b/packages/utils/lib/helpers/index.ts @@ -5,6 +5,7 @@ export * from './deploy'; export * from './helpers'; export * from './in-house-labs'; export * from './parseCommaSeparatedTags'; +export * from './slugify'; export * from './labs'; export * from './medications'; export * from './operations'; diff --git a/packages/utils/lib/helpers/paperwork/paperwork.ts b/packages/utils/lib/helpers/paperwork/paperwork.ts index 0b85dcf04d..e2cfacab4e 100644 --- a/packages/utils/lib/helpers/paperwork/paperwork.ts +++ b/packages/utils/lib/helpers/paperwork/paperwork.ts @@ -810,7 +810,8 @@ export function isNonPaperworkQuestionnaireResponse(reso return ( resource.resourceType === 'QuestionnaireResponse' && !resource.questionnaire?.includes('https://ottehr.com/FHIR/Questionnaire/intake-paperwork-inperson') && - !resource.questionnaire?.includes('https://ottehr.com/FHIR/Questionnaire/intake-paperwork-virtual') + !resource.questionnaire?.includes('https://ottehr.com/FHIR/Questionnaire/intake-paperwork-virtual') && + !resource.questionnaire?.includes('https://ottehr.com/FHIR/Questionnaire/intake-paperwork-consent-only') ); } diff --git a/packages/utils/lib/helpers/slugify.ts b/packages/utils/lib/helpers/slugify.ts new file mode 100644 index 0000000000..486c2f15cd --- /dev/null +++ b/packages/utils/lib/helpers/slugify.ts @@ -0,0 +1,16 @@ +/** + * Derive a stable kebab-case slug from a display name: lowercase, runs of + * non-alphanumerics collapse to single dashes, no leading/trailing dashes. + * Used for practice-managed questionnaire canonical names (and other admin-authored slugs). + */ +export function slugify(name: string, options: { maxLength?: number } = {}): string { + let slug = name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (options.maxLength !== undefined && slug.length > options.maxLength) { + slug = slug.slice(0, options.maxLength).replace(/-+$/g, ''); + } + return slug; +} diff --git a/packages/utils/lib/ottehr-config/index.ts b/packages/utils/lib/ottehr-config/index.ts index 43c282539e..03300d1ed3 100644 --- a/packages/utils/lib/ottehr-config/index.ts +++ b/packages/utils/lib/ottehr-config/index.ts @@ -11,6 +11,7 @@ export * from './forms'; export * from './review-of-systems'; export * from './intake-paperwork'; export * from './intake-paperwork-virtual'; +export * from './intake-paperwork-lite'; export * from './legal'; export * from './medical-history'; export * from './patient-record'; diff --git a/packages/utils/lib/ottehr-config/intake-paperwork-lite/index.ts b/packages/utils/lib/ottehr-config/intake-paperwork-lite/index.ts new file mode 100644 index 0000000000..8ef0a0954a --- /dev/null +++ b/packages/utils/lib/ottehr-config/intake-paperwork-lite/index.ts @@ -0,0 +1,206 @@ +// Lite intake paperwork — a stripped-down pre-visit flow that includes only: +// 1. A brief welcome page (single display item) — this page exists ONLY so the +// paperwork renderer's practice-managed-Q injection logic has a non-consent +// page to land on; the renderer triggers the injection check when the NEXT +// page is a finalization page (consent / medical-history), so a Questionnaire +// with consent as the first/only page would never trigger the check. +// 2. The standard consent-forms page (same `consent-forms-page` linkId as the +// full intake flows, so the existing consent harvest + UI logic apply +// verbatim — including the `pageHarvestStrategy['consent-forms-page']` map). +// +// When a practice tags a practice-managed Questionnaire for this flow (via the +// "Present with" admin picker), it's injected between the welcome page and the +// consent page so the patient sees: Welcome → Practice-managed Q(s) → Consent. +// +// Used when the practice schedules an appointment with paperwork subtype +// 'consent-form-only'. The appointment scaffolds its QuestionnaireResponse against +// LITE_INTAKE_PAPERWORK_CANONICAL instead of the full in-person/virtual canonical. + +import { + type PaperworkConfig, + PaperworkConfigSchema, + PaperworkFormFields, + type QuestionnaireBase, + type QuestionnaireConfigType, + type ResolvedConsentFormConfig, + type ValueSetsConfig, +} from 'config-types'; +import { Questionnaire } from 'fhir/r4b'; +import { mergeAndFreezeConfigObjects } from '../../config-helpers/helpers'; +import { buildConsentFormCheckboxItems } from '../../config-helpers/intake-paperwork'; +import { createQuestionnaireFromConfig } from '../../config-helpers/shared-questionnaire'; +import { getConsentFormsForLocation } from '../consent-forms'; +import { VALUE_SETS } from '../value-sets'; + +export const LITE_INTAKE_PAPERWORK_URL = 'https://ottehr.com/FHIR/Questionnaire/intake-paperwork-consent-only'; +export const LITE_INTAKE_PAPERWORK_VERSION = '1.0.0'; +export const LITE_INTAKE_PAPERWORK_CANONICAL = { + url: LITE_INTAKE_PAPERWORK_URL, + version: LITE_INTAKE_PAPERWORK_VERSION, +} as const; + +const hiddenFormSections: string[] = []; + +const questionnaireBaseDefaults = { + resourceType: 'Questionnaire', + url: LITE_INTAKE_PAPERWORK_URL, + version: LITE_INTAKE_PAPERWORK_VERSION, + name: 'intake-paperwork-consent-only', + title: 'Consent form only', + status: 'active', +} as const satisfies QuestionnaireBase; + +// FormFields builder takes valueSets the same way the full intake configs do, even +// though this minimal flow doesn't use them — keeps the shape uniform so anything +// that switches on PaperworkConfig.FormFields treats this flow the same way. +function buildFormFields(_valueSets: ValueSetsConfig): PaperworkFormFields { + return { + welcome: { + linkId: 'provider-questionnaire-intro-page', + title: 'Provider questionnaire', + items: { + intro: { + key: 'provider-questionnaire-intro', + text: 'Your provider has asked you to complete a short questionnaire. Click Continue to begin.', + type: 'display', + }, + }, + hiddenFields: [], + requiredFields: [], + }, + consentForms: { + linkId: 'consent-forms-page', + title: 'Complete consent forms', + reviewText: 'Consent forms', + triggers: [ + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'completed', + }, + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'amended', + }, + ], + enableBehavior: 'all', + items: { + checkboxGroup: { + key: 'consent-forms-checkbox-group', + type: 'group', + // items + requiredFields populated dynamically below from the resolved + // consent-forms config (same as full intake flows). + items: {}, + requiredFields: [], + }, + signature: { + key: 'signature', + label: 'Signature', + type: 'string', + dataType: 'Signature', + triggers: [ + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'completed', + }, + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'amended', + }, + ], + enableBehavior: 'all', + disabledDisplay: 'disabled', + }, + fullName: { + key: 'full-name', + label: 'Full name', + type: 'string', + triggers: [ + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'completed', + }, + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'amended', + }, + ], + enableBehavior: 'all', + autocomplete: 'section-consent-forms shipping name', + disabledDisplay: 'disabled', + }, + consentFormSignerRelationship: { + key: 'consent-form-signer-relationship', + label: 'Relationship to the patient', + type: 'choice', + options: VALUE_SETS.relationshipOptions, + triggers: [ + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'completed', + }, + { + targetQuestionLinkId: '$status', + effect: ['enable'], + operator: '!=', + answerString: 'amended', + }, + ], + enableBehavior: 'all', + disabledDisplay: 'disabled', + }, + }, + hiddenFields: [], + requiredFields: ['signature', 'full-name', 'consent-form-signer-relationship'], + }, + }; +} + +export function getLiteIntakePaperworkConfig(consentFormsConfig?: ResolvedConsentFormConfig[]): PaperworkConfig { + const valueSets = VALUE_SETS; + const consentForms = consentFormsConfig ?? getConsentFormsForLocation(); + const FormFields = buildFormFields(valueSets); + + const DEFAULTS: PaperworkConfig = { + questionnaireBase: questionnaireBaseDefaults, + hiddenFormSections, + FormFields, + }; + + // Inject the resolved consent-forms list (same merge pattern the full intake + // configs use) so the checkbox group reflects whatever consent forms are + // configured for the current location. + const consentFormsOverride = { + FormFields: { + consentForms: { + items: { + checkboxGroup: { + items: buildConsentFormCheckboxItems(consentForms), + requiredFields: consentForms.map((f) => f.id), + }, + }, + }, + }, + }; + + const merged = mergeAndFreezeConfigObjects(DEFAULTS, consentFormsOverride); + return PaperworkConfigSchema.parse(merged); +} + +export const LITE_INTAKE_PAPERWORK_CONFIG = getLiteIntakePaperworkConfig(); + +export const LITE_INTAKE_PAPERWORK_QUESTIONNAIRE = (): Questionnaire => + JSON.parse(JSON.stringify(createQuestionnaireFromConfig(LITE_INTAKE_PAPERWORK_CONFIG as QuestionnaireConfigType))); diff --git a/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts b/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts index ba68914821..dbf6e8d499 100644 --- a/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts +++ b/packages/utils/lib/types/api/prebook-create-appointment/prebook-create-appointment.types.ts @@ -1,6 +1,6 @@ import { Appointment, Encounter, Patient, QuestionnaireResponse, Slot } from 'fhir/r4b'; import { ServiceCategoryCode } from '../../../ottehr-config'; -import { CanonicalUrl, ServiceMode, Timezone } from '../../common'; +import { AppointmentPaperworkSubtype, CanonicalUrl, ServiceMode, Timezone } from '../../common'; import { PatientInfo } from '../../data'; import { ScheduleOwnerFhirResource } from '../schedules'; @@ -27,6 +27,12 @@ export interface CreateAppointmentInputParams { locationState?: string; appointmentMetadata?: Appointment['meta']; followUpOptions?: FollowUpOptions; + // Optional override that picks an alternate paperwork flow for this appointment. + // When set to 'consent-form-only', create-appointment scaffolds the encounter's + // QuestionnaireResponse against LITE_INTAKE_PAPERWORK_CANONICAL instead of the + // ServiceMode-based default. Also written to Appointment.appointmentType.coding + // so the EHR can surface a paperwork-type badge without re-reading the QR. + paperworkSubtype?: AppointmentPaperworkSubtype; } export interface CreateAppointmentResponse { diff --git a/packages/utils/lib/types/common.ts b/packages/utils/lib/types/common.ts index 28514acba9..f3c98ad75d 100644 --- a/packages/utils/lib/types/common.ts +++ b/packages/utils/lib/types/common.ts @@ -543,6 +543,18 @@ export const Task_Patient_Payment_Candid_Sync_And_Receipt_Url = export const Task_Generate_Patient_Statement_Url = 'https://fhir.ottehr.com/CodeSystem/generate-patient-statement'; export const Task_Send_Patient_Statement_By_Mail_Url = 'https://fhir.ottehr.com/CodeSystem/patient-statement-mail'; +// Appointment.appointmentType.coding system + codes for which paperwork flow an +// appointment scaffolds. When the coding's `code` is APPOINTMENT_PAPERWORK_SUBTYPE.CONSENT_FORM_ONLY, +// create-appointment scaffolds the encounter's QR against the lite Questionnaire +// (LITE_INTAKE_PAPERWORK_CANONICAL) instead of the full in-person/virtual canonical. +// When absent, the existing ServiceMode-based mapping applies. +export const APPOINTMENT_PAPERWORK_SUBTYPE_SYSTEM = 'https://fhir.ottehr.com/CodeSystem/appointment-paperwork-subtype'; +export const APPOINTMENT_PAPERWORK_SUBTYPE = { + CONSENT_FORM_ONLY: 'consent-form-only', +} as const; +export type AppointmentPaperworkSubtype = + (typeof APPOINTMENT_PAPERWORK_SUBTYPE)[keyof typeof APPOINTMENT_PAPERWORK_SUBTYPE]; + type Task_System_Member = | typeof Task_Email_Communication_Url | typeof Task_Text_Communication_Url diff --git a/packages/utils/lib/types/data/tasks/types.ts b/packages/utils/lib/types/data/tasks/types.ts index 47e05af9f4..b56b60c2e1 100644 --- a/packages/utils/lib/types/data/tasks/types.ts +++ b/packages/utils/lib/types/data/tasks/types.ts @@ -62,6 +62,7 @@ export const MANUAL_TASK = { orderId: 'order-id', encounterId: 'encounter-id', patient: 'patient', + documentReferenceId: 'document-reference-id', }, } as const; diff --git a/packages/zambdas/src/ehr/admin-create-questionnaire/index.ts b/packages/zambdas/src/ehr/admin-create-questionnaire/index.ts new file mode 100644 index 0000000000..f7e8d890df --- /dev/null +++ b/packages/zambdas/src/ehr/admin-create-questionnaire/index.ts @@ -0,0 +1,27 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { Questionnaire } from 'fhir/r4b'; +import { MISSING_REQUEST_BODY, PRACTICE_MANAGED_QUESTIONNAIRE_TAG } from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient } from '../admin-questionnaires/helpers'; + +export const index = wrapHandler( + 'admin-create-questionnaire', + async (input: ZambdaInput): Promise => { + if (!input.body) throw MISSING_REQUEST_BODY; + const oystehr = await getClient(input); + const parsed = JSON.parse(input.body) as { questionnaire: Questionnaire }; + + const resource: Questionnaire = { + ...parsed.questionnaire, + resourceType: 'Questionnaire', + meta: { tag: [PRACTICE_MANAGED_QUESTIONNAIRE_TAG] }, + }; + delete (resource as unknown as Record).id; + + const created = await oystehr.fhir.create(resource); + return { + statusCode: 200, + body: JSON.stringify({ questionnaire: created }), + }; + } +); diff --git a/packages/zambdas/src/ehr/admin-delete-questionnaire/index.ts b/packages/zambdas/src/ehr/admin-delete-questionnaire/index.ts new file mode 100644 index 0000000000..230a2da5d7 --- /dev/null +++ b/packages/zambdas/src/ehr/admin-delete-questionnaire/index.ts @@ -0,0 +1,29 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { Questionnaire } from 'fhir/r4b'; +import { MISSING_REQUEST_BODY, MISSING_REQUIRED_PARAMETERS } from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient } from '../admin-questionnaires/helpers'; + +export const index = wrapHandler( + 'admin-delete-questionnaire', + async (input: ZambdaInput): Promise => { + if (!input.body) throw MISSING_REQUEST_BODY; + const oystehr = await getClient(input); + const parsed = JSON.parse(input.body) as { questionnaireId: string }; + + if (!parsed.questionnaireId) throw MISSING_REQUIRED_PARAMETERS(['questionnaireId']); + + // Soft delete: mark as retired so existing QuestionnaireResponses remain + // viewable in the EHR and the canonical reference stays resolvable. + const existing = await oystehr.fhir.get({ + resourceType: 'Questionnaire', + id: parsed.questionnaireId, + }); + const updated = await oystehr.fhir.update({ ...existing, status: 'retired' }); + + return { + statusCode: 200, + body: JSON.stringify({ message: 'Questionnaire retired', questionnaire: updated }), + }; + } +); diff --git a/packages/zambdas/src/ehr/admin-get-questionnaire/index.ts b/packages/zambdas/src/ehr/admin-get-questionnaire/index.ts new file mode 100644 index 0000000000..7c7df2b906 --- /dev/null +++ b/packages/zambdas/src/ehr/admin-get-questionnaire/index.ts @@ -0,0 +1,25 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { Questionnaire } from 'fhir/r4b'; +import { MISSING_REQUEST_BODY, MISSING_REQUIRED_PARAMETERS } from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient } from '../admin-questionnaires/helpers'; + +export const index = wrapHandler( + 'admin-get-questionnaire', + async (input: ZambdaInput): Promise => { + if (!input.body) throw MISSING_REQUEST_BODY; + const oystehr = await getClient(input); + const { questionnaireId } = JSON.parse(input.body) as { questionnaireId: string }; + if (!questionnaireId) throw MISSING_REQUIRED_PARAMETERS(['questionnaireId']); + + const questionnaire = await oystehr.fhir.get({ + resourceType: 'Questionnaire', + id: questionnaireId, + }); + + return { + statusCode: 200, + body: JSON.stringify({ questionnaire }), + }; + } +); diff --git a/packages/zambdas/src/ehr/admin-list-questionnaires/index.ts b/packages/zambdas/src/ehr/admin-list-questionnaires/index.ts new file mode 100644 index 0000000000..e719f124dd --- /dev/null +++ b/packages/zambdas/src/ehr/admin-list-questionnaires/index.ts @@ -0,0 +1,77 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { Questionnaire } from 'fhir/r4b'; +import { getAllFhirSearchPages, PRACTICE_MANAGED_QUESTIONNAIRE_TAG } from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient } from '../admin-questionnaires/helpers'; + +export const index = wrapHandler( + 'admin-list-questionnaires', + async (input: ZambdaInput): Promise => { + const oystehr = await getClient(input); + + // Fetch practice-managed questionnaires. Paginated — a single page would silently + // truncate once the catalog outgrows the default page size (it already has 100+). + const practiceManaged = await getAllFhirSearchPages( + { + resourceType: 'Questionnaire', + params: [{ name: '_tag', value: PRACTICE_MANAGED_QUESTIONNAIRE_TAG.code }], + }, + oystehr + ); + + // Fetch all questionnaires to find system ones (for association options). Projecting only + // identifying fields — including `item` blows past the 6 MB SDK response cap because the + // page-item trees are huge across hundreds of versioned questionnaires. Intake forms are + // instead identified below via URL pattern, which is reliable for Ottehr's canonical + // intake-paperwork-{inperson,virtual} URL convention. + const all = ( + await oystehr.fhir.search({ + resourceType: 'Questionnaire', + params: [ + { name: 'status', value: 'active' }, + { name: '_elements', value: 'id,url,name,title' }, + { name: '_count', value: '500' }, + ], + }) + ).unbundle(); + + const practiceManagedIds = new Set(practiceManaged.map((q) => q.id)); + + // Deduplicate by URL and name — keep one entry per unique questionnaire + const seenUrls = new Set(); + const seenNames = new Set(); + const systemQuestionnaires: { id: string; url: string; title: string }[] = []; + + console.log(`Found ${all.length} total questionnaires, ${practiceManagedIds.size} practice-managed`); + + for (const q of all) { + if (practiceManagedIds.has(q.id) || !q.url) continue; + // Only include patient-facing intake questionnaires. Ottehr's intake URLs follow the + // convention `…/Questionnaire/intake-paperwork-{inperson,virtual}` — matching on that + // pattern is reliable and lets us avoid pulling the full `item` tree (8.5 MB across + // versioned questionnaires) just to check linkId structure. + if (!q.url.includes('intake-paperwork')) continue; + const key = q.url; + if (seenUrls.has(key)) continue; + seenUrls.add(key); + + const name = q.name || q.url.split('/').pop() || ''; + if (seenNames.has(name)) continue; + seenNames.add(name); + + const title = q.title || q.name || 'Untitled'; + systemQuestionnaires.push({ + id: q.id!, + url: q.url, + title, + }); + + console.log(`System questionnaire: ${title} | name=${name} | url=${q.url} | id=${q.id}`); + } + + return { + statusCode: 200, + body: JSON.stringify({ questionnaires: practiceManaged, systemQuestionnaires }), + }; + } +); diff --git a/packages/zambdas/src/ehr/admin-questionnaires/helpers.ts b/packages/zambdas/src/ehr/admin-questionnaires/helpers.ts new file mode 100644 index 0000000000..e4fee980b8 --- /dev/null +++ b/packages/zambdas/src/ehr/admin-questionnaires/helpers.ts @@ -0,0 +1,10 @@ +import Oystehr from '@oystehr/sdk'; +import { checkOrCreateM2MClientToken, createOystehrClient, ZambdaInput } from '../../shared'; + +let m2mToken: string; + +export async function getClient(input: ZambdaInput): Promise { + if (!input.secrets) throw new Error('No secrets provided'); + m2mToken = await checkOrCreateM2MClientToken(m2mToken, input.secrets); + return createOystehrClient(m2mToken, input.secrets); +} diff --git a/packages/zambdas/src/ehr/admin-update-questionnaire/index.ts b/packages/zambdas/src/ehr/admin-update-questionnaire/index.ts new file mode 100644 index 0000000000..5ac01e5be2 --- /dev/null +++ b/packages/zambdas/src/ehr/admin-update-questionnaire/index.ts @@ -0,0 +1,28 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { Questionnaire } from 'fhir/r4b'; +import { MISSING_REQUEST_BODY, MISSING_REQUIRED_PARAMETERS, PRACTICE_MANAGED_QUESTIONNAIRE_TAG } from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient } from '../admin-questionnaires/helpers'; + +export const index = wrapHandler( + 'admin-update-questionnaire', + async (input: ZambdaInput): Promise => { + if (!input.body) throw MISSING_REQUEST_BODY; + const oystehr = await getClient(input); + const parsed = JSON.parse(input.body) as { questionnaire: Questionnaire }; + + if (!parsed.questionnaire.id) throw MISSING_REQUIRED_PARAMETERS(['questionnaire.id']); + + const resource: Questionnaire = { + ...parsed.questionnaire, + resourceType: 'Questionnaire', + meta: { tag: [PRACTICE_MANAGED_QUESTIONNAIRE_TAG] }, + }; + + const updated = await oystehr.fhir.update(resource); + return { + statusCode: 200, + body: JSON.stringify({ questionnaire: updated }), + }; + } +); diff --git a/packages/zambdas/src/ehr/send-patient-form/index.ts b/packages/zambdas/src/ehr/send-patient-form/index.ts new file mode 100644 index 0000000000..da7f2c8bb4 --- /dev/null +++ b/packages/zambdas/src/ehr/send-patient-form/index.ts @@ -0,0 +1,65 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { Encounter, Patient } from 'fhir/r4b'; +import { FHIR_RESOURCE_NOT_FOUND, getSecret, SecretsKeys } from 'utils'; +import { checkOrCreateM2MClientToken, createOystehrClient, wrapHandler, ZambdaInput } from '../../shared'; +import { sendSmsForPatient } from '../../shared/communication'; +import { validateRequestParameters } from './validateRequestParameters'; + +let m2mToken: string; + +export const index = wrapHandler('send-patient-form', async (input: ZambdaInput): Promise => { + const { appointmentId, patientId, questionnaireId, questionnaireName, secrets } = validateRequestParameters(input); + + m2mToken = await checkOrCreateM2MClientToken(m2mToken, secrets); + const oystehr = createOystehrClient(m2mToken, secrets); + + // Resolve the patient from either an appointment's encounter or a direct patientId. + let resolvedPatientId = patientId; + if (!resolvedPatientId && appointmentId) { + const encounters = ( + await oystehr.fhir.search({ + resourceType: 'Encounter', + params: [{ name: 'appointment', value: `Appointment/${appointmentId}` }], + }) + ).unbundle(); + if (encounters.length === 0) { + // appointmentId is caller-supplied; a bad/stale id is user-actionable, not a page. + throw FHIR_RESOURCE_NOT_FOUND('Encounter'); + } + const patientRef = encounters[0].subject?.reference; + if (!patientRef) { + throw new Error('Encounter has no patient reference'); + } + resolvedPatientId = patientRef.replace('Patient/', ''); + } + + if (!resolvedPatientId) { + throw new Error('Could not resolve patient'); + } + + const patient = await oystehr.fhir.get({ + resourceType: 'Patient', + id: resolvedPatientId, + }); + + // Build the form URL. Patient-level links omit the appointment segment. + const websiteUrl = getSecret(SecretsKeys.WEBSITE_URL, secrets); + const formUrl = appointmentId + ? `${websiteUrl}/forms/${appointmentId}/${questionnaireId}` + : `${websiteUrl}/forms/patient/${resolvedPatientId}/${questionnaireId}`; + + // Send SMS to the patient + const ENVIRONMENT = getSecret(SecretsKeys.ENVIRONMENT, secrets); + const message = `Please complete ${questionnaireName} for your visit: ${formUrl}`; + await sendSmsForPatient(message, oystehr, patient, ENVIRONMENT); + + console.log(`Form link sent to patient ${patient.id}: ${formUrl}`); + + return { + statusCode: 200, + body: JSON.stringify({ + formUrl, + messageSent: true, + }), + }; +}); diff --git a/packages/zambdas/src/ehr/send-patient-form/validateRequestParameters.ts b/packages/zambdas/src/ehr/send-patient-form/validateRequestParameters.ts new file mode 100644 index 0000000000..996201f01f --- /dev/null +++ b/packages/zambdas/src/ehr/send-patient-form/validateRequestParameters.ts @@ -0,0 +1,33 @@ +import { INVALID_INPUT_ERROR, MISSING_REQUEST_BODY, MISSING_REQUIRED_PARAMETERS, Secrets } from 'utils'; +import { ZambdaInput } from '../../shared'; + +export interface SendPatientFormInput { + appointmentId?: string; + patientId?: string; + questionnaireId: string; + questionnaireName: string; + secrets: Secrets | null; +} + +export function validateRequestParameters(input: ZambdaInput): SendPatientFormInput { + if (!input.body) { + throw MISSING_REQUEST_BODY; + } + + const { appointmentId, patientId, questionnaireId, questionnaireName } = JSON.parse(input.body); + + if (!appointmentId && !patientId) { + throw INVALID_INPUT_ERROR('appointmentId or patientId is required'); + } + if (!questionnaireId) { + throw MISSING_REQUIRED_PARAMETERS(['questionnaireId']); + } + + return { + appointmentId, + patientId, + questionnaireId, + questionnaireName: questionnaireName || 'a form', + secrets: input.secrets, + }; +} diff --git a/packages/zambdas/src/patient/appointment/create-appointment/index.ts b/packages/zambdas/src/patient/appointment/create-appointment/index.ts index 1521caa991..c1bea46543 100644 --- a/packages/zambdas/src/patient/appointment/create-appointment/index.ts +++ b/packages/zambdas/src/patient/appointment/create-appointment/index.ts @@ -24,6 +24,7 @@ import { DateTime } from 'luxon'; import { uuid } from 'short-uuid'; import { ACCIDENT_TYPE_SYSTEM, + APPOINTMENT_PAPERWORK_SUBTYPE_SYSTEM, CanonicalUrl, CreateAppointmentResponse, CREATED_BY_SYSTEM, @@ -98,6 +99,7 @@ interface CreateAppointmentInput { bookingLocation?: ResolvedBookingLocation; /** Resolved attending Practitioner (populated for PractitionerRole bookings). */ attendingPractitioner?: ResolvedAttendingPractitioner; + paperworkSubtype?: string; } // Lifting up value to outside of the handler allows it to stay in memory across warm lambda invocations @@ -136,6 +138,7 @@ export const index = wrapHandler('create-appointment', async (input: ZambdaInput followUpOptions, bookingLocation, attendingPractitioner, + paperworkSubtype, } = effectInput; console.log('effectInput', effectInput); console.timeEnd('performing-complex-validation'); @@ -171,6 +174,7 @@ export const index = wrapHandler('create-appointment', async (input: ZambdaInput followUpOptions, bookingLocation, attendingPractitioner, + paperworkSubtype, }, oystehr ); @@ -221,6 +225,7 @@ export async function createAppointment( appointmentMetadata, bookingLocation, attendingPractitioner, + paperworkSubtype, } = input; const { verifiedPhoneNumber, listRequests, createPatientRequest, updatePatientRequest, isEHRUser, maybeFhirPatient } = @@ -304,6 +309,7 @@ export async function createAppointment( slot, appointmentMetadata, followUpOptions: input.followUpOptions, + paperworkSubtype, }); let relatedPersonId = ''; @@ -397,6 +403,7 @@ interface TransactionInput { slot?: Slot; appointmentMetadata?: Appointment['meta']; followUpOptions?: FollowUpOptions; + paperworkSubtype?: string; } interface TransactionOutput { appointment: Appointment; @@ -432,6 +439,7 @@ export const performTransactionalFhirRequests = async (input: TransactionInput): slot, appointmentMetadata, followUpOptions, + paperworkSubtype, } = input; const parentEncounterId = followUpOptions?.parentEncounterId; @@ -622,6 +630,19 @@ export const performTransactionalFhirRequests = async (input: TransactionInput): slot: slotReference ? [slotReference] : undefined, appointmentType: { text: visitType, + // When the appointment uses a non-default paperwork flow (e.g. consent-form-only for + // a return visit that skips full demographics), tag the coding so the EHR can surface + // a paperwork-type badge / filter without re-reading the encounter's QR canonical. + ...(paperworkSubtype + ? { + coding: [ + { + system: APPOINTMENT_PAPERWORK_SUBTYPE_SYSTEM, + code: paperworkSubtype, + }, + ], + } + : {}), }, serviceCategory: slot?.serviceCategory, description: reasonForVisit, diff --git a/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts b/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts index f983dcdbbe..33e3741625 100644 --- a/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts +++ b/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts @@ -4,6 +4,8 @@ import { DateTime } from 'luxon'; import { AllStates, APPOINTMENT_ALREADY_EXISTS_ERROR, + APPOINTMENT_PAPERWORK_SUBTYPE, + AppointmentPaperworkSubtype, CanonicalUrl, CHARACTER_LIMIT_EXCEEDED_ERROR, checkSlotAvailable, @@ -60,7 +62,7 @@ export function validateCreateAppointmentParams(input: ZambdaInput, user: User): const isEHRUser = user && checkIsEHRUser(user); const bodyJSON = JSON.parse(input.body); - const { slotId, language, patient, locationState, appointmentMetadata, followUpOptions } = bodyJSON; + const { slotId, language, patient, locationState, appointmentMetadata, followUpOptions, paperworkSubtype } = bodyJSON; console.log('patient:', patient, 'slotId:', slotId); // Check existence of necessary fields if (patient === undefined) { @@ -153,6 +155,7 @@ export function validateCreateAppointmentParams(input: ZambdaInput, user: User): locationState, appointmentMetadata, followUpOptions: validatedFollowUpOptions, + paperworkSubtype, }; } @@ -190,6 +193,7 @@ export interface CreateAppointmentEffectInput { locationState?: string; appointmentMetadata?: Appointment['meta']; followUpOptions?: FollowUpOptions; + paperworkSubtype?: AppointmentPaperworkSubtype; /** * Unified booking-location resolution. Populated when the booking should be * attributed to a specific Location — either because the scheduleOwner IS @@ -251,7 +255,7 @@ export const createAppointmentComplexValidation = async ( input: CreateAppointmentBasicInput, oystehrClient: Oystehr ): Promise => { - const { slotId, isEHRUser, user, patient, appointmentMetadata } = input; + const { slotId, isEHRUser, user, patient, appointmentMetadata, paperworkSubtype } = input; console.log('createAppointmentComplexValidation metadata:', appointmentMetadata); @@ -405,12 +409,32 @@ export const createAppointmentComplexValidation = async ( const slotQuestionnaireExtension = slot.extension?.find( (ext) => ext.url === SLOT_QUESTIONNAIRE_CANONICAL_EXTENSION_URL ); + // Validate paperworkSubtype if provided — must be a known APPOINTMENT_PAPERWORK_SUBTYPE + // value. The router in getCanonicalUrlForPrevisitQuestionnaire uses it to pick the lite + // canonical; rejecting unknown values here protects against typos that would silently + // fall through to the ServiceMode default. + const validatedPaperworkSubtype: AppointmentPaperworkSubtype | undefined = + typeof paperworkSubtype === 'string' + ? (Object.values(APPOINTMENT_PAPERWORK_SUBTYPE) as string[]).includes(paperworkSubtype) + ? (paperworkSubtype as AppointmentPaperworkSubtype) + : (() => { + throw INVALID_INPUT_ERROR( + `Unknown paperworkSubtype "${paperworkSubtype}". Allowed: ${Object.values( + APPOINTMENT_PAPERWORK_SUBTYPE + ).join(', ')}` + ); + })() + : undefined; + if (slotQuestionnaireExtension?.valueString) { + // Slot extension wins over both subtype and ServiceMode — it's the explicit per-slot + // override (used when a slot is set up for a specific questionnaire that isn't covered + // by either the ServiceMode default or the paperworkSubtype shortcut). questionnaireCanonical = parseQuestionnaireCanonicalExtension(slotQuestionnaireExtension.valueString); console.log('Using questionnaire canonical from slot extension:', questionnaireCanonical); } else { - // Fall back to service-mode-based questionnaire selection - questionnaireCanonical = getCanonicalUrlForPrevisitQuestionnaire(serviceMode); + // Fall back to subtype-aware service-mode-based selection. + questionnaireCanonical = getCanonicalUrlForPrevisitQuestionnaire(serviceMode, validatedPaperworkSubtype); } let visitType = getSlotIsPostTelemed(slot) ? VisitType.PostTelemed : VisitType.PreBook; @@ -495,6 +519,7 @@ export const createAppointmentComplexValidation = async ( locationState, appointmentMetadata, followUpOptions: input.followUpOptions, + paperworkSubtype: validatedPaperworkSubtype, bookingLocation, attendingPractitioner, }; diff --git a/packages/zambdas/src/patient/appointment/helpers.ts b/packages/zambdas/src/patient/appointment/helpers.ts index 9629b5cca2..09521bc418 100644 --- a/packages/zambdas/src/patient/appointment/helpers.ts +++ b/packages/zambdas/src/patient/appointment/helpers.ts @@ -1,9 +1,12 @@ import Oystehr from '@oystehr/sdk'; import { Coding, DocumentReference, Extension, Organization, Practitioner, Questionnaire } from 'fhir/r4b'; import { + APPOINTMENT_PAPERWORK_SUBTYPE, + AppointmentPaperworkSubtype, CanonicalUrl, getCanonicalQuestionnaire, IN_PERSON_INTAKE_PAPERWORK_CANONICAL, + LITE_INTAKE_PAPERWORK_CANONICAL, OtherParticipantsExtension, PatientAccountResponse, ServiceMode, @@ -13,13 +16,23 @@ import { import { getAccountAndCoverageResourcesForPatient, PATIENT_CONTAINED_PHARMACY_ID } from '../../ehr/shared/harvest'; export const getCurrentQuestionnaireForServiceType = async ( serviceMode: ServiceMode, - oystehrClient: Oystehr + oystehrClient: Oystehr, + paperworkSubtype?: AppointmentPaperworkSubtype ): Promise => { - const canonical = getCanonicalUrlForPrevisitQuestionnaire(serviceMode); + const canonical = getCanonicalUrlForPrevisitQuestionnaire(serviceMode, paperworkSubtype); return getCanonicalQuestionnaire(canonical, oystehrClient); }; -export const getCanonicalUrlForPrevisitQuestionnaire = (serviceMode: ServiceMode): CanonicalUrl => { +export const getCanonicalUrlForPrevisitQuestionnaire = ( + serviceMode: ServiceMode, + paperworkSubtype?: AppointmentPaperworkSubtype +): CanonicalUrl => { + // Subtype takes precedence over the ServiceMode default — used when the appointment is + // scheduled with a non-default paperwork flow (e.g. consent-form-only for a return visit + // that doesn't need full demographics/insurance reentry). + if (paperworkSubtype === APPOINTMENT_PAPERWORK_SUBTYPE.CONSENT_FORM_ONLY) { + return LITE_INTAKE_PAPERWORK_CANONICAL; + } switch (serviceMode) { case ServiceMode['in-person']: return IN_PERSON_INTAKE_PAPERWORK_CANONICAL; diff --git a/packages/zambdas/src/patient/paperwork/finalize-practice-managed-response/index.ts b/packages/zambdas/src/patient/paperwork/finalize-practice-managed-response/index.ts new file mode 100644 index 0000000000..c5411093e9 --- /dev/null +++ b/packages/zambdas/src/patient/paperwork/finalize-practice-managed-response/index.ts @@ -0,0 +1,387 @@ +import { captureException } from '@sentry/aws-serverless'; +import { APIGatewayProxyResult } from 'aws-lambda'; +import { randomUUID } from 'crypto'; +import { Encounter, List, Location, Patient, Questionnaire, QuestionnaireResponse } from 'fhir/r4b'; +import { DateTime } from 'luxon'; +import { PageSizes, PDFDocument, PDFFont, StandardFonts } from 'pdf-lib'; +import { + BUCKET_NAMES, + createFilesDocumentReferences, + EXPORTED_QUESTIONNAIRE_CODE, + getFullName, + MANUAL_TASK, + MISSING_REQUEST_BODY, + MISSING_REQUIRED_PARAMETERS, + OTTEHR_MODULE, +} from 'utils'; +import { + checkOrCreateM2MClientToken, + createOystehrClient, + createPresignedUrl, + getAuth0Token, + uploadObjectToZ3, + wrapHandler, + ZambdaInput, +} from '../../../shared'; +import { makeZ3Url } from '../../../shared/presigned-file-urls'; +import { createTask } from '../../../shared/tasks'; + +const ZAMBDA_NAME = 'finalize-practice-managed-response'; + +let oystehrToken: string; +let m2mToken: string; + +const PAGE_WIDTH = PageSizes.A4[0]; +const PAGE_HEIGHT = PageSizes.A4[1]; +const MARGIN = 48; +const LINE_HEIGHT = 14; +const BOTTOM_MARGIN = 60; + +export const index = wrapHandler(ZAMBDA_NAME, async (input: ZambdaInput): Promise => { + if (!input.body) throw MISSING_REQUEST_BODY; + if (!input.secrets) throw new Error('No secrets provided'); + const { questionnaireResponseId } = JSON.parse(input.body) as { questionnaireResponseId: string }; + if (!questionnaireResponseId) throw MISSING_REQUIRED_PARAMETERS(['questionnaireResponseId']); + + if (!oystehrToken) oystehrToken = await getAuth0Token(input.secrets); + m2mToken = await checkOrCreateM2MClientToken(m2mToken, input.secrets); + const oystehr = createOystehrClient(oystehrToken, input.secrets); + + // 1. Load QR + Questionnaire + Patient + List folders in one search + const qr = await oystehr.fhir.get({ + resourceType: 'QuestionnaireResponse', + id: questionnaireResponseId, + }); + const patientId = qr.subject?.reference?.replace('Patient/', '') || ''; + if (!patientId) throw new Error('QR has no patient subject'); + + const canonicalUrl = qr.questionnaire?.split('|')[0]; + if (!canonicalUrl) throw new Error('QR has no questionnaire canonical URL'); + const qBundle = ( + await oystehr.fhir.search({ + resourceType: 'Questionnaire', + params: [ + { name: 'url', value: canonicalUrl }, + { name: '_sort', value: '-_lastUpdated' }, + { name: '_count', value: '1' }, + ], + }) + ).unbundle(); + const questionnaire = qBundle[0]; + + const patient = await oystehr.fhir.get({ resourceType: 'Patient', id: patientId }); + + const listResources = ( + await oystehr.fhir.search({ + resourceType: 'List', + params: [ + { name: 'subject', value: `Patient/${patientId}` }, + { name: 'code', value: 'patient-docs-folder' }, + ], + }) + ).unbundle(); + + // 2. Generate PDF bytes + const pdfBytes = await renderQrPdf(qr, questionnaire, patient); + + // 3. Upload to Z3 (Paperwork bucket) + const timestamp = DateTime.now().toUTC().toFormat('yyyy-MM-dd-x'); + const title = questionnaire?.title || questionnaire?.name || 'Form'; + const fileName = `${slugify(title)}-${qr.id}-${timestamp}.pdf`; + const baseFileUrl = makeZ3Url({ + secrets: input.secrets, + fileName, + bucketName: BUCKET_NAMES.PAPERWORK, + patientID: patientId, + }); + const presignedUrl = await createPresignedUrl(m2mToken, baseFileUrl, 'upload'); + await uploadObjectToZ3(pdfBytes, presignedUrl); + + // 4. Create DocumentReference — EXPORTED_QUESTIONNAIRE_CODE auto-links it into the "Paperwork" folder + // Title includes a completion date so multiple submissions are distinguishable in the Docs UI. + const completionDate = DateTime.now().toFormat('yyyy-MM-dd'); + const displayTitle = `${title} — ${completionDate}.pdf`; + const { docRefs } = await createFilesDocumentReferences({ + files: [{ url: baseFileUrl, title: displayTitle }], + type: { + coding: [{ system: 'http://loinc.org', code: EXPORTED_QUESTIONNAIRE_CODE, display: title }], + text: title, + }, + dateCreated: DateTime.now().toUTC().toISO() || new Date().toISOString(), + searchParams: [ + { name: 'subject', value: `Patient/${patientId}` }, + { name: 'type', value: EXPORTED_QUESTIONNAIRE_CODE }, + ...(qr.encounter?.reference ? [{ name: 'encounter', value: qr.encounter.reference }] : []), + ], + references: { + subject: { reference: `Patient/${patientId}` }, + ...(qr.encounter ? { context: { encounter: [qr.encounter] } } : {}), + }, + oystehr, + generateUUID: randomUUID, + listResources, + meta: { tag: [{ code: OTTEHR_MODULE.IP }, { code: OTTEHR_MODULE.TM }] }, + }); + + // 5. Mark QR completed + await oystehr.fhir.patch({ + resourceType: 'QuestionnaireResponse', + id: qr.id || '', + operations: [{ op: 'replace', path: '/status', value: 'completed' }], + }); + + // 6. Create a Patient Follow-up Task announcing the completed form. + // The Task carries the DocumentReference id as an input so Go To Task can + // navigate back to this PDF in the patient's Paperwork folder. + try { + const docRefId = docRefs[0]?.id; + const patientName = getFullName(patient); + const formTitle = questionnaire?.title || questionnaire?.name || 'Form'; + const taskEncounterId = qr.encounter?.reference?.replace('Encounter/', ''); + const locationForTask = await resolveLocationForTask(oystehr, qr, patientId); + + const task = createTask({ + category: MANUAL_TASK.category.patientFollowUp, + title: `${patientName} completed ${formTitle}`, + encounterId: taskEncounterId, + location: locationForTask ? { id: locationForTask.id || '', name: locationForTask.name || '' } : undefined, + input: [ + { type: MANUAL_TASK.input.title, valueString: `${patientName} completed ${formTitle}` }, + { + type: MANUAL_TASK.input.patient, + valueReference: { reference: `Patient/${patientId}`, display: patientName }, + }, + ...(taskEncounterId ? [{ type: MANUAL_TASK.input.encounterId, valueString: taskEncounterId }] : []), + ...(docRefId ? [{ type: MANUAL_TASK.input.documentReferenceId, valueString: docRefId }] : []), + ], + }); + await oystehr.fhir.create(task); + } catch (taskErr) { + // Non-fatal by design — the form itself finalized — but a staff follow-up Task + // silently never appearing is signal-worthy, so report it. + console.error('Failed to create follow-up task:', taskErr); + captureException(taskErr); + } + + return { + statusCode: 200, + body: JSON.stringify({ documentReferenceId: docRefs[0]?.id, status: 'completed' }), + }; +}); + +// Resolve a Location for the new Task. Preference order: +// 1. If the QR has an encounter, use that encounter's location (via +// encounter.location[0].location). +// 2. Else, find the patient's most recent Appointment and use its location +// (via the Encounter associated with that Appointment). +async function resolveLocationForTask( + oystehr: ReturnType, + qr: QuestionnaireResponse, + patientId: string +): Promise { + const fetchLocation = async (ref: string): Promise => { + const id = ref.replace('Location/', ''); + if (!id) return undefined; + try { + return await oystehr.fhir.get({ resourceType: 'Location', id }); + } catch { + return undefined; + } + }; + + // Path 1: QR's own encounter + const encounterRef = qr.encounter?.reference; + if (encounterRef) { + try { + const encounter = await oystehr.fhir.get({ + resourceType: 'Encounter', + id: encounterRef.replace('Encounter/', ''), + }); + const locRef = encounter.location?.[0]?.location?.reference; + if (locRef) return await fetchLocation(locRef); + } catch { + // fall through to path 2 + } + } + + // Path 2: patient's most recent Encounter with a location + try { + const encounters = ( + await oystehr.fhir.search({ + resourceType: 'Encounter', + params: [ + { name: 'subject', value: `Patient/${patientId}` }, + { name: '_sort', value: '-date' }, + { name: '_count', value: '10' }, + ], + }) + ).unbundle(); + for (const enc of encounters) { + const locRef = enc.location?.[0]?.location?.reference; + if (locRef) return await fetchLocation(locRef); + } + } catch { + // no location found + } + return undefined; +} + +function slugify(s: string): string { + return (s || 'form') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + +async function renderQrPdf( + qr: QuestionnaireResponse, + questionnaire: Questionnaire | undefined, + patient: Patient +): Promise { + const pdf = await PDFDocument.create(); + const regular = await pdf.embedFont(StandardFonts.Helvetica); + const bold = await pdf.embedFont(StandardFonts.HelveticaBold); + + let page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]); + let y = PAGE_HEIGHT - MARGIN; + + const newPage = (): void => { + page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]); + y = PAGE_HEIGHT - MARGIN; + }; + const ensureSpace = (needed: number): void => { + if (y - needed < BOTTOM_MARGIN) newPage(); + }; + const drawLine = (text: string, opts: { font?: PDFFont; size?: number; indent?: number } = {}): void => { + const font = opts.font || regular; + const size = opts.size || 10; + const indent = opts.indent || 0; + // Helvetica can only encode WinAnsi; swap out Unicode characters we emit + // from calculated expressions (≥, em-dash) for Latin-1 equivalents. + const safe = sanitizeForWinAnsi(text); + const lines = wrapText(safe, font, size, PAGE_WIDTH - MARGIN * 2 - indent); + for (const line of lines) { + ensureSpace(size + 2); + page.drawText(line, { x: MARGIN + indent, y, size, font }); + y -= LINE_HEIGHT; + } + }; + + const title = questionnaire?.title || questionnaire?.name || 'Form'; + drawLine(title, { font: bold, size: 16 }); + y -= 4; + const name = (patient.name?.[0]?.given?.join(' ') || '') + ' ' + (patient.name?.[0]?.family || ''); + drawLine(`Patient: ${name.trim()}${patient.birthDate ? ` (DOB ${patient.birthDate})` : ''}`, { + font: regular, + size: 10, + }); + drawLine(`Completed: ${DateTime.now().toFormat('yyyy-MM-dd HH:mm')}`, { font: regular, size: 10 }); + y -= 10; + + // Build a flat list of Q items (for hidden-check) and a map by linkId + const allQItems: any[] = []; + const walkQ = (items: any[]): void => { + for (const it of items || []) { + allQItems.push(it); + if (it.item) walkQ(it.item); + } + }; + walkQ(questionnaire?.item || []); + const qItemByLinkId = new Map(allQItems.map((it) => [it.linkId, it])); + const isHidden = (item: any): boolean => + !!item?.extension?.some( + (e: any) => e.url === 'http://hl7.org/fhir/StructureDefinition/questionnaire-hidden' && e.valueBoolean === true + ); + + // Render each page group + const pageGroups = (qr.item || []).filter((p) => p.linkId !== 'results'); + for (const p of pageGroups) { + const qp = qItemByLinkId.get(p.linkId); + const pageTitle = qp?.text || p.linkId; + ensureSpace(20); + drawLine(pageTitle, { font: bold, size: 12 }); + y -= 2; + for (const child of p.item || []) { + const qItem = qItemByLinkId.get(child.linkId); + if (isHidden(qItem)) continue; + const label = qItem?.text || child.linkId; + const value = formatAnswer(child); + drawLine(label, { font: regular, size: 10, indent: 10 }); + drawLine(` ${value}`, { font: bold, size: 10, indent: 10 }); + y -= 4; + } + y -= 6; + } + + // Render "Screening Results" if present + const results = (qr.item || []).find((p) => p.linkId === 'results'); + if (results?.item?.length) { + ensureSpace(20); + drawLine('Screening Results', { font: bold, size: 12 }); + y -= 2; + // Pair each computed item with its rationale (same convention as the viewer) + const resultMap = new Map((results.item || []).map((i) => [i.linkId, i])); + const rendered = new Set(); + for (const item of results.item) { + if (rendered.has(item.linkId)) continue; + if (item.linkId.endsWith('-rationale')) continue; + rendered.add(item.linkId); + const q = qItemByLinkId.get(item.linkId); + const label = q?.text || item.linkId; + const value = formatAnswer(item); + drawLine(`${label}: ${value}`, { font: regular, size: 10, indent: 10 }); + const rationale = resultMap.get(`${item.linkId}-rationale`); + if (rationale) { + rendered.add(rationale.linkId); + const r = rationale.answer?.[0]?.valueString; + if (r) drawLine(r, { font: regular, size: 9, indent: 20 }); + } + } + } + + return pdf.save(); +} + +function formatAnswer(item: any): string { + const a = item.answer?.[0]; + if (!a) return ''; + if (a.valueCoding?.display) return a.valueCoding.display; + if (a.valueString !== undefined) return a.valueString; + if (a.valueBoolean !== undefined) return a.valueBoolean ? 'Positive' : 'Negative'; + if (a.valueInteger !== undefined) return String(a.valueInteger); + if (a.valueDecimal !== undefined) return String(a.valueDecimal); + if (a.valueDate) return a.valueDate; + if (a.valueDateTime) return a.valueDateTime; + return ''; +} + +function sanitizeForWinAnsi(text: string): string { + return String(text || '') + .replace(/\u2265/g, '>=') + .replace(/\u2264/g, '<=') + .replace(/\u2260/g, '!=') + .replace(/\u2014/g, '-') + .replace(/\u2013/g, '-') + .replace(/\u2018|\u2019/g, "'") + .replace(/\u201C|\u201D/g, '"') + .replace(/\u00A0/g, ' ') + .replace(/[^\x20-\x7E\xA1-\xFF]/g, '?'); +} + +function wrapText(text: string, font: PDFFont, size: number, maxWidth: number): string[] { + const words = String(text || '').split(/\s+/); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (font.widthOfTextAtSize(candidate, size) > maxWidth && current) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current) lines.push(current); + return lines; +} diff --git a/packages/zambdas/src/patient/paperwork/get-practice-managed-questionnaires/index.ts b/packages/zambdas/src/patient/paperwork/get-practice-managed-questionnaires/index.ts new file mode 100644 index 0000000000..7912b09f6b --- /dev/null +++ b/packages/zambdas/src/patient/paperwork/get-practice-managed-questionnaires/index.ts @@ -0,0 +1,294 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { Encounter, Questionnaire, QuestionnaireItem, QuestionnaireResponse } from 'fhir/r4b'; +import { + getAllFhirSearchPages, + INVALID_INPUT_ERROR, + MISSING_REQUEST_BODY, + PRACTICE_MANAGED_QR_TAG, + PRACTICE_MANAGED_QUESTIONNAIRE_TAG, +} from 'utils'; +import { + createOystehrClient, + getAuth0Token, + getUser, + userHasAccessToPatient, + wrapHandler, + ZambdaInput, +} from '../../../shared'; + +/** + * Determines the linkId of the last "data collection" page in the intake questionnaire. + * Practice-managed forms should be inserted after this page. + * + * Finalization pages are identified by their content: + * - Pages containing signature fields (linkId includes 'signature' or 'full-name' as direct children of consent groups) + * - Pages containing consent checkboxes (linkId includes 'consent') + * - Pages with the medical-history chatbot (linkId includes 'medical-history' with a boolean-type child) + * + * The last page before the first finalization page is the insertion point. + */ +function findInsertionPoint(items: QuestionnaireItem[]): string | undefined { + const isFinalizationPage = (page: QuestionnaireItem): boolean => { + const linkId = (page.linkId || '').toLowerCase(); + // Consent/signature pages + if (linkId.includes('consent')) return true; + // Medical history chatbot page (typically has a single boolean item) + if (linkId.includes('medical-history')) return true; + // Check children for signature-type fields + const hasSignature = page.item?.some((child) => { + const childLinkId = (child.linkId || '').toLowerCase(); + return childLinkId.includes('signature') || childLinkId === 'full-name'; + }); + if (hasSignature) return true; + return false; + }; + + // Walk pages in order; return the linkId of the page just before the first finalization page + const pages = items.filter((item) => item.type === 'group'); + for (let i = 0; i < pages.length; i++) { + if (isFinalizationPage(pages[i])) { + // Return the previous page's linkId, or undefined if finalization is the first page + return i > 0 ? pages[i - 1].linkId : undefined; + } + } + // No finalization pages found — insert after the last page + return pages.length > 0 ? pages[pages.length - 1].linkId : undefined; +} + +let oystehrToken: string; + +export const index = wrapHandler( + 'get-practice-managed-questionnaires', + async (input: ZambdaInput): Promise => { + if (!input.body) throw MISSING_REQUEST_BODY; + const { + appointmentId, + patientId: directPatientId, + intakeQuestionnaireUrl, + questionnaireId: directQuestionnaireId, + } = JSON.parse(input.body) as { + appointmentId?: string; + patientId?: string; + intakeQuestionnaireUrl?: string; + questionnaireId?: string; + }; + + if (!appointmentId && !directPatientId) { + throw INVALID_INPUT_ERROR('appointmentId or patientId is required'); + } + if (!input.secrets) throw new Error('No secrets provided'); + + if (!oystehrToken) { + oystehrToken = await getAuth0Token(input.secrets); + } + const oystehr = createOystehrClient(oystehrToken, input.secrets); + + // Resolve encounterId (appointment-scoped) and patientId. Patient-level + // sends (from the patient profile) pass patientId directly and have no + // associated encounter. + let encounterId = ''; + let patientId = directPatientId || ''; + if (appointmentId) { + const encounters = ( + await oystehr.fhir.search({ + resourceType: 'Encounter', + params: [{ name: 'appointment', value: appointmentId }], + }) + ).unbundle(); + const encounter = encounters[0]; + encounterId = encounter?.id || ''; + patientId = patientId || encounter?.subject?.reference?.replace('Patient/', '') || ''; + } + + // Authorization: the caller must be connected to the patient (or be an EHR user — + // userHasAccessToPatient allows those implicitly). Without this, any authenticated + // account could read another patient's forms AND their QuestionnaireResponse answers + // by guessing/replaying an appointment id. + if (patientId) { + const callerToken = input.headers?.Authorization?.replace('Bearer ', ''); + const caller = callerToken ? await getUser(callerToken, input.secrets).catch(() => undefined) : undefined; + const hasAccess = caller ? await userHasAccessToPatient(caller, patientId, oystehr) : false; + if (!hasAccess) { + return { + statusCode: 403, + body: JSON.stringify({ error: 'ACCESS_DENIED', message: 'You do not have access to this form.' }), + }; + } + } + + // If the caller didn't provide the intake questionnaire URL, look it up from the encounter's QR + let intakeUrl = intakeQuestionnaireUrl; + if (!intakeUrl && encounterId) { + const qrs = ( + await oystehr.fhir.search({ + resourceType: 'QuestionnaireResponse', + params: [ + { name: 'encounter', value: `Encounter/${encounterId}` }, + { name: '_sort', value: '-_lastUpdated' }, + { name: '_count', value: '10' }, + ], + }) + ).unbundle(); + + // Find the intake QR by excluding practice-managed tagged QRs + const intakeQr = qrs.find( + (qr) => qr.questionnaire && !qr.meta?.tag?.some((t) => t.code === PRACTICE_MANAGED_QR_TAG.code) + ); + if (intakeQr?.questionnaire) { + intakeUrl = intakeQr.questionnaire.split('|')[0]; + } + } + + // Standalone form by direct id. Works for both visit-scoped + // (/forms/:appointmentId/:questionnaireId) and patient-scoped + // (/forms/patient/:patientId/:questionnaireId) URLs and does NOT require + // an intake QR to already exist on the encounter — sent forms must open + // before the patient has started intake paperwork. + if (directQuestionnaireId) { + const allPracticeManaged = await getAllFhirSearchPages( + { + resourceType: 'Questionnaire', + params: [{ name: '_tag', value: PRACTICE_MANAGED_QUESTIONNAIRE_TAG.code }], + }, + oystehr + ); + const q = allPracticeManaged.find((pq) => pq.id === directQuestionnaireId && pq.status === 'active'); + if (!q) { + return { statusCode: 200, body: JSON.stringify({ questionnaires: [], encounterId, patientId }) }; + } + + // Prefer an existing QR on this encounter; fall back to a patient-level + // QR (no encounter) for patient-scoped sends. + let matchingQr: QuestionnaireResponse | undefined; + if (encounterId) { + const encounterQrs = ( + await oystehr.fhir.search({ + resourceType: 'QuestionnaireResponse', + params: [ + { name: 'encounter', value: `Encounter/${encounterId}` }, + { name: 'questionnaire', value: q.url || '' }, + ], + }) + ).unbundle(); + matchingQr = encounterQrs[0]; + } + if (!matchingQr && patientId) { + const patientQrs = ( + await oystehr.fhir.search({ + resourceType: 'QuestionnaireResponse', + params: [ + { name: 'subject', value: `Patient/${patientId}` }, + { name: 'questionnaire', value: q.url || '' }, + ], + }) + ).unbundle(); + matchingQr = patientQrs.find((qr) => !qr.encounter); + } + + return { + statusCode: 200, + body: JSON.stringify({ + questionnaires: [ + { + id: q.id, + url: q.url, + version: q.version, + title: q.title || q.name || 'Untitled', + status: q.status, + questionnaireResponseId: matchingQr?.id, + questionnaireResponseStatus: matchingQr?.status, + questionnaireResponseItems: matchingQr?.item, + item: q.item, + }, + ], + encounterId, + patientId, + }), + }; + } + + if (!intakeUrl) { + return { + statusCode: 200, + body: JSON.stringify({ questionnaires: [], encounterId, patientId }), + }; + } + + // Fetch the intake questionnaire to determine the insertion point + const intakeQuestionnaires = ( + await oystehr.fhir.search({ + resourceType: 'Questionnaire', + params: [ + { name: 'url', value: intakeUrl }, + { name: '_sort', value: '-_lastUpdated' }, + { name: '_count', value: '1' }, + ], + }) + ).unbundle(); + const intakeQuestionnaire = intakeQuestionnaires[0]; + const insertAfterPageLinkId = intakeQuestionnaire ? findInsertionPoint(intakeQuestionnaire.item || []) : undefined; + + // Find practice-managed questionnaires. Include retired so that existing QRs + // tied to soft-deleted questionnaires can still be matched and rendered in + // the EHR, but filter to active for any flow that surfaces a new form. + const allPracticeManaged = await getAllFhirSearchPages( + { + resourceType: 'Questionnaire', + params: [{ name: '_tag', value: PRACTICE_MANAGED_QUESTIONNAIRE_TAG.code }], + }, + oystehr + ); + const activePracticeManaged = allPracticeManaged.filter((q) => q.status === 'active'); + + // A standalone direct-id request returns just that form. Attaching forms to the intake + // itself is handled by paperwork flows (a separate change); here that set is empty, but + // forms with an existing QR on the encounter are still surfaced below (e.g. completed + // forms sent to the patient, shown in the EHR). + let associated: Questionnaire[]; + if (directQuestionnaireId) { + // Standalone form lookups must not serve retired forms to patients. + associated = activePracticeManaged.filter((q) => q.id === directQuestionnaireId); + } else { + associated = []; + } + + // Check for existing QRs on this encounter + const existingQrs = encounterId + ? ( + await oystehr.fhir.search({ + resourceType: 'QuestionnaireResponse', + params: [{ name: 'encounter', value: `Encounter/${encounterId}` }], + }) + ).unbundle() + : []; + + // Also include any practice-managed questionnaires that have existing QRs on this + // encounter but aren't in the associated set (e.g. standalone forms sent to patient) + const associatedIds = new Set(associated.map((q) => q.id)); + const withExistingQrs = allPracticeManaged.filter((q) => { + if (associatedIds.has(q.id!)) return false; + return existingQrs.some((qr) => qr.questionnaire?.split('|')[0] === q.url); + }); + const allRelevant = [...associated, ...withExistingQrs]; + + const questionnaires = allRelevant.map((q) => { + const matchingQr = existingQrs.find((qr) => qr.questionnaire?.split('|')[0] === q.url); + return { + id: q.id, + url: q.url, + version: q.version, + title: q.title || q.name || 'Untitled', + status: q.status, + questionnaireResponseId: matchingQr?.id, + questionnaireResponseStatus: matchingQr?.status, + questionnaireResponseItems: matchingQr?.item, + item: q.item, + }; + }); + + return { + statusCode: 200, + body: JSON.stringify({ questionnaires, encounterId, patientId, insertAfterPageLinkId }), + }; + } +); diff --git a/packages/zambdas/src/patient/paperwork/save-practice-managed-response/index.ts b/packages/zambdas/src/patient/paperwork/save-practice-managed-response/index.ts new file mode 100644 index 0000000000..7e119ef947 --- /dev/null +++ b/packages/zambdas/src/patient/paperwork/save-practice-managed-response/index.ts @@ -0,0 +1,122 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { QuestionnaireResponse, QuestionnaireResponseItem } from 'fhir/r4b'; +import { MISSING_REQUEST_BODY, MISSING_REQUIRED_PARAMETERS, PRACTICE_MANAGED_QR_TAG } from 'utils'; +import { + createOystehrClient, + getAuth0Token, + getUser, + userHasAccessToPatient, + wrapHandler, + ZambdaInput, +} from '../../../shared'; + +let oystehrToken: string; + +export const index = wrapHandler( + 'save-practice-managed-response', + async (input: ZambdaInput): Promise => { + if (!input.body) throw MISSING_REQUEST_BODY; + if (!input.secrets) throw new Error('No secrets provided'); + + const { + questionnaireResponseId, + questionnaireUrl, + questionnaireVersion, + encounterId, + patientId, + pageIndex, + answers, + complete, + } = JSON.parse(input.body) as { + questionnaireResponseId?: string; + questionnaireUrl: string; + questionnaireVersion?: string; + encounterId: string; + patientId: string; + pageIndex: number; + answers: QuestionnaireResponseItem; + /** Set on the final page save of an in-visit form so the QR finishes 'completed' — + * otherwise the intake's insertion check re-enters the form forever. */ + complete?: boolean; + }; + + if (!questionnaireUrl) throw MISSING_REQUIRED_PARAMETERS(['questionnaireUrl']); + if (!patientId) throw MISSING_REQUIRED_PARAMETERS(['patientId']); + // encounterId is optional — patient-level forms (sent from the patient + // profile, not a specific visit) have no associated encounter. + + if (!oystehrToken) { + oystehrToken = await getAuth0Token(input.secrets); + } + const oystehr = createOystehrClient(oystehrToken, input.secrets); + + const canonicalRef = questionnaireVersion ? `${questionnaireUrl}|${questionnaireVersion}` : questionnaireUrl; + + // Authorization: writes are gated on the caller being connected to the patient (EHR + // users pass implicitly). For updates the QR's own subject is authoritative — the body + // patientId is caller-supplied and must not be trusted to authorize writing someone + // else's response. + const existing = questionnaireResponseId + ? await oystehr.fhir.get({ + resourceType: 'QuestionnaireResponse', + id: questionnaireResponseId, + }) + : undefined; + const patientIdToAuthorize = existing?.subject?.reference?.replace('Patient/', '') || patientId; + const callerToken = input.headers?.Authorization?.replace('Bearer ', ''); + const caller = callerToken ? await getUser(callerToken, input.secrets).catch(() => undefined) : undefined; + const hasAccess = caller ? await userHasAccessToPatient(caller, patientIdToAuthorize, oystehr) : false; + if (!hasAccess) { + return { + statusCode: 403, + body: JSON.stringify({ error: 'ACCESS_DENIED', message: 'You do not have access to this form.' }), + }; + } + + if (existing) { + // Patch existing QR — update the page at pageIndex + + const items = existing.item || []; + // Ensure the array is large enough + while (items.length <= pageIndex) { + items.push({ linkId: `page-${items.length}` }); + } + items[pageIndex] = answers; + + const updated = await oystehr.fhir.update({ + ...existing, + item: items, + status: complete ? 'completed' : 'in-progress', + }); + + return { + statusCode: 200, + body: JSON.stringify({ + questionnaireResponseId: updated.id, + status: updated.status, + }), + }; + } else { + // Create new QR with practice-managed tag for identification + const newQr: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + meta: { tag: [PRACTICE_MANAGED_QR_TAG] }, + questionnaire: canonicalRef, + status: complete ? 'completed' : 'in-progress', + subject: { reference: `Patient/${patientId}` }, + ...(encounterId ? { encounter: { reference: `Encounter/${encounterId}` } } : {}), + item: [answers], + }; + + const created = await oystehr.fhir.create(newQr); + + return { + statusCode: 200, + body: JSON.stringify({ + questionnaireResponseId: created.id, + status: created.status, + }), + }; + } + } +); diff --git a/packages/zambdas/src/shared/helpers.ts b/packages/zambdas/src/shared/helpers.ts index 52a2b692a8..57fc6f75c8 100644 --- a/packages/zambdas/src/shared/helpers.ts +++ b/packages/zambdas/src/shared/helpers.ts @@ -20,6 +20,7 @@ import { getSecret, getTimezone, INVALID_INPUT_ERROR, + LITE_INTAKE_PAPERWORK_URL, pickFirstValueFromAnswerItem, PRIVATE_EXTENSION_BASE_URL, PUBLIC_EXTENSION_BASE_URL, @@ -236,17 +237,18 @@ export function getOtherOfficesForLocation(location: Location): { display: strin } export function checkPaperworkComplete(questionnaireResponse: QuestionnaireResponse): boolean { - if (questionnaireResponse?.status === 'completed' || questionnaireResponse?.status === 'amended') { - let photoIdFront: Attachment | undefined; - const photoIdFrontItem = findQuestionnaireResponseItemLinkId('photo-id-front', questionnaireResponse?.item ?? []); - if (photoIdFrontItem) { - photoIdFront = pickFirstValueFromAnswerItem(photoIdFrontItem, 'attachment'); - } - if (photoIdFront) { - return true; - } + if (questionnaireResponse?.status !== 'completed' && questionnaireResponse?.status !== 'amended') { + return false; + } + // The lite intake flow never collects a photo ID — a finalized QR is sufficient. + if (questionnaireResponse.questionnaire?.startsWith(LITE_INTAKE_PAPERWORK_URL)) { + return true; } - return false; + const photoIdFrontItem = findQuestionnaireResponseItemLinkId('photo-id-front', questionnaireResponse?.item ?? []); + const photoIdFront: Attachment | undefined = photoIdFrontItem + ? pickFirstValueFromAnswerItem(photoIdFrontItem, 'attachment') + : undefined; + return Boolean(photoIdFront); } export function resolveTimezone(schedule?: Schedule, location?: Location, fallback: string = TIMEZONES[0]): string { diff --git a/scripts/reprocess-vanderbilt-qr.ts b/scripts/reprocess-vanderbilt-qr.ts new file mode 100644 index 0000000000..350733be29 --- /dev/null +++ b/scripts/reprocess-vanderbilt-qr.ts @@ -0,0 +1,211 @@ +/** + * Re-evaluates calculated expressions on a Vanderbilt QuestionnaireResponse and + * writes the computed values (screen booleans + rationale strings) back into the + * QR's "results" group. Use this after updating expressions on the Questionnaire + * to backfill existing QRs. + * + * Usage: + * npx env-cmd -f apps/ehr/env/tests..json npx tsx scripts/reprocess-vanderbilt-qr.ts + * + * Required env vars: AUTH0_CLIENT, AUTH0_SECRET, PROJECT_ID. + * Optional overrides: AUTH0_ENDPOINT, AUTH0_AUDIENCE, FHIR_API. + */ + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) { + console.error(`Missing required env var: ${name}`); + console.error('Provide credentials via env file, e.g.'); + console.error( + ` npx env-cmd -f apps/ehr/env/tests..json npx tsx scripts/reprocess-vanderbilt-qr.ts ` + ); + process.exit(1); + } + return v; +} + +const ENV = { + AUTH0_ENDPOINT: process.env.AUTH0_ENDPOINT || 'https://auth.zapehr.com/oauth/token', + AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE || 'https://api.zapehr.com', + AUTH0_CLIENT: requireEnv('AUTH0_CLIENT'), + AUTH0_SECRET: requireEnv('AUTH0_SECRET'), + FHIR_API: process.env.FHIR_API || 'https://fhir-api.zapehr.com', + PROJECT_ID: requireEnv('PROJECT_ID'), +}; + +const APPT_ID = process.argv[2]; +if (!APPT_ID) { + console.error('Usage: npx tsx scripts/reprocess-vanderbilt-qr.ts '); + process.exit(1); +} + +const HIDDEN_URL = 'http://hl7.org/fhir/StructureDefinition/questionnaire-hidden'; +const CALC_URL = 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression'; +const PRACTICE_MANAGED_TAG_CODE = 'practice-managed'; + +async function getToken(): Promise { + const res = await fetch(ENV.AUTH0_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: ENV.AUTH0_CLIENT, + client_secret: ENV.AUTH0_SECRET, + audience: ENV.AUTH0_AUDIENCE, + }), + }); + const data = (await res.json()) as { access_token: string }; + return data.access_token; +} + +async function fhirGet(token: string, path: string): Promise { + const res = await fetch(`${ENV.FHIR_API}/r4/${path}`, { + headers: { Authorization: `Bearer ${token}`, 'x-zapehr-project-id': ENV.PROJECT_ID }, + }); + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status} ${await res.text()}`); + return res.json(); +} + +async function fhirPut(token: string, resourceType: string, id: string, body: any): Promise { + const res = await fetch(`${ENV.FHIR_API}/r4/${resourceType}/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'x-zapehr-project-id': ENV.PROJECT_ID, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`PUT ${resourceType}/${id} failed: ${res.status} ${await res.text()}`); + } + return res.json(); +} + +function isHidden(item: any): boolean { + return item.extension?.some((e: any) => e.url === HIDDEN_URL && e.valueBoolean === true); +} + +function getExpression(item: any): string | undefined { + const ext = item.extension?.find((e: any) => e.url === CALC_URL); + if (ext?.valueExpression?.language === 'text/javascript') return ext.valueExpression.expression; + return undefined; +} + +function extractAnswer(answer: any): any { + if (answer.valueCoding?.code) return parseInt(answer.valueCoding.code, 10) || answer.valueCoding.code; + if (answer.valueString !== undefined) return answer.valueString; + if (answer.valueBoolean !== undefined) return answer.valueBoolean; + if (answer.valueInteger !== undefined) return answer.valueInteger; + if (answer.valueDecimal !== undefined) return answer.valueDecimal; + return undefined; +} + +function buildAnswers(items: any[], out: Record = {}): Record { + for (const it of items || []) { + if (it.answer?.[0]) { + const v = extractAnswer(it.answer[0]); + if (v !== undefined) out[it.linkId] = v; + } + if (it.item) buildAnswers(it.item, out); + } + return out; +} + +function flattenItems(items: any[], out: any[] = []): any[] { + for (const it of items || []) { + out.push(it); + if (it.item) flattenItems(it.item, out); + } + return out; +} + +function evaluateExpressions(allItems: any[], answers: Record): Record { + const results: Record = {}; + for (let pass = 0; pass < 2; pass++) { + for (const item of allItems) { + const expr = getExpression(item); + if (!expr) continue; + try { + const ctx = { ...answers, ...results }; + const fn = new Function('answers', `with(answers) { return (${expr}); }`); + results[item.linkId] = fn(ctx); + } catch (e: any) { + console.warn(` failed to evaluate ${item.linkId}:`, e.message); + } + } + } + return results; +} + +function valueFor(v: any): any { + if (typeof v === 'boolean') return { valueBoolean: v }; + if (typeof v === 'number') return { valueDecimal: v }; + return { valueString: String(v) }; +} + +async function main(): Promise { + const token = await getToken(); + + console.log(`Looking up encounter for appointment ${APPT_ID}...`); + const encBundle = await fhirGet(token, `Encounter?appointment=${APPT_ID}`); + const encounter = encBundle.entry?.[0]?.resource; + if (!encounter) throw new Error('No encounter found for appointment'); + console.log(` encounter: ${encounter.id}`); + + console.log('Fetching QuestionnaireResponses on encounter...'); + const qrBundle = await fhirGet( + token, + `QuestionnaireResponse?encounter=Encounter/${encounter.id}&_sort=-_lastUpdated&_count=50` + ); + const qrs = (qrBundle.entry || []).map((e: any) => e.resource); + const pmQrs = qrs.filter((qr: any) => qr.meta?.tag?.some((t: any) => t.code === PRACTICE_MANAGED_TAG_CODE)); + console.log(` found ${qrs.length} QRs total, ${pmQrs.length} practice-managed`); + + if (pmQrs.length === 0) { + console.log('No practice-managed QRs to reprocess.'); + return; + } + + const qr = pmQrs[0]; + console.log(`\nReprocessing QR ${qr.id}`); + console.log(` questionnaire: ${qr.questionnaire}`); + + const qUrl = qr.questionnaire?.split('|')[0]; + if (!qUrl) throw new Error('QR has no questionnaire canonical URL'); + const qBundle = await fhirGet(token, `Questionnaire?url=${encodeURIComponent(qUrl)}&_sort=-_lastUpdated&_count=1`); + const questionnaire = qBundle.entry?.[0]?.resource; + if (!questionnaire) throw new Error('Questionnaire not found for QR'); + console.log(` loaded Questionnaire ${questionnaire.id} (${questionnaire.title})`); + + const answers = buildAnswers(qr.item || []); + console.log(` extracted ${Object.keys(answers).length} raw answers`); + + const allItems = flattenItems(questionnaire.item || []); + const computedItems = allItems.filter((it: any) => isHidden(it) && getExpression(it)); + console.log(` evaluating ${computedItems.length} calculated items...`); + const results = evaluateExpressions(allItems, answers); + + const resultsGroupItems = computedItems + .filter((it: any) => results[it.linkId] !== undefined) + .map((it: any) => ({ linkId: it.linkId, answer: [valueFor(results[it.linkId])] })); + + console.log('\n computed values:'); + for (const it of resultsGroupItems) { + const v = it.answer[0].valueBoolean ?? it.answer[0].valueString ?? it.answer[0].valueDecimal; + console.log(` ${it.linkId}: ${v}`); + } + + const newItems = (qr.item || []).filter((i: any) => i.linkId !== 'results'); + newItems.push({ linkId: 'results', item: resultsGroupItems }); + + const updatedQr = { ...qr, item: newItems }; + console.log(`\nWriting updated QR ${qr.id}...`); + await fhirPut(token, 'QuestionnaireResponse', qr.id, updatedQr); + console.log('Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/update-vanderbilt-questionnaire.ts b/scripts/update-vanderbilt-questionnaire.ts new file mode 100644 index 0000000000..ed26610544 --- /dev/null +++ b/scripts/update-vanderbilt-questionnaire.ts @@ -0,0 +1,110 @@ +/** + * Overwrites the existing Vanderbilt Questionnaire resource in FHIR with the + * updated JSON from ~/Downloads/vanderbiltParent.json, preserving id and the + * practice-managed tag. + * + * Usage: + * npx env-cmd -f apps/ehr/env/tests..json npx tsx scripts/update-vanderbilt-questionnaire.ts + * + * Required env vars: AUTH0_CLIENT, AUTH0_SECRET, PROJECT_ID. + * Optional overrides: AUTH0_ENDPOINT, AUTH0_AUDIENCE, FHIR_API. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) { + console.error(`Missing required env var: ${name}`); + console.error('Provide credentials via env file, e.g.'); + console.error(` npx env-cmd -f apps/ehr/env/tests..json npx tsx scripts/update-vanderbilt-questionnaire.ts`); + process.exit(1); + } + return v; +} + +const ENV = { + AUTH0_ENDPOINT: process.env.AUTH0_ENDPOINT || 'https://auth.zapehr.com/oauth/token', + AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE || 'https://api.zapehr.com', + AUTH0_CLIENT: requireEnv('AUTH0_CLIENT'), + AUTH0_SECRET: requireEnv('AUTH0_SECRET'), + FHIR_API: process.env.FHIR_API || 'https://fhir-api.zapehr.com', + PROJECT_ID: requireEnv('PROJECT_ID'), +}; + +const JSON_PATH = path.join(os.homedir(), 'Downloads', 'vanderbiltParent.json'); +const PRACTICE_MANAGED_TAG = { + system: 'https://fhir.ottehr.com/CodeSystem/questionnaire-type', + code: 'practice-managed', +}; + +async function getToken(): Promise { + const res = await fetch(ENV.AUTH0_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: ENV.AUTH0_CLIENT, + client_secret: ENV.AUTH0_SECRET, + audience: ENV.AUTH0_AUDIENCE, + }), + }); + const data = (await res.json()) as { access_token: string }; + return data.access_token; +} + +async function fhirGet(token: string, p: string): Promise { + const res = await fetch(`${ENV.FHIR_API}/r4/${p}`, { + headers: { Authorization: `Bearer ${token}`, 'x-zapehr-project-id': ENV.PROJECT_ID }, + }); + if (!res.ok) throw new Error(`GET ${p} failed: ${res.status} ${await res.text()}`); + return res.json(); +} + +async function fhirPut(token: string, resourceType: string, id: string, body: any): Promise { + const res = await fetch(`${ENV.FHIR_API}/r4/${resourceType}/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'x-zapehr-project-id': ENV.PROJECT_ID, + }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`PUT ${resourceType}/${id} failed: ${res.status} ${await res.text()}`); + return res.json(); +} + +async function main(): Promise { + const token = await getToken(); + const updated = JSON.parse(fs.readFileSync(JSON_PATH, 'utf8')); + const canonicalUrl = + updated.url || 'https://ottehr.com/FHIR/Questionnaire/nichq-vanderbilt-assessment-scale-parent-informant'; + updated.url = canonicalUrl; + + console.log(`Looking up existing Vanderbilt Q by URL ${canonicalUrl}...`); + const bundle = await fhirGet( + token, + `Questionnaire?url=${encodeURIComponent(canonicalUrl)}&_sort=-_lastUpdated&_count=1` + ); + const existing = bundle.entry?.[0]?.resource; + if (!existing) throw new Error('No existing Vanderbilt Questionnaire found with that URL'); + console.log(` existing id: ${existing.id}`); + + const merged = { + ...updated, + resourceType: 'Questionnaire', + id: existing.id, + meta: { tag: [PRACTICE_MANAGED_TAG] }, + }; + + console.log('Writing updated Questionnaire...'); + await fhirPut(token, 'Questionnaire', existing.id, merged); + console.log('Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});