Skip to main content
Glama
questionnaire-response.ts12.4 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Patient, QuestionnaireResponse, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer, Reference, } from '@medplum/fhirtypes'; import type { HealthieClient } from './client'; import { HEALTHIE_FORM_ANSWER_GROUP_ID_SYSTEM } from './constants'; // TypeScript interfaces for Healthie API structures export interface HealthieFormAnswerGroup { id: string; user_id: string; name: string; created_at: string; finished?: boolean; form_answers: HealthieFormAnswer[]; } export interface HealthieFormAnswer { label: string; displayed_answer?: string; answer: string; id: string; custom_module: HealthieCustomModule; } export interface HealthieCustomModule { id: string; mod_type: string; label: string; required?: boolean; } // GraphQL query for fetching form answer groups const GET_FORM_ANSWER_GROUPS_QUERY = ` query formAnswerGroups($userId: String!) { formAnswerGroups(user_id: $userId, page_size: 100) { id user_id name created_at finished form_answers { label displayed_answer answer id custom_module { id mod_type label required } } } } `; /** * Fetches Healthie FormAnswerGroups for a patient from the API * @param patientId - The Healthie patient ID * @param healthieClient - The Healthie API client * @returns Array of Healthie FormAnswerGroup objects */ export async function fetchHealthieFormAnswerGroups( patientId: string, healthieClient: HealthieClient ): Promise<HealthieFormAnswerGroup[]> { const result = await healthieClient.query<{ formAnswerGroups: HealthieFormAnswerGroup[] }>( GET_FORM_ANSWER_GROUPS_QUERY, { userId: patientId } ); return result.formAnswerGroups; } /** * Converts a Healthie FormAnswerGroup to a FHIR QuestionnaireResponse * @param formAnswerGroup - The Healthie FormAnswerGroup * @param healthieApiUrl - The base URL for Healthie API (for canonical references) * @param patientReference - The reference to the patient * @returns FHIR QuestionnaireResponse resource */ export function convertHealthieFormAnswerGroupToFhir( formAnswerGroup: HealthieFormAnswerGroup, healthieApiUrl: string, patientReference: Reference<Patient> ): QuestionnaireResponse { // Group answers by custom_module.id to handle multiple answers for the same question const answerGroups = new Map<string, HealthieFormAnswer[]>(); formAnswerGroup.form_answers .filter((answer) => answer.answer && answer.answer.trim() !== '') // Filter out empty answers .forEach((answer) => { const moduleId = answer.custom_module.id; if (!answerGroups.has(moduleId)) { answerGroups.set(moduleId, []); } const moduleAnswers = answerGroups.get(moduleId); if (moduleAnswers) { moduleAnswers.push(answer); } }); // Convert each group to FHIR items const items: QuestionnaireResponseItem[] = []; for (const [, answers] of answerGroups) { const item = convertHealthieAnswerGroupToFhirItem(answers); if (item) { items.push(item); } } return { resourceType: 'QuestionnaireResponse', identifier: { system: HEALTHIE_FORM_ANSWER_GROUP_ID_SYSTEM, value: formAnswerGroup.id }, questionnaire: `${healthieApiUrl}/Questionnaire/healthie-${createSlug(formAnswerGroup.name)}`, status: formAnswerGroup.finished ? 'completed' : 'in-progress', subject: patientReference, authored: convertHealthieTimestampToIso(formAnswerGroup.created_at), item: items, }; } /** * Creates a URL-friendly slug from a string * @param text - The text to slugify * @returns URL-friendly slug */ export function createSlug(text: string): string { return text .toLowerCase() .replaceAll(/[^a-z0-9\s-]/g, '') // Remove special characters .replaceAll(/\s+/g, '-') // Replace spaces with hyphens .replaceAll(/-+/g, '-') // Replace multiple hyphens with single .trim(); } /** * Converts a Healthie timestamp to ISO format * Healthie format: "YYYY-MM-DD HH:mm:ss -HHMM" (e.g., "2025-07-19 10:48:32 -0700") * ISO format: "YYYY-MM-DDTHH:mm:ss.sssZ" or "YYYY-MM-DDTHH:mm:ss±HH:MM" * @param healthieTimestamp - The timestamp from Healthie * @returns ISO formatted timestamp */ export function convertHealthieTimestampToIso(healthieTimestamp: string): string { // Healthie format: "2025-07-19 10:48:32 -0700" // We need to convert to: "2025-07-19T10:48:32-07:00" // Split the timestamp into date/time part and timezone part const parts = healthieTimestamp.trim().split(' '); if (parts.length !== 3) { // Fallback: if format is unexpected, return as-is return healthieTimestamp; } const datePart = parts[0]; // "2025-07-19" const timePart = parts[1]; // "10:48:32" const timezonePart = parts[2]; // "-0700" // Format timezone from "-0700" to "-07:00" let formattedTimezone = timezonePart; if (timezonePart.length === 5 && (timezonePart.startsWith('+') || timezonePart.startsWith('-'))) { const sign = timezonePart[0]; const hours = timezonePart.slice(1, 3); const minutes = timezonePart.slice(3, 5); formattedTimezone = `${sign}${hours}:${minutes}`; } // Combine into ISO format return `${datePart}T${timePart}${formattedTimezone}`; } /** * Converts a group of Healthie FormAnswers (for the same question) to a FHIR QuestionnaireResponse item * @param answers - The group of Healthie FormAnswers for the same question * @returns FHIR QuestionnaireResponse item or undefined if should be filtered out */ function convertHealthieAnswerGroupToFhirItem(answers: HealthieFormAnswer[]): QuestionnaireResponseItem | undefined { if (answers.length === 0) { return undefined; } const firstAnswer = answers[0]; const { custom_module } = firstAnswer; // Filter out display-only fields if (['label', 'read_only', 'hipaa'].includes(custom_module.mod_type)) { return undefined; } // Handle matrix questions (TODO: implement parsing) if (custom_module.mod_type === 'matrix') { // Parse matrix answer and create sub-items const matrixItem = parseMatrixAnswer(firstAnswer.answer, custom_module.id, custom_module.label); return matrixItem; } const item: QuestionnaireResponseItem = { linkId: custom_module.id, text: custom_module.label, answer: [], }; // Convert each answer in the group for (const answer of answers) { const fhirAnswers = convertHealthieAnswerValueToFhir(answer); if (fhirAnswers && item.answer) { // fhirAnswers is now an array to handle newline-separated values item.answer.push(...fhirAnswers); } } return item.answer && item.answer.length > 0 ? item : undefined; } /** * Converts a single Healthie FormAnswer value to FHIR answer values * Handles newline-separated multiple values (e.g., checkbox selections) * @param answer - The Healthie FormAnswer * @returns Array of FHIR answer values */ function convertHealthieAnswerValueToFhir(answer: HealthieFormAnswer): QuestionnaireResponseItemAnswer[] { const { custom_module, answer: answerValue } = answer; if (answerValue.trim().length === 0) { return []; } // Helper function to split newline-separated values const splitAnswerValues = (value: string): string[] => { return value .split('\n') .map((v) => v.trim()) .filter((v) => v !== ''); }; switch (custom_module.mod_type) { case 'checkbox': { // Checkbox answers can be newline-separated for multiple selections const values = splitAnswerValues(answerValue); return values.map((value) => ({ valueString: value })); } case 'radio': case 'text': case 'textarea': case 'name': return [{ valueString: answerValue }]; case 'agree_to_above': return [ { valueBoolean: answerValue.toLowerCase() === 'true' || answerValue.toLowerCase() === 'yes' || answerValue.includes('agree'), }, ]; case 'date': case 'dob': return [{ valueDate: answerValue }]; case 'time': return [{ valueTime: answerValue }]; case 'number': case 'Body Fat %': return [{ valueQuantity: { value: Number.parseFloat(answerValue) } }]; case 'signature': { // TODO: Use FHIR SDC extension for signatures const base64Data = answerValue.includes(',') ? answerValue.split(',')[1] : answerValue; return [ { valueAttachment: { contentType: 'image/png', data: base64Data, }, }, ]; } default: return [{ valueString: answerValue }]; } } /** * Parses a matrix answer JSON string and converts to FHIR QuestionnaireResponse item * @param matrixAnswer - The JSON string containing matrix data * @param linkId - The parent question's linkId * @param label - The parent question's label * @returns FHIR QuestionnaireResponse item with sub-items */ function parseMatrixAnswer(matrixAnswer: string, linkId: string, label: string): QuestionnaireResponseItem | undefined { try { // Parse the outer JSON array const matrixData = JSON.parse(matrixAnswer) as string[][]; if (!Array.isArray(matrixData) || matrixData.length === 0) { return undefined; } // First row contains column headers const headerRow = matrixData[0]; const columnHeaders: string[] = []; // Parse each cell in the header row for (const cellJson of headerRow) { const cellData = JSON.parse(cellJson); columnHeaders.push(cellData.value || ''); } // Create the main matrix item const matrixItem: QuestionnaireResponseItem = { linkId, text: label.trim() || undefined, item: [], }; // Process data rows (skip header row) for (let rowIndex = 1; rowIndex < matrixData.length; rowIndex++) { const row = matrixData[rowIndex]; if (!Array.isArray(row) || row.length === 0) { continue; } // First cell is the row name let rowName = ''; const firstCellData = JSON.parse(row[0]); rowName = firstCellData.value?.trim() || `Row ${rowIndex}`; // Create a sub-item for this row const rowItem: QuestionnaireResponseItem = { linkId: `${linkId}.${rowIndex}`, text: rowName, item: [], }; // Process the remaining cells in the row (skip first cell which is the row name) for (let colIndex = 1; colIndex < row.length && colIndex < columnHeaders.length; colIndex++) { const cellJson = row[colIndex]; const columnHeader = columnHeaders[colIndex]; if (!columnHeader) { continue; } try { const cellData = JSON.parse(cellJson); const cellValue = cellData.value; const cellType = cellData.type; // Only create sub-items for cells with values const hasValue = (cellValue && typeof cellValue === 'string' && cellValue.trim() !== '') || typeof cellValue === 'boolean'; if (hasValue) { const cellItem = { linkId: `${linkId}.${rowIndex}.${colIndex}`, text: columnHeader, answer: [] as QuestionnaireResponseItemAnswer[], } satisfies QuestionnaireResponseItem; // Convert cell value based on type if (cellType === 'checkbox') { if (cellItem.answer) { cellItem.answer.push({ valueBoolean: cellValue as boolean }); } } else { cellItem.answer.push({ valueString: cellValue as string }); } if (rowItem.item) { rowItem.item.push(cellItem); } } } catch { // Skip cells that can't be parsed continue; } } // Only add the row item if it has sub-items if (rowItem.item && rowItem.item.length > 0 && matrixItem.item) { matrixItem.item.push(rowItem); } } return matrixItem.item && matrixItem.item.length > 0 ? matrixItem : undefined; } catch (error) { console.log('Failed to parse matrix answer:', error); return undefined; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server