From 371c6647f81d8896a30e1a9f506fce21a270f995 Mon Sep 17 00:00:00 2001 From: Daniel Abrams Date: Fri, 12 Jun 2026 16:11:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20paperwork=20flows=20=E2=80=94=20compose?= =?UTF-8?q?=20base-intake=20and=20per-service=20forms=20at=20booking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Practices design their own paperwork flows: which forms every booking on a base intake includes, plus per-service form bundles, composed at booking time. Data model (FHIR List, tagged practice-paperwork-flow): - Service flow: { slug, name, base: standard | consent-only, ordered formIds }, referenced by ServiceCategory config (paperworkFlowId); assigning a service to a flow is reconciled across categories (single-valued field). - Base flow: 3 fixed Lists, one per base intake canonical (in-person / virtual / consent-only), marked by a paperwork-flow-canonical extension; auto-created idempotently (ensureBaseFlows); identity is fixed, only their form list edits. Booking time (create-appointment): - The slot's service category resolves its paperworkFlowId; a consent-only flow base maps to the consent-form-only subtype (lite intake canonical); the flow id is stamped on the Encounter and Appointment. Flow-lookup failures fall back to the mode default and are logged, never silently. Form resolution (get-practice-managed-questionnaires): - A booking's forms = base-intake forms (the base flow bound to the resolved canonical) + service-flow forms, base-first, de-duped keep-first (composeFormIds). Same-base isolation falls out: a consent-only booking never inherits standard-intake forms. Flow searches are paginated. The standalone send-form path is unchanged. Admin (Paperwork Flows tab): - Base intake cards (In-person / Virtual / Consent-only) with auto-saving add/remove/reorder of attached forms — debounced reorder, sequenced saves with a stable unmount flush, and failed saves surfacing an error and reconciling to refetched server state. - Service flows table + dialog (name, auto-slug via shared slugify, base, forms, applies-to-services) with "New flow" scoped to that section. - admin-{list,create,update,delete}-paperwork-flow zambdas with base-flow guard rails (not creatable/deletable; only formIds editable; delete refuses to proceed when the guard fetch fails). Unit tests: flow List round-trip, base-flow catalog, composeFormIds ordering/dedup, canonical mapping for the consent-only base. Co-Authored-By: Claude Opus 4.8 --- apps/ehr/src/api/api.ts | 42 + .../admin/PaperworkPackagesAdminPage.tsx | 715 ++++++++++++++++++ apps/ehr/src/pages/AdminPage.tsx | 11 + config/oystehr-core/zambdas.json | 28 + .../utils/lib/fhir/base-paperwork-flows.ts | 37 + packages/utils/lib/fhir/constants.ts | 28 + packages/utils/lib/fhir/healthcareService.ts | 18 + packages/utils/lib/fhir/index.ts | 2 + .../utils/lib/fhir/paperwork-flow.test.ts | 171 +++++ packages/utils/lib/fhir/paperwork-flow.ts | 114 +++ .../lib/fhir/serviceCategoryResolution.ts | 5 + packages/utils/lib/helpers/slugify.ts | 2 +- .../ehr/admin-create-paperwork-flow/index.ts | 60 ++ .../ehr/admin-delete-paperwork-flow/index.ts | 44 ++ .../ehr/admin-list-paperwork-flows/index.ts | 13 + .../src/ehr/admin-paperwork-flows/helpers.ts | 161 ++++ .../ehr/admin-service-categories/helpers.ts | 20 +- .../ehr/admin-update-paperwork-flow/index.ts | 80 ++ .../appointment/create-appointment/index.ts | 15 + .../validateRequestParameters.ts | 43 +- .../index.ts | 65 +- .../zambdas/test/appointment-helpers.test.ts | 31 +- 22 files changed, 1683 insertions(+), 22 deletions(-) create mode 100644 apps/ehr/src/features/admin/PaperworkPackagesAdminPage.tsx create mode 100644 packages/utils/lib/fhir/base-paperwork-flows.ts create mode 100644 packages/utils/lib/fhir/paperwork-flow.test.ts create mode 100644 packages/utils/lib/fhir/paperwork-flow.ts create mode 100644 packages/zambdas/src/ehr/admin-create-paperwork-flow/index.ts create mode 100644 packages/zambdas/src/ehr/admin-delete-paperwork-flow/index.ts create mode 100644 packages/zambdas/src/ehr/admin-list-paperwork-flows/index.ts create mode 100644 packages/zambdas/src/ehr/admin-paperwork-flows/helpers.ts create mode 100644 packages/zambdas/src/ehr/admin-update-paperwork-flow/index.ts diff --git a/apps/ehr/src/api/api.ts b/apps/ehr/src/api/api.ts index 90a3035f72..4a17dc8c87 100644 --- a/apps/ehr/src/api/api.ts +++ b/apps/ehr/src/api/api.ts @@ -171,6 +171,7 @@ import { OnDemandLabelXmlRequestInput, OnDemandLabelXmlRequestOutput, PaginatedResponse, + PaperworkFlow, PaperworkToPDFInput, PatientInstructionQuickPickData, PendingSupervisorApprovalInput, @@ -326,6 +327,10 @@ const ADMIN_LIST_SERVICE_CATEGORIES_ZAMBDA_ID = 'admin-list-service-categories'; const ADMIN_CREATE_SERVICE_CATEGORY_ZAMBDA_ID = 'admin-create-service-category'; const ADMIN_UPDATE_SERVICE_CATEGORY_ZAMBDA_ID = 'admin-update-service-category'; const ADMIN_DELETE_SERVICE_CATEGORY_ZAMBDA_ID = 'admin-delete-service-category'; +const ADMIN_LIST_PAPERWORK_FLOWS_ZAMBDA_ID = 'admin-list-paperwork-flows'; +const ADMIN_CREATE_PAPERWORK_FLOW_ZAMBDA_ID = 'admin-create-paperwork-flow'; +const ADMIN_UPDATE_PAPERWORK_FLOW_ZAMBDA_ID = 'admin-update-paperwork-flow'; +const ADMIN_DELETE_PAPERWORK_FLOW_ZAMBDA_ID = 'admin-delete-paperwork-flow'; const ADMIN_CREATE_PRACTITIONER_ROLE_ZAMBDA_ID = 'admin-create-practitioner-role'; const ADMIN_UPDATE_PRACTITIONER_ROLE_ZAMBDA_ID = 'admin-update-practitioner-role'; const ADMIN_DELETE_PRACTITIONER_ROLE_ZAMBDA_ID = 'admin-delete-practitioner-role'; @@ -2886,6 +2891,8 @@ export interface ServiceCategoryRuntimeConfig { /** Booking flows for this category — 'prebook' vs 'walk-in'. */ visitTypes: Array<'prebook' | 'walk-in'>; reasonsForVisit?: Array<{ label: string; value: string }>; + /** Practice paperwork flow this visit type uses (OTR-2309); undefined = default by mode. */ + paperworkFlowId?: string; } export interface ServiceCategory { @@ -2901,6 +2908,41 @@ export const listServiceCategories = async (oystehr: Oystehr): Promise<{ service return chooseJson(response); }; +// ── Practice Paperwork Flows (OTR-2309) ── + +/** A paperwork flow plus the ids of the service categories assigned to it. */ +export interface PaperworkFlowWithServices extends PaperworkFlow { + serviceIds: string[]; +} + +export const listPaperworkFlows = async ( + oystehr: Oystehr +): Promise<{ flows: PaperworkFlowWithServices[]; baseFlows: PaperworkFlow[] }> => { + const response = await oystehr.zambda.execute({ id: ADMIN_LIST_PAPERWORK_FLOWS_ZAMBDA_ID }); + return chooseJson(response); +}; + +export const createPaperworkFlow = async ( + oystehr: Oystehr, + input: { flow: Omit; serviceIds: string[] } +): Promise<{ flow: PaperworkFlow; serviceIds: string[] }> => { + const response = await oystehr.zambda.execute({ id: ADMIN_CREATE_PAPERWORK_FLOW_ZAMBDA_ID, ...input }); + return chooseJson(response); +}; + +export const updatePaperworkFlow = async ( + oystehr: Oystehr, + input: { flow: PaperworkFlow; serviceIds: string[] } +): Promise<{ flow: PaperworkFlow; serviceIds: string[] }> => { + const response = await oystehr.zambda.execute({ id: ADMIN_UPDATE_PAPERWORK_FLOW_ZAMBDA_ID, ...input }); + return chooseJson(response); +}; + +export const deletePaperworkFlow = async (oystehr: Oystehr, flowId: string): Promise<{ success: boolean }> => { + const response = await oystehr.zambda.execute({ id: ADMIN_DELETE_PAPERWORK_FLOW_ZAMBDA_ID, flowId }); + return chooseJson(response); +}; + export const createServiceCategory = async ( oystehr: Oystehr, serviceCategory: ServiceCategory diff --git a/apps/ehr/src/features/admin/PaperworkPackagesAdminPage.tsx b/apps/ehr/src/features/admin/PaperworkPackagesAdminPage.tsx new file mode 100644 index 0000000000..f708749db3 --- /dev/null +++ b/apps/ehr/src/features/admin/PaperworkPackagesAdminPage.tsx @@ -0,0 +1,715 @@ +// Admin surface for Practice Paperwork Flows (OTR-2309). +// +// A *paperwork flow* is a reusable bundle of a base intake (standard, resolved +// by visit mode — or consent-only lite) plus an ordered set of practice-managed +// forms. Service categories reference a flow; patients booking a service see +// that service's flow. This page authors flows and assigns them to services. +// +// Backed by the admin-{list,create,update,delete}-paperwork-flow zambdas via +// the api.ts client wrappers; flows persist as FHIR List resources and service +// assignments live on each ServiceCategory HealthcareService. +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import { + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + IconButton, + InputLabel, + ListItemText, + MenuItem, + OutlinedInput, + Paper, + Radio, + RadioGroup, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { enqueueSnackbar } from 'notistack'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + createPaperworkFlow, + deletePaperworkFlow, + listPaperworkFlows, + listPracticeManagedQuestionnaires, + listServiceCategories, + PaperworkFlowWithServices, + updatePaperworkFlow, +} from 'src/api/api'; +import { useApiClients } from 'src/hooks/useAppClients'; +import { PaperworkFlow, PaperworkFlowBase, slugify } from 'utils'; + +const FLOWS_QUERY_KEY = ['paperwork-flows']; +const FORMS_QUERY_KEY = ['paperwork-flows', 'practice-forms']; +const SERVICES_QUERY_KEY = ['paperwork-flows', 'service-categories']; + +const BASE_OPTIONS: Array<{ value: PaperworkFlowBase; label: string; helper: string }> = [ + { + value: 'standard', + label: 'Standard intake', + helper: 'Full intake, resolved by visit mode (in-person or virtual).', + }, + { + value: 'consent-only', + label: 'Consent only (lite)', + helper: 'Just the consent forms — no demographics/insurance intake.', + }, +]; + +const baseLabel = (b: PaperworkFlowBase): string => BASE_OPTIONS.find((o) => o.value === b)?.label ?? b; + +interface FormOption { + id: string; + label: string; +} +interface ServiceOption { + id: string; + label: string; +} + +type DraftFlow = { + id?: string; + slug: string; + name: string; + base: PaperworkFlowBase; + formIds: string[]; + serviceIds: string[]; +}; + +const BLANK_DRAFT: DraftFlow = { + slug: '', + name: '', + base: 'consent-only', + formIds: [], + serviceIds: [], +}; + +// ── Edit dialog ────────────────────────────────────────────────────────────── + +const FlowDialog: FC<{ + open: boolean; + initial?: DraftFlow; + formOptions: FormOption[]; + serviceOptions: ServiceOption[]; + saving: boolean; + onClose: () => void; + onSave: (value: DraftFlow) => void; +}> = ({ open, initial, formOptions, serviceOptions, saving, onClose, onSave }) => { + const [value, setValue] = useState(initial ?? BLANK_DRAFT); + // Track whether the user has hand-edited the slug; until then keep it synced + // to the name so most flows never need to think about slugs. + const [slugTouched, setSlugTouched] = useState(!!initial?.id); + + useEffect(() => { + setValue(initial ?? BLANK_DRAFT); + setSlugTouched(!!initial?.id); + }, [initial]); + + const setName = (name: string): void => setValue((v) => ({ ...v, name, slug: slugTouched ? v.slug : slugify(name) })); + + const labelForForm = (id: string): string => formOptions.find((f) => f.id === id)?.label ?? id; + const labelForService = (id: string): string => serviceOptions.find((s) => s.id === id)?.label ?? id; + + return ( + + {initial?.id ? 'Edit paperwork flow' : 'New paperwork flow'} + + + setName(e.target.value)} + fullWidth + /> + { + setSlugTouched(true); + setValue({ ...value, slug: e.target.value }); + }} + helperText="Stable identifier for this flow. Auto-derived from the name; edit if needed." + fullWidth + /> + + + Base paperwork + setValue({ ...value, base: e.target.value as PaperworkFlowBase })} + > + {BASE_OPTIONS.map((opt) => ( + } + label={ + + {opt.label} + + {opt.helper} + + + } + /> + ))} + + + + + Practice forms + + + + + Applies to services + + + + + + + + + + ); +}; + +// ── Base intake form editor ────────────────────────────────────────────────── + +const sameIds = (a: string[], b: string[]): boolean => a.length === b.length && a.every((x, i) => x === b[i]); + +// One card per base intake (In-person / Virtual / Consent-only). Its forms compose +// onto every booking on that base intake. Order matters (List.entry order). Edits +// auto-save: add/remove persist immediately, reorder is debounced (coalesces rapid +// up/down clicks); saves are sequenced per card so a later edit can't lose to an +// earlier one's response. +const BaseFlowCard: FC<{ + baseFlow: PaperworkFlow; + formOptions: FormOption[]; + onPersist: (formIds: string[]) => Promise; + onError: () => void; +}> = ({ baseFlow, formOptions, onPersist, onError }) => { + const [formIds, setFormIds] = useState(baseFlow.formIds); + const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + + const latestRef = useRef(baseFlow.formIds); + const lastSavedRef = useRef(baseFlow.formIds); + const inFlightRef = useRef(false); + const failedRef = useRef(false); + const debounceRef = useRef | undefined>(undefined); + // Callbacks live in refs so the unmount-flush effect can have [] deps — the parent passes + // inline arrows, and depending on them would run the "unmount" cleanup on every render, + // firing unsequenced saves mid-debounce. + const onPersistRef = useRef(onPersist); + onPersistRef.current = onPersist; + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + // Accept an external update (e.g. a refetch) when there's no unsaved local divergence — + // OR after a failed save, where the server is the only trustworthy state (server wins; + // the user re-applies their edit). + useEffect(() => { + if (sameIds(latestRef.current, lastSavedRef.current) || failedRef.current) { + setFormIds(baseFlow.formIds); + latestRef.current = baseFlow.formIds; + lastSavedRef.current = baseFlow.formIds; + failedRef.current = false; + } + }, [baseFlow.formIds]); + + const save = useCallback(async (): Promise => { + if (inFlightRef.current) return; // a save is running; it re-checks latestRef on completion + if (sameIds(latestRef.current, lastSavedRef.current)) return; + inFlightRef.current = true; + setStatus('saving'); + const ids = latestRef.current; + try { + await onPersistRef.current(ids); + lastSavedRef.current = ids; + failedRef.current = false; + } catch { + inFlightRef.current = false; + failedRef.current = true; // let the refetch reconcile this card to server state + setStatus('error'); + onErrorRef.current(); // snackbar + refetch + return; + } + inFlightRef.current = false; + if (!sameIds(latestRef.current, lastSavedRef.current)) { + void save(); // edits arrived during the save — persist the latest + } else { + setStatus('saved'); + } + }, []); + + const apply = useCallback( + (next: string[], debounce: boolean): void => { + latestRef.current = next; + setFormIds(next); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (debounce) debounceRef.current = setTimeout(() => void save(), 500); + else void save(); + }, + [save] + ); + + // Flush a pending debounced reorder if the card unmounts (e.g. navigation). [] deps — + // this must run ONLY at unmount, and it flushes through save() so it can never race a + // save already in flight. + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + void save(); + }, + [save] + ); + + const labelFor = (id: string): string => formOptions.find((f) => f.id === id)?.label ?? id; + const available = formOptions.filter((f) => !formIds.includes(f.id)); + + const move = (i: number, dir: -1 | 1): void => { + const j = i + dir; + if (j < 0 || j >= formIds.length) return; + const next = [...formIds]; + [next[i], next[j]] = [next[j], next[i]]; + apply(next, true); // debounce — coalesce rapid reorder clicks + }; + + return ( + + + + {baseFlow.name} + + {status === 'saving' && } + {status === 'saved' && ( + + Saved + + )} + {status === 'error' && ( + + Save failed — list reloaded + + )} + + + {formIds.length === 0 ? ( + + No forms attached. + + ) : ( + formIds.map((id, i) => ( + + move(i, -1)}> + + + move(i, 1)}> + + + + {labelFor(id)} + + + apply( + formIds.filter((f) => f !== id), + false + ) + } + > + + + + )) + )} + + + Add form + + + + ); +}; + +// ── List page ──────────────────────────────────────────────────────────────── + +const PaperworkPackagesAdminPage: FC = () => { + const { oystehrZambda } = useApiClients(); + const queryClient = useQueryClient(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(undefined); + + const { data: flowsData, isLoading: flowsLoading } = useQuery({ + queryKey: FLOWS_QUERY_KEY, + queryFn: async () => { + if (!oystehrZambda) return { flows: [] as PaperworkFlowWithServices[], baseFlows: [] as PaperworkFlow[] }; + return listPaperworkFlows(oystehrZambda); + }, + enabled: !!oystehrZambda, + }); + + const { data: formsData } = useQuery({ + queryKey: FORMS_QUERY_KEY, + queryFn: async () => { + if (!oystehrZambda) return { questionnaires: [] as any[] }; + return listPracticeManagedQuestionnaires(oystehrZambda); + }, + enabled: !!oystehrZambda, + }); + + const { data: servicesData } = useQuery({ + queryKey: SERVICES_QUERY_KEY, + queryFn: async () => { + if (!oystehrZambda) return { serviceCategories: [] }; + return listServiceCategories(oystehrZambda); + }, + enabled: !!oystehrZambda, + }); + + const flows = flowsData?.flows ?? []; + const baseFlows = flowsData?.baseFlows ?? []; + + const formOptions: FormOption[] = useMemo( + () => + (formsData?.questionnaires ?? []) + .map((q: any) => ({ id: q.id as string, label: (q.title || q.name || q.id) as string })) + .filter((o: FormOption) => !!o.id) + .sort((a: FormOption, b: FormOption) => a.label.localeCompare(b.label)), + [formsData] + ); + + const serviceOptions: ServiceOption[] = useMemo( + () => + (servicesData?.serviceCategories ?? []) + .filter((s) => !!s.id) + .map((s) => ({ id: s.id as string, label: s.name || s.code })) + .sort((a, b) => a.label.localeCompare(b.label)), + [servicesData] + ); + + const saveMutation = useMutation({ + mutationFn: async (draft: DraftFlow) => { + if (!oystehrZambda) throw new Error('Not connected'); + const { id, serviceIds, ...flow } = draft; + if (id) { + return updatePaperworkFlow(oystehrZambda, { flow: { id, ...flow }, serviceIds }); + } + return createPaperworkFlow(oystehrZambda, { flow, serviceIds }); + }, + onSuccess: async (_data, draft) => { + enqueueSnackbar(`Saved "${draft.name}"`, { variant: 'success' }); + setDialogOpen(false); + await queryClient.invalidateQueries({ queryKey: FLOWS_QUERY_KEY }); + await queryClient.invalidateQueries({ queryKey: SERVICES_QUERY_KEY }); + }, + onError: (err: any) => { + enqueueSnackbar(`Could not save flow: ${err?.message ?? 'unknown error'}`, { variant: 'error' }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (flowId: string) => { + if (!oystehrZambda) throw new Error('Not connected'); + return deletePaperworkFlow(oystehrZambda, flowId); + }, + onSuccess: async () => { + enqueueSnackbar('Flow deleted', { variant: 'success' }); + await queryClient.invalidateQueries({ queryKey: FLOWS_QUERY_KEY }); + await queryClient.invalidateQueries({ queryKey: SERVICES_QUERY_KEY }); + }, + onError: (err: any) => { + enqueueSnackbar(`Could not delete flow: ${err?.message ?? 'unknown error'}`, { variant: 'error' }); + }, + }); + + // Base intake cards auto-save each edit. Persist then patch the FLOWS cache in place + // (no refetch — a refetch could clobber an in-progress edit; BaseFlowCard reconciles). + const persistBaseFlow = useCallback( + async (flow: PaperworkFlow, formIds: string[]): Promise => { + if (!oystehrZambda) throw new Error('Not connected'); + await updatePaperworkFlow(oystehrZambda, { flow: { ...flow, formIds }, serviceIds: [] }); + queryClient.setQueryData<{ flows: PaperworkFlowWithServices[]; baseFlows: PaperworkFlow[] }>( + FLOWS_QUERY_KEY, + (old) => + old ? { ...old, baseFlows: old.baseFlows.map((b) => (b.id === flow.id ? { ...b, formIds } : b)) } : old + ); + }, + [oystehrZambda, queryClient] + ); + + const handleBaseFlowError = useCallback((): void => { + enqueueSnackbar('Could not save base intake forms', { variant: 'error' }); + void queryClient.invalidateQueries({ queryKey: FLOWS_QUERY_KEY }); + }, [queryClient]); + + const openNew = (): void => { + setEditing(undefined); + setDialogOpen(true); + }; + + const openEdit = (flow: PaperworkFlowWithServices): void => { + setEditing({ + id: flow.id, + slug: flow.slug, + name: flow.name, + base: flow.base, + formIds: flow.formIds, + serviceIds: flow.serviceIds, + }); + setDialogOpen(true); + }; + + const handleDelete = (flow: PaperworkFlowWithServices): void => { + if (!flow.id) return; + if (!window.confirm(`Delete the "${flow.name}" paperwork flow? Services using it will fall back to the default.`)) + return; + deleteMutation.mutate(flow.id); + }; + + return ( + + + Paperwork Flows + + + A booking's paperwork is composed from its base intake forms (below — applied to every booking on that + intake) plus any service flow forms (further down — applied only to that flow's service categories). + + + + Base intake forms + + + Forms attached here appear on every booking that uses the base intake. Attach a form to both In-person and + Virtual for all standard visits, or to one for that mode only. + + + {flowsLoading && } + {baseFlows.map((bf) => ( + persistBaseFlow(bf, formIds)} + onError={handleBaseFlowError} + /> + ))} + + + + Service flows + + + + + + + + Flow + Base paperwork + Practice forms + Applies to services + + Actions + + + + + {flowsLoading && ( + + + + + + )} + {!flowsLoading && flows.length === 0 && ( + + + No paperwork flows yet. Click "New flow" to create one. + + + )} + {flows.map((flow) => ( + + + + {flow.name} + + + {flow.slug} + + + {baseLabel(flow.base)} + + + {flow.formIds.length === 0 ? ( + + — + + ) : ( + flow.formIds.map((id) => ( + f.id === id)?.label ?? id} + sx={{ mb: 0.5 }} + /> + )) + )} + + + + + {flow.serviceIds.length === 0 ? ( + + — + + ) : ( + flow.serviceIds.map((id) => ( + s.id === id)?.label ?? id} + sx={{ mb: 0.5 }} + /> + )) + )} + + + + + openEdit(flow)}> + + + + + handleDelete(flow)} disabled={deleteMutation.isPending}> + + + + + + ))} + +
+
+ + setDialogOpen(false)} + onSave={(value) => saveMutation.mutate(value)} + /> +
+ ); +}; + +export default PaperworkPackagesAdminPage; diff --git a/apps/ehr/src/pages/AdminPage.tsx b/apps/ehr/src/pages/AdminPage.tsx index 91c5fdc09d..c3798fdbfe 100644 --- a/apps/ehr/src/pages/AdminPage.tsx +++ b/apps/ehr/src/pages/AdminPage.tsx @@ -9,6 +9,7 @@ import AdminPrintingConfig from 'src/features/visits/telemed/components/admin/la import QuestionnaireAdminPage from 'src/features/visits/telemed/components/admin/questionnaires/QuestionnaireAdminPage'; import SupportDialogAdminPage from 'src/features/visits/telemed/components/admin/support-dialog/SupportDialogAdminPage'; import BillingConfiguration from '../features/admin/BillingConfiguration'; +import PaperworkPackagesAdminPage from '../features/admin/PaperworkPackagesAdminPage'; import EMCodesAdminPage from '../features/visits/telemed/components/admin/EMCodesAdminPage'; import GlobalTemplatesAdminPage from '../features/visits/telemed/components/admin/GlobalTemplatesAdminPage'; import QuickPicksAdminPage from '../features/visits/telemed/components/admin/QuickPicksAdminPage'; @@ -38,6 +39,7 @@ enum PageTab { 'em-codes' = 'em-codes', 'lab-sets' = 'lab-sets', questionnaires = 'questionnaires', + 'paperwork-packages' = 'paperwork-packages', 'docs-folders' = 'docs-folders', 'support-dialog' = 'support-dialog', 'progress-note' = 'progress-note', @@ -156,6 +158,12 @@ export function AdminPage(): JSX.Element { sx={{ textTransform: 'none', fontWeight: 500 }} onClick={() => navigate(`/admin/${PageTab.questionnaires}`)} /> + navigate(`/admin/${PageTab['paperwork-packages']}`)} + /> + + + diff --git a/config/oystehr-core/zambdas.json b/config/oystehr-core/zambdas.json index 006c524422..e5f9d35451 100644 --- a/config/oystehr-core/zambdas.json +++ b/config/oystehr-core/zambdas.json @@ -2124,6 +2124,34 @@ "src": "src/ehr/admin-delete-service-category/index", "zip": ".dist/zips/admin-delete-service-category.zip" }, + "ADMIN_LIST_PAPERWORK_FLOWS": { + "name": "admin-list-paperwork-flows", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-list-paperwork-flows/index", + "zip": ".dist/zips/admin-list-paperwork-flows.zip" + }, + "ADMIN_CREATE_PAPERWORK_FLOW": { + "name": "admin-create-paperwork-flow", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-create-paperwork-flow/index", + "zip": ".dist/zips/admin-create-paperwork-flow.zip" + }, + "ADMIN_UPDATE_PAPERWORK_FLOW": { + "name": "admin-update-paperwork-flow", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-update-paperwork-flow/index", + "zip": ".dist/zips/admin-update-paperwork-flow.zip" + }, + "ADMIN_DELETE_PAPERWORK_FLOW": { + "name": "admin-delete-paperwork-flow", + "type": "http_auth", + "runtime": "nodejs22.x", + "src": "src/ehr/admin-delete-paperwork-flow/index", + "zip": ".dist/zips/admin-delete-paperwork-flow.zip" + }, "ADMIN_CREATE_PRACTITIONER_ROLE": { "name": "admin-create-practitioner-role", "type": "http_auth", diff --git a/packages/utils/lib/fhir/base-paperwork-flows.ts b/packages/utils/lib/fhir/base-paperwork-flows.ts new file mode 100644 index 0000000000..6f4f7d18c8 --- /dev/null +++ b/packages/utils/lib/fhir/base-paperwork-flows.ts @@ -0,0 +1,37 @@ +import { IN_PERSON_INTAKE_PAPERWORK_URL } from '../ottehr-config/intake-paperwork'; +import { LITE_INTAKE_PAPERWORK_URL } from '../ottehr-config/intake-paperwork-lite'; +import { VIRTUAL_INTAKE_PAPERWORK_URL } from '../ottehr-config/intake-paperwork-virtual'; +import { PaperworkFlowBase } from './constants'; + +// Catalog of the three fixed *base* paperwork flows — one per base intake canonical. +// Their forms compose onto every booking that resolves to that canonical (OTR-2309 v2). +// Kept out of paperwork-flow.ts so the base-canonical config imports don't land in the +// broadly-imported flow record module / EHR main bundle. + +export interface BasePaperworkFlowDescriptor { + /** Base intake canonical URL this base flow binds to. */ + canonical: string; + /** Reserved, stable List.identifier slug. */ + slug: string; + /** Display name shown on the Paperwork Flows admin page. */ + title: string; + /** Mirrors PaperworkFlowBase for the List's base extension (in-person/virtual = standard). */ + base: PaperworkFlowBase; +} + +export const BASE_PAPERWORK_FLOWS: BasePaperworkFlowDescriptor[] = [ + { + canonical: IN_PERSON_INTAKE_PAPERWORK_URL, + slug: 'base-standard-in-person', + title: 'In-person intake', + base: 'standard', + }, + { canonical: VIRTUAL_INTAKE_PAPERWORK_URL, slug: 'base-standard-virtual', title: 'Virtual intake', base: 'standard' }, + { canonical: LITE_INTAKE_PAPERWORK_URL, slug: 'base-consent-only', title: 'Consent only', base: 'consent-only' }, +]; + +/** The base-flow descriptor for a resolved intake canonical, or undefined. */ +export function baseFlowForCanonical(canonical: string | undefined): BasePaperworkFlowDescriptor | undefined { + if (!canonical) return undefined; + return BASE_PAPERWORK_FLOWS.find((b) => b.canonical === canonical); +} diff --git a/packages/utils/lib/fhir/constants.ts b/packages/utils/lib/fhir/constants.ts index 633b827792..676d570974 100644 --- a/packages/utils/lib/fhir/constants.ts +++ b/packages/utils/lib/fhir/constants.ts @@ -441,6 +441,34 @@ export const SERVICE_CATEGORY_CONFIG_EXTENSION_URL = ottehrExtensionUrl('service // 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. +/** The two base intakes a paperwork flow can use. `standard` resolves to the full + * in-person/virtual intake from the visit's service mode; `consent-only` is the lite flow. */ +export type PaperworkFlowBase = 'standard' | 'consent-only'; +export const PAPERWORK_FLOW_BASES: PaperworkFlowBase[] = ['standard', 'consent-only']; + +/** meta.tag identifying a List as a practice paperwork flow. */ +export const PAPERWORK_FLOW_TAG = { + system: ottehrCodeSystemUrl('list-type'), + code: 'practice-paperwork-flow', +}; + +/** Stable identifier system for a paperwork-flow List (value = the flow's slug). */ +export const PAPERWORK_FLOW_IDENTIFIER_SYSTEM = ottehrCodeSystemUrl('paperwork-flow-slug'); + +/** Extension on the paperwork-flow List holding the base intake (valueCode = PaperworkFlowBase). */ +export const PAPERWORK_FLOW_BASE_EXTENSION_URL = ottehrExtensionUrl('paperwork-flow-base'); + +/** + * Extension on a *base* paperwork-flow List binding it to a base intake canonical + * (valueUri = the in-person / virtual / consent-only intake URL). Presence marks the + * List as a base flow: its forms compose onto every booking that resolves to that + * canonical. Service flows do not carry this. See `isBaseFlow`. + */ +export const PAPERWORK_FLOW_CANONICAL_EXTENSION_URL = ottehrExtensionUrl('paperwork-flow-canonical'); + +/** Extension stamped on a booked Appointment with the resolved paperwork-flow id (valueString). */ +export const APPOINTMENT_PAPERWORK_FLOW_EXTENSION_URL = ottehrExtensionUrl('appointment-paperwork-flow'); + /** meta.tag identifying a Questionnaire as practice-managed (admin-authored custom form). */ export const PRACTICE_MANAGED_QUESTIONNAIRE_TAG = { system: ottehrCodeSystemUrl('questionnaire-type'), diff --git a/packages/utils/lib/fhir/healthcareService.ts b/packages/utils/lib/fhir/healthcareService.ts index 0f9edbb3c3..bd74a491cc 100644 --- a/packages/utils/lib/fhir/healthcareService.ts +++ b/packages/utils/lib/fhir/healthcareService.ts @@ -112,6 +112,24 @@ export function parseReasonsForVisit(hs: HealthcareService): Array<{ label: stri ); } +/** + * Read the paperwork-flow id (OTR-2309) from the service-category JSON-blob config + * extension. Returns undefined when absent or the blob is unparseable. + */ +export function parsePaperworkFlowId(hs: HealthcareService): string | undefined { + const raw = hs.extension?.find((e) => e.url === SERVICE_CATEGORY_CONFIG_EXTENSION_URL)?.valueString; + if (!raw) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (parsed === null || typeof parsed !== 'object') return undefined; + const id = (parsed as { paperworkFlowId?: unknown }).paperworkFlowId; + return typeof id === 'string' && id.length > 0 ? id : undefined; +} + // ── Group readers ─────────────────────────────────────────────────────────── export function getGroupAssignmentMode(hs: HealthcareService): GroupAssignmentMode | undefined { diff --git a/packages/utils/lib/fhir/index.ts b/packages/utils/lib/fhir/index.ts index 596edcadcd..38d102d3b6 100644 --- a/packages/utils/lib/fhir/index.ts +++ b/packages/utils/lib/fhir/index.ts @@ -12,6 +12,8 @@ export * from './convertFhirNameToDisplayName'; export * from './list'; export * from './location'; export * from './healthcareService'; +export * from './base-paperwork-flows'; +export * from './paperwork-flow'; export * from './medication-administration'; export * from './vitals'; export * from './encounter'; diff --git a/packages/utils/lib/fhir/paperwork-flow.test.ts b/packages/utils/lib/fhir/paperwork-flow.test.ts new file mode 100644 index 0000000000..6c75683850 --- /dev/null +++ b/packages/utils/lib/fhir/paperwork-flow.test.ts @@ -0,0 +1,171 @@ +import { List } from 'fhir/r4b'; +import { describe, expect, it } from 'vitest'; +import { BASE_PAPERWORK_FLOWS, baseFlowForCanonical } from './base-paperwork-flows'; +import { PAPERWORK_FLOW_BASE_EXTENSION_URL, PAPERWORK_FLOW_IDENTIFIER_SYSTEM, PAPERWORK_FLOW_TAG } from './constants'; +import { + composeFormIds, + getPaperworkFlowBase, + getPaperworkFlowCanonical, + getPaperworkFlowFormIds, + isBaseFlow, + isPaperworkFlowList, + PaperworkFlow, + paperworkFlowToFhirList, + toPaperworkFlowRecord, +} from './paperwork-flow'; + +const flowList = (overrides: Partial = {}): List => ({ + resourceType: 'List', + status: 'current', + mode: 'working', + title: 'Massage intake', + meta: { tag: [PAPERWORK_FLOW_TAG] }, + identifier: [{ system: PAPERWORK_FLOW_IDENTIFIER_SYSTEM, value: 'massage-intake' }], + extension: [{ url: PAPERWORK_FLOW_BASE_EXTENSION_URL, valueCode: 'consent-only' }], + entry: [{ item: { reference: 'Questionnaire/q-1' } }, { item: { reference: 'Questionnaire/q-2' } }], + ...overrides, +}); + +describe('isPaperworkFlowList', () => { + it('recognizes a flow-tagged List', () => { + expect(isPaperworkFlowList(flowList())).toBe(true); + }); + + it('rejects a List without the flow tag', () => { + expect(isPaperworkFlowList(flowList({ meta: { tag: [] } }))).toBe(false); + expect(isPaperworkFlowList(flowList({ meta: undefined }))).toBe(false); + }); +}); + +describe('getPaperworkFlowBase', () => { + it('reads the configured base', () => { + expect(getPaperworkFlowBase(flowList())).toBe('consent-only'); + }); + + it('defaults to standard when the extension is missing or invalid', () => { + expect(getPaperworkFlowBase(flowList({ extension: [] }))).toBe('standard'); + expect( + getPaperworkFlowBase(flowList({ extension: [{ url: PAPERWORK_FLOW_BASE_EXTENSION_URL, valueCode: 'bogus' }] })) + ).toBe('standard'); + }); +}); + +describe('getPaperworkFlowFormIds', () => { + it('extracts ordered Questionnaire ids and ignores non-Questionnaire entries', () => { + const list = flowList({ + entry: [ + { item: { reference: 'Questionnaire/q-1' } }, + { item: { reference: 'Patient/p-1' } }, + { item: { reference: 'Questionnaire/q-2' } }, + ], + }); + expect(getPaperworkFlowFormIds(list)).toEqual(['q-1', 'q-2']); + }); + + it('returns an empty array when there are no entries', () => { + expect(getPaperworkFlowFormIds(flowList({ entry: undefined }))).toEqual([]); + }); +}); + +describe('toPaperworkFlowRecord', () => { + it('parses a complete flow List', () => { + expect(toPaperworkFlowRecord(flowList({ id: 'list-1' }))).toEqual({ + id: 'list-1', + slug: 'massage-intake', + name: 'Massage intake', + base: 'consent-only', + formIds: ['q-1', 'q-2'], + }); + }); + + it('falls back to the slug for the name when title is absent', () => { + expect(toPaperworkFlowRecord(flowList({ title: undefined }))?.name).toBe('massage-intake'); + }); + + it('returns null when the List is not a flow', () => { + expect(toPaperworkFlowRecord(flowList({ meta: { tag: [] } }))).toBeNull(); + }); + + it('returns null when the flow has no slug identifier', () => { + expect(toPaperworkFlowRecord(flowList({ identifier: [] }))).toBeNull(); + }); +}); + +describe('paperworkFlowToFhirList round-trip', () => { + it('serializes a record and parses back to the same logical values', () => { + const record: PaperworkFlow = { + id: 'list-9', + slug: 'facial-intake', + name: 'Facial intake', + base: 'standard', + formIds: ['form-a', 'form-b', 'form-c'], + }; + const list = paperworkFlowToFhirList(record); + expect(isPaperworkFlowList(list)).toBe(true); + expect(toPaperworkFlowRecord(list)).toEqual(record); + }); + + it('omits the id for unsaved records', () => { + const list = paperworkFlowToFhirList({ slug: 's', name: 'n', base: 'standard', formIds: [] }); + expect(list.id).toBeUndefined(); + }); +}); + +describe('base flows (canonical-bound)', () => { + const inPerson = BASE_PAPERWORK_FLOWS[0]; + + it('round-trips a base flow with its canonical and is recognized by isBaseFlow', () => { + const record: PaperworkFlow = { + id: 'base-1', + slug: inPerson.slug, + name: inPerson.title, + base: inPerson.base, + formIds: ['f1', 'f2'], + canonical: inPerson.canonical, + }; + const list = paperworkFlowToFhirList(record); + expect(isBaseFlow(list)).toBe(true); + expect(getPaperworkFlowCanonical(list)).toBe(inPerson.canonical); + expect(toPaperworkFlowRecord(list)).toEqual(record); + }); + + it('treats a flow without a canonical as a service flow', () => { + const list = paperworkFlowToFhirList({ slug: 's', name: 'n', base: 'consent-only', formIds: [] }); + expect(isBaseFlow(list)).toBe(false); + expect(getPaperworkFlowCanonical(list)).toBeUndefined(); + }); + + it('catalogs exactly three base flows, one per base intake, with distinct canonicals', () => { + expect(BASE_PAPERWORK_FLOWS).toHaveLength(3); + expect(new Set(BASE_PAPERWORK_FLOWS.map((b) => b.canonical)).size).toBe(3); + expect(BASE_PAPERWORK_FLOWS.map((b) => b.base)).toEqual(['standard', 'standard', 'consent-only']); + }); + + it('baseFlowForCanonical maps a canonical to its descriptor, else undefined', () => { + for (const b of BASE_PAPERWORK_FLOWS) { + expect(baseFlowForCanonical(b.canonical)).toEqual(b); + } + expect(baseFlowForCanonical('https://example.com/not-a-base')).toBeUndefined(); + expect(baseFlowForCanonical(undefined)).toBeUndefined(); + }); +}); + +describe('composeFormIds (base + service compose)', () => { + it('puts base forms first, then service forms', () => { + expect(composeFormIds(['b1', 'b2'], ['s1', 's2'])).toEqual(['b1', 'b2', 's1', 's2']); + }); + + it('de-dupes keep-first — a form in both keeps its base position', () => { + expect(composeFormIds(['hipaa', 'b2'], ['s1', 'hipaa', 's2'])).toEqual(['hipaa', 'b2', 's1', 's2']); + }); + + it('handles empty base (service-only) and empty service (base-only)', () => { + expect(composeFormIds([], ['s1', 's2'])).toEqual(['s1', 's2']); + expect(composeFormIds(['b1'], [])).toEqual(['b1']); + expect(composeFormIds([], [])).toEqual([]); + }); + + it('de-dupes repeats within a single list too', () => { + expect(composeFormIds(['b1', 'b1'], ['b1'])).toEqual(['b1']); + }); +}); diff --git a/packages/utils/lib/fhir/paperwork-flow.ts b/packages/utils/lib/fhir/paperwork-flow.ts new file mode 100644 index 0000000000..a754c8ec70 --- /dev/null +++ b/packages/utils/lib/fhir/paperwork-flow.ts @@ -0,0 +1,114 @@ +import { List } from 'fhir/r4b'; +import { + PAPERWORK_FLOW_BASE_EXTENSION_URL, + PAPERWORK_FLOW_BASES, + PAPERWORK_FLOW_CANONICAL_EXTENSION_URL, + PAPERWORK_FLOW_IDENTIFIER_SYSTEM, + PAPERWORK_FLOW_TAG, + PaperworkFlowBase, +} from './constants'; + +// A reusable practice paperwork flow (OTR-2309): a base intake + an ordered set of +// practice-managed form Questionnaires. Persisted as a FHIR List; ServiceCategories +// reference one by id and booked Appointments are stamped with the resolved flow id. +export interface PaperworkFlow { + /** List.id — present for persisted flows. */ + id?: string; + /** Stable, human-authored slug (List.identifier value). */ + slug: string; + /** Display name (List.title). */ + name: string; + /** Base intake: 'standard' (mode-resolved full intake) or 'consent-only' (lite). */ + base: PaperworkFlowBase; + /** Ordered practice-managed form Questionnaire ids included after the base intake. */ + formIds: string[]; + /** + * Set only on *base* flows: the base intake canonical this flow's forms attach to + * (in-person / virtual / consent-only URL). Undefined on service flows. A booking + * composes the base flow for its resolved canonical with its service flow. + */ + canonical?: string; +} + +const isPaperworkFlowBase = (v: unknown): v is PaperworkFlowBase => + typeof v === 'string' && (PAPERWORK_FLOW_BASES as string[]).includes(v); + +/** Whether a List is a practice paperwork flow (carries the flow tag). */ +export function isPaperworkFlowList(list: List): boolean { + return (list.meta?.tag ?? []).some( + (t) => t.system === PAPERWORK_FLOW_TAG.system && t.code === PAPERWORK_FLOW_TAG.code + ); +} + +/** Read the base intake from a paperwork-flow List; defaults to 'standard' when absent/invalid. */ +export function getPaperworkFlowBase(list: List): PaperworkFlowBase { + const raw = (list.extension ?? []).find((e) => e.url === PAPERWORK_FLOW_BASE_EXTENSION_URL)?.valueCode; + return isPaperworkFlowBase(raw) ? raw : 'standard'; +} + +/** The base intake canonical a List is bound to, or undefined (service flows have none). */ +export function getPaperworkFlowCanonical(list: List): string | undefined { + return (list.extension ?? []).find((e) => e.url === PAPERWORK_FLOW_CANONICAL_EXTENSION_URL)?.valueUri; +} + +/** Whether a List is a *base* paperwork flow (bound to a base intake canonical) vs a service flow. */ +export function isBaseFlow(list: List): boolean { + return isPaperworkFlowList(list) && getPaperworkFlowCanonical(list) !== undefined; +} + +/** + * The forms a booking shows (OTR-2309 v2): the base intake's forms first, then the service + * flow's forms, de-duplicated keep-first (a form in both keeps its base-flow position). + */ +export function composeFormIds(baseFormIds: string[], serviceFormIds: string[]): string[] { + const seen = new Set(); + const ordered: string[] = []; + for (const id of [...baseFormIds, ...serviceFormIds]) { + if (!seen.has(id)) { + seen.add(id); + ordered.push(id); + } + } + return ordered; +} + +/** Read the ordered practice-managed form Questionnaire ids referenced by a flow List. */ +export function getPaperworkFlowFormIds(list: List): string[] { + return (list.entry ?? []) + .map((e) => e.item?.reference) + .filter((ref): ref is string => typeof ref === 'string' && ref.startsWith('Questionnaire/')) + .map((ref) => ref.split('/').pop() as string); +} + +/** Parse a paperwork-flow List into a record. Returns null when the List isn't a flow or lacks a slug. */ +export function toPaperworkFlowRecord(list: List): PaperworkFlow | null { + if (!isPaperworkFlowList(list)) return null; + const slug = (list.identifier ?? []).find((i) => i.system === PAPERWORK_FLOW_IDENTIFIER_SYSTEM)?.value; + if (!slug) return null; + return { + id: list.id, + slug, + name: list.title ?? slug, + base: getPaperworkFlowBase(list), + formIds: getPaperworkFlowFormIds(list), + canonical: getPaperworkFlowCanonical(list), + }; +} + +/** Build the FHIR List resource for a paperwork-flow record (for create/update). */ +export function paperworkFlowToFhirList(record: PaperworkFlow): List { + return { + resourceType: 'List', + ...(record.id ? { id: record.id } : {}), + status: 'current', + mode: 'working', + title: record.name, + meta: { tag: [PAPERWORK_FLOW_TAG] }, + identifier: [{ system: PAPERWORK_FLOW_IDENTIFIER_SYSTEM, value: record.slug }], + extension: [ + { url: PAPERWORK_FLOW_BASE_EXTENSION_URL, valueCode: record.base }, + ...(record.canonical ? [{ url: PAPERWORK_FLOW_CANONICAL_EXTENSION_URL, valueUri: record.canonical }] : []), + ], + entry: record.formIds.map((id) => ({ item: { reference: `Questionnaire/${id}` } })), + }; +} diff --git a/packages/utils/lib/fhir/serviceCategoryResolution.ts b/packages/utils/lib/fhir/serviceCategoryResolution.ts index 635f01ae90..0e705f46a1 100644 --- a/packages/utils/lib/fhir/serviceCategoryResolution.ts +++ b/packages/utils/lib/fhir/serviceCategoryResolution.ts @@ -7,6 +7,7 @@ import { getAllFhirSearchPages } from './getAllFhirSearchPages'; import { getServiceCategoryCadenceMinutes, getServiceCategoryDurationMinutes, + parsePaperworkFlowId, parseReasonsForVisit, } from './healthcareService'; @@ -30,6 +31,8 @@ export interface ResolvedServiceCategory { cadenceMinutes: number | undefined; /** Configured RFV options; empty array when none. */ reasonsForVisit: Array<{ value: string; label: string }>; + /** Practice paperwork flow this category uses (OTR-2309). Undefined for booking-config entries. */ + paperworkFlowId: string | undefined; source: 'booking-config' | 'fhir'; } @@ -64,6 +67,7 @@ export async function resolveServiceCategory( durationMinutes: undefined, cadenceMinutes: undefined, reasonsForVisit: getReasonForVisitOptionsForServiceCategory(code), + paperworkFlowId: undefined, source: 'booking-config', }; } @@ -94,6 +98,7 @@ export async function resolveServiceCategory( durationMinutes: getServiceCategoryDurationMinutes(fhirMatch), cadenceMinutes: getServiceCategoryCadenceMinutes(fhirMatch), reasonsForVisit: parseReasonsForVisit(fhirMatch), + paperworkFlowId: parsePaperworkFlowId(fhirMatch), source: 'fhir', }; } diff --git a/packages/utils/lib/helpers/slugify.ts b/packages/utils/lib/helpers/slugify.ts index 486c2f15cd..23d240d85f 100644 --- a/packages/utils/lib/helpers/slugify.ts +++ b/packages/utils/lib/helpers/slugify.ts @@ -1,7 +1,7 @@ /** * 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). + * Used for paperwork-flow slugs and practice-managed questionnaire canonical names. */ export function slugify(name: string, options: { maxLength?: number } = {}): string { let slug = name diff --git a/packages/zambdas/src/ehr/admin-create-paperwork-flow/index.ts b/packages/zambdas/src/ehr/admin-create-paperwork-flow/index.ts new file mode 100644 index 0000000000..7d203f0f17 --- /dev/null +++ b/packages/zambdas/src/ehr/admin-create-paperwork-flow/index.ts @@ -0,0 +1,60 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { List } from 'fhir/r4b'; +import { + INVALID_INPUT_ERROR, + MISSING_REQUEST_BODY, + MISSING_REQUIRED_PARAMETERS, + PAPERWORK_FLOW_BASES, + PaperworkFlow, + PaperworkFlowBase, + paperworkFlowToFhirList, + toPaperworkFlowRecord, +} from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient, reconcileFlowServiceAssignments } from '../admin-paperwork-flows/helpers'; + +interface Input { + flow: Omit; + serviceIds: string[]; +} + +function validate(input: ZambdaInput): Input { + if (!input.body) throw MISSING_REQUEST_BODY; + let parsed: any; + try { + parsed = JSON.parse(input.body); + } catch { + throw INVALID_INPUT_ERROR('Request body must be valid JSON'); + } + const flow = parsed.flow; + if (!flow || typeof flow !== 'object') throw INVALID_INPUT_ERROR('"flow" must be an object'); + if (flow.canonical) throw INVALID_INPUT_ERROR('base flows are fixed and not user-creatable'); + const missing: string[] = []; + if (!flow.slug) missing.push('flow.slug'); + if (!flow.name) missing.push('flow.name'); + if (!flow.base) missing.push('flow.base'); + if (missing.length) throw MISSING_REQUIRED_PARAMETERS(missing); + if (!(PAPERWORK_FLOW_BASES as string[]).includes(flow.base)) + throw INVALID_INPUT_ERROR(`"flow.base" must be one of: ${PAPERWORK_FLOW_BASES.join(', ')}`); + const formIds = Array.isArray(flow.formIds) ? flow.formIds.filter((f: unknown) => typeof f === 'string') : []; + const serviceIds = Array.isArray(parsed.serviceIds) + ? parsed.serviceIds.filter((s: unknown) => typeof s === 'string') + : []; + return { + flow: { slug: String(flow.slug), name: String(flow.name), base: flow.base as PaperworkFlowBase, formIds }, + serviceIds, + }; +} + +export const index = wrapHandler( + 'admin-create-paperwork-flow', + async (input: ZambdaInput): Promise => { + const { flow, serviceIds } = validate(input); + const oystehr = await getClient(input); + const created = await oystehr.fhir.create(paperworkFlowToFhirList(flow)); + if (created.id && serviceIds.length > 0) { + await reconcileFlowServiceAssignments(oystehr, created.id, serviceIds); + } + return { statusCode: 200, body: JSON.stringify({ flow: toPaperworkFlowRecord(created), serviceIds }) }; + } +); diff --git a/packages/zambdas/src/ehr/admin-delete-paperwork-flow/index.ts b/packages/zambdas/src/ehr/admin-delete-paperwork-flow/index.ts new file mode 100644 index 0000000000..5cb2c34dfd --- /dev/null +++ b/packages/zambdas/src/ehr/admin-delete-paperwork-flow/index.ts @@ -0,0 +1,44 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { List } from 'fhir/r4b'; +import { + FHIR_RESOURCE_NOT_FOUND, + INVALID_INPUT_ERROR, + isBaseFlow, + MISSING_REQUEST_BODY, + MISSING_REQUIRED_PARAMETERS, +} from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { clearFlowFromAllServices, getClient } from '../admin-paperwork-flows/helpers'; + +function validate(input: ZambdaInput): { id: string } { + if (!input.body) throw MISSING_REQUEST_BODY; + let parsed: any; + try { + parsed = JSON.parse(input.body); + } catch { + throw INVALID_INPUT_ERROR('Request body must be valid JSON'); + } + // The request's `id` field selects the zambda; the flow's id travels as `flowId`. + if (!parsed.flowId || typeof parsed.flowId !== 'string') throw MISSING_REQUIRED_PARAMETERS(['flowId']); + return { id: parsed.flowId }; +} + +export const index = wrapHandler( + 'admin-delete-paperwork-flow', + async (input: ZambdaInput): Promise => { + const { id } = validate(input); + const oystehr = await getClient(input); + // Base flows are fixed (one per base intake canonical) — not deletable. The fetch must + // succeed for the guard to be trustworthy: a 404 is the caller's problem (stale id), and + // any other failure propagates rather than silently bypassing the guard. + const existing = await oystehr.fhir.get({ resourceType: 'List', id }).catch((err) => { + if ((err as { code?: number })?.code === 404) throw FHIR_RESOURCE_NOT_FOUND('List'); + throw err; + }); + if (isBaseFlow(existing)) throw INVALID_INPUT_ERROR('base flows cannot be deleted'); + // Clear the flow from any service that points at it, then delete the flow List. + await clearFlowFromAllServices(oystehr, id); + await oystehr.fhir.delete({ resourceType: 'List', id }); + return { statusCode: 200, body: JSON.stringify({ success: true }) }; + } +); diff --git a/packages/zambdas/src/ehr/admin-list-paperwork-flows/index.ts b/packages/zambdas/src/ehr/admin-list-paperwork-flows/index.ts new file mode 100644 index 0000000000..3af07a9572 --- /dev/null +++ b/packages/zambdas/src/ehr/admin-list-paperwork-flows/index.ts @@ -0,0 +1,13 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient, listBaseFlows, listFlowsWithServices } from '../admin-paperwork-flows/helpers'; + +export const index = wrapHandler( + 'admin-list-paperwork-flows', + async (input: ZambdaInput): Promise => { + const oystehr = await getClient(input); + // baseFlows are ensured-and-returned (3 fixed, canonical-bound); flows are service flows. + const [flows, baseFlows] = await Promise.all([listFlowsWithServices(oystehr), listBaseFlows(oystehr)]); + return { statusCode: 200, body: JSON.stringify({ flows, baseFlows }) }; + } +); diff --git a/packages/zambdas/src/ehr/admin-paperwork-flows/helpers.ts b/packages/zambdas/src/ehr/admin-paperwork-flows/helpers.ts new file mode 100644 index 0000000000..172319777c --- /dev/null +++ b/packages/zambdas/src/ehr/admin-paperwork-flows/helpers.ts @@ -0,0 +1,161 @@ +import Oystehr from '@oystehr/sdk'; +import { HealthcareService, List } from 'fhir/r4b'; +import { + BASE_PAPERWORK_FLOWS, + getAllFhirSearchPages, + isBaseFlow, + PAPERWORK_FLOW_TAG, + PaperworkFlow, + paperworkFlowToFhirList, + parsePaperworkFlowId, + SERVICE_CATEGORY_CONFIG_EXTENSION_URL, + SERVICE_CATEGORY_TAG, + toPaperworkFlowRecord, +} from 'utils'; +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); +} + +/** A flow record plus the ids of the service categories currently assigned to it. */ +export interface PaperworkFlowWithServices extends PaperworkFlow { + serviceIds: string[]; +} + +export async function searchPaperworkFlowLists(oystehr: Oystehr): Promise { + return getAllFhirSearchPages( + { + resourceType: 'List', + params: [{ name: '_tag', value: PAPERWORK_FLOW_TAG.code }], + }, + oystehr + ); +} + +export async function searchServiceCategoryHealthcareServices(oystehr: Oystehr): Promise { + return getAllFhirSearchPages( + { + resourceType: 'HealthcareService', + params: [{ name: '_tag', value: SERVICE_CATEGORY_TAG.code }], + }, + oystehr + ); +} + +/** + * Ensure the three fixed base flows (one per base intake canonical) exist, creating + * any that are missing, and return their Lists. Idempotent — matches existing base + * flows by canonical so reruns never duplicate. Base flows start with no forms. + */ +export async function ensureBaseFlows(oystehr: Oystehr): Promise { + const existing = (await searchPaperworkFlowLists(oystehr)).filter(isBaseFlow); + const byCanonical = new Map(existing.map((l) => [toPaperworkFlowRecord(l)?.canonical, l])); + const created = await Promise.all( + BASE_PAPERWORK_FLOWS.filter((desc) => !byCanonical.has(desc.canonical)).map((desc) => + oystehr.fhir.create( + paperworkFlowToFhirList({ + slug: desc.slug, + name: desc.title, + base: desc.base, + formIds: [], + canonical: desc.canonical, + }) + ) + ) + ); + for (const list of created) { + byCanonical.set(toPaperworkFlowRecord(list)?.canonical, list); + } + // Return in catalog order so the admin UI renders in-person / virtual / consent-only consistently. + return BASE_PAPERWORK_FLOWS.map((desc) => byCanonical.get(desc.canonical)).filter((l): l is List => l !== undefined); +} + +/** Ensure + return the base flows as records (the canonical-bound form lists). */ +export async function listBaseFlows(oystehr: Oystehr): Promise { + const lists = await ensureBaseFlows(oystehr); + return lists.map((l) => toPaperworkFlowRecord(l)).filter((r): r is PaperworkFlow => r !== null); +} + +/** List every *service* flow (base flows excluded), each enriched with the service-category ids pointing at it. */ +export async function listFlowsWithServices(oystehr: Oystehr): Promise { + const [lists, services] = await Promise.all([ + searchPaperworkFlowLists(oystehr), + searchServiceCategoryHealthcareServices(oystehr), + ]); + const servicesByFlow = new Map(); + for (const hs of services) { + const flowId = parsePaperworkFlowId(hs); + if (flowId && hs.id) servicesByFlow.set(flowId, [...(servicesByFlow.get(flowId) ?? []), hs.id]); + } + return lists + .filter((l) => !isBaseFlow(l)) + .map((l) => { + const record = toPaperworkFlowRecord(l); + if (!record?.id) return null; + return { ...record, serviceIds: servicesByFlow.get(record.id) ?? [] }; + }) + .filter((f): f is PaperworkFlowWithServices => f !== null); +} + +/** Return a copy of the service-category HS with its config blob's paperworkFlowId set or cleared. */ +function withPaperworkFlowId(hs: HealthcareService, flowId: string | undefined): HealthcareService { + const existing = hs.extension?.find((e) => e.url === SERVICE_CATEGORY_CONFIG_EXTENSION_URL)?.valueString; + let blob: Record = {}; + if (existing) { + try { + const parsed = JSON.parse(existing); + if (parsed && typeof parsed === 'object') blob = parsed as Record; + } catch { + blob = {}; + } + } + if (flowId) blob.paperworkFlowId = flowId; + else delete blob.paperworkFlowId; + + const otherExtensions = (hs.extension ?? []).filter((e) => e.url !== SERVICE_CATEGORY_CONFIG_EXTENSION_URL); + const configExtension = + Object.keys(blob).length > 0 + ? [{ url: SERVICE_CATEGORY_CONFIG_EXTENSION_URL, valueString: JSON.stringify(blob) }] + : []; + const nextExtensions = [...otherExtensions, ...configExtension]; + return { ...hs, extension: nextExtensions.length > 0 ? nextExtensions : undefined }; +} + +/** + * Make exactly `desiredServiceIds` point at `flowId`: assign it to those not already pointing at it, + * and clear it from any service that pointed at this flow but is no longer in the set. Because the + * field is single-valued, assigning a service here inherently removes it from any other flow. + */ +export async function reconcileFlowServiceAssignments( + oystehr: Oystehr, + flowId: string, + desiredServiceIds: string[] +): Promise { + const services = await searchServiceCategoryHealthcareServices(oystehr); + const desired = new Set(desiredServiceIds); + const updates: HealthcareService[] = []; + for (const hs of services) { + if (!hs.id) continue; + const current = parsePaperworkFlowId(hs); + if (desired.has(hs.id) && current !== flowId) { + updates.push(withPaperworkFlowId(hs, flowId)); + } else if (!desired.has(hs.id) && current === flowId) { + updates.push(withPaperworkFlowId(hs, undefined)); + } + } + await Promise.all(updates.map((hs) => oystehr.fhir.update(hs))); +} + +/** Clear `flowId` from every service that points at it (used when a flow is deleted). */ +export async function clearFlowFromAllServices(oystehr: Oystehr, flowId: string): Promise { + const services = await searchServiceCategoryHealthcareServices(oystehr); + const updates = services + .filter((hs) => hs.id && parsePaperworkFlowId(hs) === flowId) + .map((hs) => withPaperworkFlowId(hs, undefined)); + await Promise.all(updates.map((hs) => oystehr.fhir.update(hs))); +} diff --git a/packages/zambdas/src/ehr/admin-service-categories/helpers.ts b/packages/zambdas/src/ehr/admin-service-categories/helpers.ts index 2c9100a736..a1a37558f6 100644 --- a/packages/zambdas/src/ehr/admin-service-categories/helpers.ts +++ b/packages/zambdas/src/ehr/admin-service-categories/helpers.ts @@ -6,6 +6,7 @@ import { getServiceCategoryDurationMinutes, getServiceCategoryModes, getServiceCategoryVisitTypes, + parsePaperworkFlowId, parseReasonsForVisit, SERVICE_CATEGORY_CONFIG_EXTENSION_URL, SERVICE_CATEGORY_SYSTEM, @@ -40,6 +41,8 @@ export interface ServiceCategoryRuntimeConfig { /** Booking-flow capabilities — 'prebook' vs 'walk-in'. */ visitTypes: ServiceVisitType[]; reasonsForVisit?: Array<{ label: string; value: string }>; + /** Practice paperwork flow this visit type uses (OTR-2309); undefined = default by mode. */ + paperworkFlowId?: string; } export interface ServiceCategory { @@ -97,6 +100,7 @@ export function toRecord(resource: HealthcareService): ServiceCategory { serviceModes: modes.length > 0 ? modes : [ServiceMode['in-person']], visitTypes: visitTypes.length > 0 ? visitTypes : [ServiceVisitType.prebook], reasonsForVisit: parseReasonsForVisit(resource), + paperworkFlowId: parsePaperworkFlowId(resource), }, source: 'fhir', }; @@ -126,6 +130,12 @@ export function toFhirResource(record: ServiceCategory): HealthcareService { // to preserve foreign characteristics should use mergeOwnedCharacteristics // with SERVICE_CATEGORY_OWNED_CHARACTERISTIC_SYSTEMS before persisting. const reasons = record.config.reasonsForVisit ?? []; + const paperworkFlowId = record.config.paperworkFlowId; + // Free-form fields live in the JSON-blob extension. Only emit the blob when at least one + // free-form field is set, so a category with neither stays extension-free. + const configBlob: Record = {}; + if (reasons.length > 0) configBlob.reasonsForVisit = reasons; + if (paperworkFlowId) configBlob.paperworkFlowId = paperworkFlowId; return { resourceType: 'HealthcareService', id: record.id, @@ -134,15 +144,9 @@ export function toFhirResource(record: ServiceCategory): HealthcareService { name: record.name, type, characteristic: ownedCharacteristics, - // Free-form fields still live in the JSON-blob extension. extension: - reasons.length > 0 - ? [ - { - url: SERVICE_CATEGORY_CONFIG_EXTENSION_URL, - valueString: JSON.stringify({ reasonsForVisit: reasons }), - }, - ] + Object.keys(configBlob).length > 0 + ? [{ url: SERVICE_CATEGORY_CONFIG_EXTENSION_URL, valueString: JSON.stringify(configBlob) }] : undefined, }; } diff --git a/packages/zambdas/src/ehr/admin-update-paperwork-flow/index.ts b/packages/zambdas/src/ehr/admin-update-paperwork-flow/index.ts new file mode 100644 index 0000000000..d9faef5593 --- /dev/null +++ b/packages/zambdas/src/ehr/admin-update-paperwork-flow/index.ts @@ -0,0 +1,80 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; +import { List } from 'fhir/r4b'; +import { + FHIR_RESOURCE_NOT_FOUND, + INVALID_INPUT_ERROR, + isBaseFlow, + MISSING_REQUEST_BODY, + MISSING_REQUIRED_PARAMETERS, + PAPERWORK_FLOW_BASES, + PaperworkFlow, + PaperworkFlowBase, + paperworkFlowToFhirList, + toPaperworkFlowRecord, +} from 'utils'; +import { wrapHandler, ZambdaInput } from '../../shared'; +import { getClient, reconcileFlowServiceAssignments } from '../admin-paperwork-flows/helpers'; + +interface Input { + flow: PaperworkFlow; + serviceIds: string[]; +} + +function validate(input: ZambdaInput): Input { + if (!input.body) throw MISSING_REQUEST_BODY; + let parsed: any; + try { + parsed = JSON.parse(input.body); + } catch { + throw INVALID_INPUT_ERROR('Request body must be valid JSON'); + } + const flow = parsed.flow; + if (!flow || typeof flow !== 'object') throw INVALID_INPUT_ERROR('"flow" must be an object'); + const missing: string[] = []; + if (!flow.id) missing.push('flow.id'); + if (!flow.slug) missing.push('flow.slug'); + if (!flow.name) missing.push('flow.name'); + if (!flow.base) missing.push('flow.base'); + if (missing.length) throw MISSING_REQUIRED_PARAMETERS(missing); + if (!(PAPERWORK_FLOW_BASES as string[]).includes(flow.base)) + throw INVALID_INPUT_ERROR(`"flow.base" must be one of: ${PAPERWORK_FLOW_BASES.join(', ')}`); + const formIds = Array.isArray(flow.formIds) ? flow.formIds.filter((f: unknown) => typeof f === 'string') : []; + const serviceIds = Array.isArray(parsed.serviceIds) + ? parsed.serviceIds.filter((s: unknown) => typeof s === 'string') + : []; + return { + flow: { + id: String(flow.id), + slug: String(flow.slug), + name: String(flow.name), + base: flow.base as PaperworkFlowBase, + formIds, + }, + serviceIds, + }; +} + +export const index = wrapHandler( + 'admin-update-paperwork-flow', + async (input: ZambdaInput): Promise => { + const { flow, serviceIds } = validate(input); + const oystehr = await getClient(input); + // Confirm it exists (and is a flow) before overwriting. + const existing = await oystehr.fhir.get({ resourceType: 'List', id: flow.id! }).catch(() => undefined); + const existingRecord = existing ? toPaperworkFlowRecord(existing) : null; + if (!existing || !existingRecord) throw FHIR_RESOURCE_NOT_FOUND('List'); + + if (isBaseFlow(existing)) { + // Base flows are fixed identity — only their form list (content + order) is editable. + // Preserve slug/base/canonical/name from the stored List; never touch service assignments. + const updated = await oystehr.fhir.update( + paperworkFlowToFhirList({ ...existingRecord, id: flow.id!, formIds: flow.formIds }) + ); + return { statusCode: 200, body: JSON.stringify({ flow: toPaperworkFlowRecord(updated), serviceIds: [] }) }; + } + + const updated = await oystehr.fhir.update(paperworkFlowToFhirList(flow)); + await reconcileFlowServiceAssignments(oystehr, flow.id!, serviceIds); + return { statusCode: 200, body: JSON.stringify({ flow: toPaperworkFlowRecord(updated), serviceIds }) }; + } +); diff --git a/packages/zambdas/src/patient/appointment/create-appointment/index.ts b/packages/zambdas/src/patient/appointment/create-appointment/index.ts index c1bea46543..095e3e6e15 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_FLOW_EXTENSION_URL, APPOINTMENT_PAPERWORK_SUBTYPE_SYSTEM, CanonicalUrl, CreateAppointmentResponse, @@ -100,6 +101,7 @@ interface CreateAppointmentInput { /** Resolved attending Practitioner (populated for PractitionerRole bookings). */ attendingPractitioner?: ResolvedAttendingPractitioner; paperworkSubtype?: string; + paperworkFlowId?: string; } // Lifting up value to outside of the handler allows it to stay in memory across warm lambda invocations @@ -139,6 +141,7 @@ export const index = wrapHandler('create-appointment', async (input: ZambdaInput bookingLocation, attendingPractitioner, paperworkSubtype, + paperworkFlowId, } = effectInput; console.log('effectInput', effectInput); console.timeEnd('performing-complex-validation'); @@ -175,6 +178,7 @@ export const index = wrapHandler('create-appointment', async (input: ZambdaInput bookingLocation, attendingPractitioner, paperworkSubtype, + paperworkFlowId, }, oystehr ); @@ -226,6 +230,7 @@ export async function createAppointment( bookingLocation, attendingPractitioner, paperworkSubtype, + paperworkFlowId, } = input; const { verifiedPhoneNumber, listRequests, createPatientRequest, updatePatientRequest, isEHRUser, maybeFhirPatient } = @@ -310,6 +315,7 @@ export async function createAppointment( appointmentMetadata, followUpOptions: input.followUpOptions, paperworkSubtype, + paperworkFlowId, }); let relatedPersonId = ''; @@ -404,6 +410,7 @@ interface TransactionInput { appointmentMetadata?: Appointment['meta']; followUpOptions?: FollowUpOptions; paperworkSubtype?: string; + paperworkFlowId?: string; } interface TransactionOutput { appointment: Appointment; @@ -440,6 +447,7 @@ export const performTransactionalFhirRequests = async (input: TransactionInput): appointmentMetadata, followUpOptions, paperworkSubtype, + paperworkFlowId, } = input; const parentEncounterId = followUpOptions?.parentEncounterId; @@ -513,6 +521,13 @@ export const performTransactionalFhirRequests = async (input: TransactionInput): encExtensions.push(...telemedEncExtensions); } + // Stamp the resolved practice paperwork flow (OTR-2309) on the encounter so the intake renderer + // serves this flow's forms. Mirror it onto the appointment so the flow is visible there too. + if (paperworkFlowId) { + encExtensions.push({ url: APPOINTMENT_PAPERWORK_FLOW_EXTENSION_URL, valueString: paperworkFlowId }); + apptExtensions.push({ url: APPOINTMENT_PAPERWORK_FLOW_EXTENSION_URL, valueString: paperworkFlowId }); + } + if (additionalInfo) { apptExtensions.push({ url: FHIR_EXTENSION.Appointment.additionalInfo.url, diff --git a/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts b/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts index 33e3741625..ce987010d2 100644 --- a/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts +++ b/packages/zambdas/src/patient/appointment/create-appointment/validateRequestParameters.ts @@ -1,5 +1,5 @@ import Oystehr, { User } from '@oystehr/sdk'; -import { Appointment, Location, Practitioner, PractitionerRole, Schedule, Slot } from 'fhir/r4b'; +import { Appointment, List, Location, Practitioner, PractitionerRole, Schedule, Slot } from 'fhir/r4b'; import { DateTime } from 'luxon'; import { AllStates, @@ -21,6 +21,7 @@ import { makeSlotAtLocationExtensionEntry, MISSING_REQUIRED_PARAMETERS, NO_READ_ACCESS_TO_PATIENT_ERROR, + PaperworkFlowBase, parseQuestionnaireCanonicalExtension, PatientInfo, PersonSex, @@ -34,6 +35,7 @@ import { SLOT_FALLBACK_REROUTED_TAG, SLOT_QUESTIONNAIRE_CANONICAL_EXTENSION_URL, SLOT_UNAVAILABLE_ERROR, + toPaperworkFlowRecord, VisitType, } from 'utils'; import { @@ -194,6 +196,8 @@ export interface CreateAppointmentEffectInput { appointmentMetadata?: Appointment['meta']; followUpOptions?: FollowUpOptions; paperworkSubtype?: AppointmentPaperworkSubtype; + /** Practice paperwork flow id resolved from the booked service category (OTR-2309); stamped on the encounter. */ + paperworkFlowId?: string; /** * Unified booking-location resolution. Populated when the booking should be * attributed to a specific Location — either because the scheduleOwner IS @@ -426,15 +430,41 @@ export const createAppointmentComplexValidation = async ( })() : undefined; + // Practice paperwork flow (OTR-2309): if the booked service category configures a flow, it sets + // the base intake and is stamped on the encounter so the intake renderer serves the flow's forms. + // A 'consent-only' base behaves like the consent-form-only subtype; 'standard' uses the mode + // default. An explicit staff paperworkSubtype still overrides the flow's base. + let paperworkFlowId: string | undefined; + let flowBase: PaperworkFlowBase | undefined; + const slotCategoryCode = (slot.serviceCategory ?? []) + .flatMap((cc) => cc.coding ?? []) + .find((c) => c.system === SERVICE_CATEGORY_SYSTEM)?.code; + if (slotCategoryCode) { + const resolvedCategory = await resolveServiceCategory(slotCategoryCode, oystehrClient); + paperworkFlowId = resolvedCategory?.paperworkFlowId; + if (paperworkFlowId) { + // Booking must not fail over a paperwork-flow lookup, but a transient error here + // silently books the patient with the mode-default intake — log so it's visible. + const flowList = await oystehrClient.fhir + .get({ resourceType: 'List', id: paperworkFlowId }) + .catch((err) => { + console.warn(`create-appointment: failed to fetch paperwork flow List/${paperworkFlowId}:`, err); + return undefined; + }); + flowBase = flowList ? toPaperworkFlowRecord(flowList)?.base : undefined; + } + } + const effectivePaperworkSubtype = + validatedPaperworkSubtype ?? + (flowBase === 'consent-only' ? APPOINTMENT_PAPERWORK_SUBTYPE.CONSENT_FORM_ONLY : 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). + // Slot extension wins over flow, subtype, and ServiceMode — the explicit per-slot override. questionnaireCanonical = parseQuestionnaireCanonicalExtension(slotQuestionnaireExtension.valueString); console.log('Using questionnaire canonical from slot extension:', questionnaireCanonical); } else { - // Fall back to subtype-aware service-mode-based selection. - questionnaireCanonical = getCanonicalUrlForPrevisitQuestionnaire(serviceMode, validatedPaperworkSubtype); + // Flow base / subtype-aware service-mode-based selection. + questionnaireCanonical = getCanonicalUrlForPrevisitQuestionnaire(serviceMode, effectivePaperworkSubtype); } let visitType = getSlotIsPostTelemed(slot) ? VisitType.PostTelemed : VisitType.PreBook; @@ -520,6 +550,7 @@ export const createAppointmentComplexValidation = async ( appointmentMetadata, followUpOptions: input.followUpOptions, paperworkSubtype: validatedPaperworkSubtype, + paperworkFlowId, bookingLocation, attendingPractitioner, }; 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 index 7912b09f6b..1d612dfbd5 100644 --- a/packages/zambdas/src/patient/paperwork/get-practice-managed-questionnaires/index.ts +++ b/packages/zambdas/src/patient/paperwork/get-practice-managed-questionnaires/index.ts @@ -1,11 +1,16 @@ +import Oystehr from '@oystehr/sdk'; import { APIGatewayProxyResult } from 'aws-lambda'; -import { Encounter, Questionnaire, QuestionnaireItem, QuestionnaireResponse } from 'fhir/r4b'; +import { Encounter, List, Questionnaire, QuestionnaireItem, QuestionnaireResponse } from 'fhir/r4b'; import { + APPOINTMENT_PAPERWORK_FLOW_EXTENSION_URL, + composeFormIds, getAllFhirSearchPages, INVALID_INPUT_ERROR, MISSING_REQUEST_BODY, + PAPERWORK_FLOW_TAG, PRACTICE_MANAGED_QR_TAG, PRACTICE_MANAGED_QUESTIONNAIRE_TAG, + toPaperworkFlowRecord, } from 'utils'; import { createOystehrClient, @@ -55,6 +60,49 @@ function findInsertionPoint(items: QuestionnaireItem[]): string | undefined { return pages.length > 0 ? pages[pages.length - 1].linkId : undefined; } +/** + * If the encounter was booked with a practice paperwork flow (OTR-2309), return the flow's ordered + * form Questionnaire ids; otherwise undefined (so the caller falls back to per-canonical association). + * The flow id is stamped on the booked Encounter at create-appointment time. + */ +async function resolveFlowFormIds(oystehr: Oystehr, encounterId: string): Promise { + // Lookup failures fall back to no-service-flow rather than breaking paperwork, but a + // transient error here silently drops the flow's forms — log so the signal isn't lost. + const encounter = await oystehr.fhir.get({ resourceType: 'Encounter', id: encounterId }).catch((err) => { + console.warn(`resolveFlowFormIds: failed to fetch Encounter/${encounterId}:`, err); + return null; + }); + const flowId = (encounter?.extension ?? []).find((e) => e.url === APPOINTMENT_PAPERWORK_FLOW_EXTENSION_URL) + ?.valueString; + if (!flowId) return undefined; + const flowList = await oystehr.fhir.get({ resourceType: 'List', id: flowId }).catch((err) => { + console.warn(`resolveFlowFormIds: failed to fetch flow List/${flowId}:`, err); + return null; + }); + const record = flowList ? toPaperworkFlowRecord(flowList) : null; + return record?.formIds; +} + +/** + * The base intake's attached form ids (OTR-2309 v2): the base flow bound to `intakeUrl` (the + * booking's resolved base canonical). These compose onto every booking on that base intake, + * regardless of service flow. Returns [] when no base flow / no forms. + */ +async function resolveBaseFlowForms(oystehr: Oystehr, intakeUrl: string): Promise { + const lists = await getAllFhirSearchPages( + { + resourceType: 'List', + params: [{ name: '_tag', value: PAPERWORK_FLOW_TAG.code }], + }, + oystehr + ); + for (const list of lists) { + const record = toPaperworkFlowRecord(list); + if (record?.canonical === intakeUrl) return record.formIds; + } + return []; +} + let oystehrToken: string; export const index = wrapHandler( @@ -240,16 +288,21 @@ export const index = wrapHandler( ); 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). + // Compose the forms this booking shows (OTR-2309 v2): + // base intake forms (the base flow bound to the resolved canonical, on every such booking) + // + service flow forms (the flow stamped on the encounter, if any), + // base-first, de-duped keep-first. A standalone direct-id request bypasses composition. let associated: Questionnaire[]; if (directQuestionnaireId) { // Standalone form lookups must not serve retired forms to patients. associated = activePracticeManaged.filter((q) => q.id === directQuestionnaireId); } else { - associated = []; + const baseFormIds = await resolveBaseFlowForms(oystehr, intakeUrl); + const serviceFormIds = (encounterId ? await resolveFlowFormIds(oystehr, encounterId) : undefined) ?? []; + const byId = new Map(activePracticeManaged.map((q) => [q.id, q])); + associated = composeFormIds(baseFormIds, serviceFormIds) + .map((id) => byId.get(id)) + .filter((q): q is Questionnaire => q !== undefined); } // Check for existing QRs on this encounter diff --git a/packages/zambdas/test/appointment-helpers.test.ts b/packages/zambdas/test/appointment-helpers.test.ts index 96545c762a..2ae329dc63 100644 --- a/packages/zambdas/test/appointment-helpers.test.ts +++ b/packages/zambdas/test/appointment-helpers.test.ts @@ -1,6 +1,14 @@ import { Appointment } from 'fhir/r4b'; -import { OTTEHR_MODULE } from 'utils'; +import { + APPOINTMENT_PAPERWORK_SUBTYPE, + IN_PERSON_INTAKE_PAPERWORK_CANONICAL, + LITE_INTAKE_PAPERWORK_CANONICAL, + OTTEHR_MODULE, + ServiceMode, + VIRTUAL_INTAKE_PAPERWORK_CANONICAL, +} from 'utils'; import { describe, expect, test } from 'vitest'; +import { getCanonicalUrlForPrevisitQuestionnaire } from '../src/patient/appointment/helpers'; import { isOnDemandVirtualAppointment } from '../src/shared'; const makeAppointment = (module: OTTEHR_MODULE, appointmentTypeText?: string): Appointment => ({ @@ -42,3 +50,24 @@ describe('isOnDemandVirtualAppointment', () => { expect(isOnDemandVirtualAppointment(makeAppointment(OTTEHR_MODULE.IP, 'prebook'))).toBe(false); }); }); + +describe('getCanonicalUrlForPrevisitQuestionnaire', () => { + test('returns the in-person full intake for in-person mode', () => { + expect(getCanonicalUrlForPrevisitQuestionnaire(ServiceMode['in-person'])).toBe( + IN_PERSON_INTAKE_PAPERWORK_CANONICAL + ); + }); + + test('returns the virtual full intake for virtual mode', () => { + expect(getCanonicalUrlForPrevisitQuestionnaire(ServiceMode.virtual)).toBe(VIRTUAL_INTAKE_PAPERWORK_CANONICAL); + }); + + test('consent-only subtype overrides mode with the lite intake (flow base mapping)', () => { + expect( + getCanonicalUrlForPrevisitQuestionnaire(ServiceMode['in-person'], APPOINTMENT_PAPERWORK_SUBTYPE.CONSENT_FORM_ONLY) + ).toBe(LITE_INTAKE_PAPERWORK_CANONICAL); + expect( + getCanonicalUrlForPrevisitQuestionnaire(ServiceMode.virtual, APPOINTMENT_PAPERWORK_SUBTYPE.CONSENT_FORM_ONLY) + ).toBe(LITE_INTAKE_PAPERWORK_CANONICAL); + }); +});