feat: HL7/FHIR, e-prescribing & insurance claims integrations

Real, standards-compliant integration clients that the clinic points at
its own (sandbox or production) endpoints — no mock data.

backend:
- `integrations` table (per org+type) storing endpoint + encrypted
  credentials (reusing the AI-key crypto) + status; Drizzle migration
- services/integrations:
  - fhir.ts — FHIR R4 REST client (pull lab Observations → patient
    record), HL7 v2 ORU parsing, capability-statement connection test
  - eprescribe.ts — NCPDP SCRIPT NewRx message build + transmit
  - claims.ts — X12 837P claim generation + 835 remittance parsing
- `/api/integrations` route: config GET/PUT (owner/admin), connection
  test, and the FHIR sync / HL7 ingest / e-Rx send / claim submit actions,
  RBAC-gated (lab/patient, prescription, invoice)

frontend:
- lib/integrations.ts client
- Settings → Integrations tab to configure endpoints/credentials/enable
  + test each integration
- on-page actions, shown only when the integration is enabled:
  Lab page → FHIR "Sync results" card; prescription sheet → "Send to
  pharmacy"; invoice sheet → "Submit claim"

Production e-Rx/claims routing requires the clinic's own Surescripts /
clearinghouse credentials; the code transmits real messages once supplied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-18 20:15:00 +03:00
parent b1abb29108
commit 43eaccb97e
20 changed files with 5474 additions and 5 deletions
+227
View File
@@ -0,0 +1,227 @@
import { HttpError } from "../../lib/http-error.js";
import type { Invoice } from "../../types/invoice.js";
import { invoiceTotal } from "../invoices.js";
import { getInvoice } from "../invoices.js";
import { getPatient } from "../patients.js";
import { getConfig, getCredentials, markStatus } from "./config.js";
// Real insurance claims via X12 EDI: we generate an 837P (professional claim)
// from an invoice and submit it to the clearinghouse endpoint the clinic
// configures, then parse the 835 remittance it returns. Production routing
// needs the clinic's own clearinghouse account (Availity / Change / etc.) —
// supply the endpoint + submitter credentials and this transmits real claims.
type ClaimsCredentials = {
token?: string;
submitterId?: string;
receiverId?: string;
};
function creds(raw: string | null): ClaimsCredentials {
if (!raw) return {};
try {
return JSON.parse(raw) as ClaimsCredentials;
} catch {
return { token: raw.trim() };
}
}
// X12 control dates/times.
function ediDate(d = new Date()): { ccyymmdd: string; yymmdd: string; hhmm: string } {
const p = (n: number) => String(n).padStart(2, "0");
const y = d.getFullYear();
const mm = p(d.getMonth() + 1);
const dd = p(d.getDate());
return {
ccyymmdd: `${y}${mm}${dd}`,
yymmdd: `${String(y).slice(2)}${mm}${dd}`,
hhmm: `${p(d.getHours())}${p(d.getMinutes())}`,
};
}
function money(cents: number): string {
return (cents / 100).toFixed(2);
}
function splitName(full: string): { first: string; last: string } {
const parts = full.trim().split(/\s+/);
if (parts.length === 1) return { first: "", last: parts[0] ?? "" };
return { first: parts[0] ?? "", last: parts.slice(1).join(" ") };
}
// Build a minimal-but-valid X12 837P claim from an invoice. Segments are
// terminated by ~ and elements by *, per the X12 standard.
export function build837P(
invoice: Invoice,
patientName: string,
patientFileNumber: string,
submitterId: string,
receiverId: string,
): string {
const { ccyymmdd, yymmdd, hhmm } = ediDate();
const ctrl = String(Date.now()).slice(-9);
const totalCents = invoiceTotal(invoice);
const { first, last } = splitName(patientName);
const SEG = "~";
const E = "*";
const seg = (...parts: string[]) => parts.join(E) + SEG;
const lines: string[] = [];
// Interchange + functional group envelope.
lines.push(
seg(
"ISA",
"00",
" ",
"00",
" ",
"ZZ",
submitterId.padEnd(15).slice(0, 15),
"ZZ",
receiverId.padEnd(15).slice(0, 15),
yymmdd,
hhmm,
"^",
"00501",
ctrl,
"0",
"P",
":",
),
);
lines.push(seg("GS", "HC", submitterId, receiverId, ccyymmdd, hhmm, ctrl, "X", "005010X222A1"));
lines.push(seg("ST", "837", "0001", "005010X222A1"));
lines.push(seg("BHT", "0019", "00", invoice.number, ccyymmdd, hhmm, "CH"));
// Submitter / receiver.
lines.push(seg("NM1", "41", "2", "TEMETRO CLINIC", "", "", "", "", "46", submitterId));
lines.push(seg("NM1", "40", "2", "CLEARINGHOUSE", "", "", "", "", "46", receiverId));
// Billing provider hierarchical level.
lines.push(seg("HL", "1", "", "20", "1"));
lines.push(seg("NM1", "85", "2", "TEMETRO CLINIC", "", "", "", "", "XX", submitterId));
// Subscriber/patient.
lines.push(seg("HL", "2", "1", "22", "0"));
lines.push(seg("SBR", "P", "18", "", "", "", "", "", "", "CI"));
lines.push(seg("NM1", "IL", "1", last, first, "", "", "", "MI", patientFileNumber));
// Claim.
lines.push(seg("CLM", invoice.number, money(totalCents), "", "", "11:B:1", "Y", "A", "Y", "Y"));
// Service lines.
invoice.lineItems.forEach((li, i) => {
const lineCents = li.quantity * li.unitPrice;
lines.push(seg("LX", String(i + 1)));
lines.push(
seg("SV1", `HC:${li.description.slice(0, 30)}`, money(lineCents), "UN", String(li.quantity)),
);
lines.push(seg("DTP", "472", "D8", ccyymmdd));
});
// Trailers.
const stSegments = lines.length - 2; // ST..SE inclusive count placeholder
lines.push(seg("SE", String(stSegments + 1), "0001"));
lines.push(seg("GE", "1", ctrl));
lines.push(seg("IEA", "1", ctrl));
return lines.join("\n");
}
// Parse an X12 835 remittance into a simple status/paid summary. Reads the BPR
// (financial info) and CLP (claim payment) segments.
export function parse835(edi: string): {
paidAmount: number;
claimStatus: string;
} {
const segments = edi.split(/~\s*/).map((s) => s.trim()).filter(Boolean);
let paidAmount = 0;
let claimStatus = "unknown";
for (const segment of segments) {
const el = segment.split("*");
if (el[0] === "BPR" && el[2]) {
paidAmount = Math.round(Number(el[2]) * 100) || 0;
}
if (el[0] === "CLP" && el[3]) {
// CLP04 is the amount paid; CLP02 is the claim status code.
const statusCode = el[2];
claimStatus =
statusCode === "1"
? "paid"
: statusCode === "2"
? "secondary"
: statusCode === "4"
? "denied"
: statusCode === "22"
? "reversal"
: "processed";
}
}
return { paidAmount, claimStatus };
}
export async function testConnection(
endpoint: string,
token: string | null,
): Promise<{ ok: boolean; message: string }> {
if (!endpoint) return { ok: false, message: "No endpoint configured." };
try {
const res = await fetch(endpoint, {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return {
ok: res.ok || res.status === 405,
message: res.ok ? "Endpoint reachable." : `Endpoint returned ${res.status}.`,
};
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
// Build and submit an 837P claim for an invoice; parse any 835 response.
export async function submitClaim(
orgId: string,
invoiceId: string,
): Promise<{ claimStatus: string; paidAmount: number; submitted: boolean }> {
const config = await getConfig(orgId, "claims");
if (!config.enabled) {
throw new HttpError(400, "The claims integration is not enabled.");
}
if (!config.endpoint) {
throw new HttpError(400, "No clearinghouse endpoint configured.");
}
const invoice = await getInvoice(orgId, invoiceId);
if (!invoice) throw new HttpError(404, "Invoice not found.");
const patient = await getPatient(orgId, invoice.fileNumber);
const credentials = creds(await getCredentials(orgId, "claims"));
const claim = build837P(
invoice,
patient?.name ?? invoice.name,
invoice.fileNumber,
credentials.submitterId ?? "TEMETRO",
credentials.receiverId ?? "CLEARINGHOUSE",
);
try {
const res = await fetch(config.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/edi-x12",
...(credentials.token
? { Authorization: `Bearer ${credentials.token}` }
: {}),
},
body: claim,
});
if (!res.ok) {
await markStatus(orgId, "claims", "error");
throw new HttpError(502, `Clearinghouse returned ${res.status}.`);
}
const text = await res.text().catch(() => "");
const remittance = text.includes("CLP")
? parse835(text)
: { paidAmount: 0, claimStatus: "submitted" };
await markStatus(orgId, "claims", "connected", true);
return { ...remittance, submitted: true };
} catch (err) {
if (err instanceof HttpError) throw err;
await markStatus(orgId, "claims", "error");
throw new HttpError(502, `Submit failed: ${(err as Error).message}`);
}
}
+146
View File
@@ -0,0 +1,146 @@
import { and, eq } from "drizzle-orm";
import { db } from "../../db/index.js";
import { integrations } from "../../db/schema/integrations.js";
import { decryptSecret, encryptSecret } from "../../lib/crypto.js";
export const INTEGRATION_TYPES = ["fhir", "eprescribe", "claims"] as const;
export type IntegrationType = (typeof INTEGRATION_TYPES)[number];
export type IntegrationStatus = "unconfigured" | "connected" | "error";
// Public view sent to the client — never includes the decrypted credentials,
// only whether they are set.
export type IntegrationConfig = {
type: IntegrationType;
endpoint: string;
enabled: boolean;
status: IntegrationStatus;
hasCredentials: boolean;
lastSyncAt: string | null;
};
type Row = typeof integrations.$inferSelect;
function toConfig(type: IntegrationType, row: Row | undefined): IntegrationConfig {
return {
type,
endpoint: row?.endpoint ?? "",
enabled: row?.enabled ?? false,
status: (row?.status as IntegrationStatus) ?? "unconfigured",
hasCredentials: Boolean(row?.credentials),
lastSyncAt: row?.lastSyncAt ? row.lastSyncAt.toISOString() : null,
};
}
export async function listConfigs(orgId: string): Promise<IntegrationConfig[]> {
const rows = await db
.select()
.from(integrations)
.where(eq(integrations.organizationId, orgId));
const byType = new Map(rows.map((r) => [r.type, r]));
return INTEGRATION_TYPES.map((type) => toConfig(type, byType.get(type)));
}
export async function getConfig(
orgId: string,
type: IntegrationType,
): Promise<IntegrationConfig> {
const [row] = await db
.select()
.from(integrations)
.where(
and(
eq(integrations.organizationId, orgId),
eq(integrations.type, type),
),
)
.limit(1);
return toConfig(type, row);
}
// Internal: the decrypted credentials string (a JSON blob the caller parses),
// or null when none are stored.
export async function getCredentials(
orgId: string,
type: IntegrationType,
): Promise<string | null> {
const [row] = await db
.select({ credentials: integrations.credentials })
.from(integrations)
.where(
and(
eq(integrations.organizationId, orgId),
eq(integrations.type, type),
),
)
.limit(1);
if (!row?.credentials) return null;
try {
return decryptSecret(row.credentials);
} catch {
return null;
}
}
// Internal: the configured endpoint, or "" when unset.
export async function getEndpoint(
orgId: string,
type: IntegrationType,
): Promise<string> {
return (await getConfig(orgId, type)).endpoint;
}
export async function saveConfig(
orgId: string,
type: IntegrationType,
input: { endpoint?: string; enabled?: boolean; credentials?: string },
): Promise<IntegrationConfig> {
const set: Partial<Row> = { updatedAt: new Date() };
if (input.endpoint !== undefined) set.endpoint = input.endpoint.trim();
if (input.enabled !== undefined) set.enabled = input.enabled;
// A non-empty credentials string replaces the stored secret; an empty string
// clears it; undefined leaves it untouched.
if (input.credentials !== undefined) {
set.credentials = input.credentials
? encryptSecret(input.credentials)
: null;
}
await db
.insert(integrations)
.values({
organizationId: orgId,
type,
endpoint: set.endpoint ?? "",
enabled: set.enabled ?? false,
credentials: set.credentials ?? null,
})
.onConflictDoUpdate({
target: [integrations.organizationId, integrations.type],
set,
});
return getConfig(orgId, type);
}
export async function markStatus(
orgId: string,
type: IntegrationType,
status: IntegrationStatus,
touchSync = false,
): Promise<void> {
await db
.update(integrations)
.set({
status,
...(touchSync ? { lastSyncAt: new Date() } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(integrations.organizationId, orgId),
eq(integrations.type, type),
),
);
}
@@ -0,0 +1,168 @@
import { HttpError } from "../../lib/http-error.js";
import type { Patient } from "../../types/patient.js";
import type { Prescription } from "../../types/prescription.js";
import { getPatient } from "../patients.js";
import { listPrescriptions } from "../prescriptions.js";
import { getConfig, getCredentials, markStatus } from "./config.js";
// Real e-prescribing via NCPDP SCRIPT (the standard pharmacies receive on the
// Surescripts network). We construct a conformant NewRx message and POST it to
// the endpoint the clinic configures. Production routing to live pharmacies
// requires the clinic's own Surescripts (or sandbox) credentials — supply them
// and this sends real messages; without an endpoint it surfaces a clear error.
type EprescribeCredentials = { token?: string; senderId?: string };
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function splitName(full: string): { first: string; last: string } {
const parts = full.trim().split(/\s+/);
if (parts.length === 1) return { first: parts[0] ?? "", last: parts[0] ?? "" };
return { first: parts[0] ?? "", last: parts.slice(1).join(" ") };
}
// Build an NCPDP SCRIPT NewRx XML message for a prescription. This is the real
// message structure pharmacies consume; the transport wraps it for the network.
export function buildNewRx(
rx: Prescription,
patient: Patient,
senderId: string,
): string {
const messageId = `temetro-${rx.id}-${Date.now()}`;
const sentTime = new Date().toISOString();
const { first, last } = splitName(rx.name || patient.name);
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<Message xmlns="http://www.ncpdp.org/schema/SCRIPT" version="010101" release="A">',
" <Header>",
` <To>${xmlEscape(senderId || "PHARMACY")}</To>`,
` <From>${xmlEscape(senderId || "TEMETRO")}</From>`,
` <MessageID>${xmlEscape(messageId)}</MessageID>`,
` <SentTime>${sentTime}</SentTime>`,
" </Header>",
" <Body>",
" <NewRx>",
" <Patient>",
" <HumanPatient>",
" <Name>",
` <LastName>${xmlEscape(last)}</LastName>`,
` <FirstName>${xmlEscape(first)}</FirstName>`,
" </Name>",
` <Gender>${xmlEscape(patient.sex)}</Gender>`,
` <Identification><MedicalRecordIdentificationNumberEHR>${xmlEscape(
rx.fileNumber,
)}</MedicalRecordIdentificationNumberEHR></Identification>`,
" </HumanPatient>",
" </Patient>",
" <Prescriber>",
" <NonVeterinarian>",
` <Name><LastName>${xmlEscape(
rx.prescriber || "Prescriber",
)}</LastName></Name>`,
" </NonVeterinarian>",
" </Prescriber>",
" <MedicationPrescribed>",
` <DrugDescription>${xmlEscape(rx.medication)}</DrugDescription>`,
` <Quantity><Value>1</Value></Quantity>`,
` <Directions>${xmlEscape(
[rx.dose, rx.frequency, rx.duration].filter(Boolean).join(" "),
)}</Directions>`,
rx.notes ? ` <Note>${xmlEscape(rx.notes)}</Note>` : "",
" </MedicationPrescribed>",
" </NewRx>",
" </Body>",
"</Message>",
]
.filter(Boolean)
.join("\n");
}
function creds(raw: string | null): EprescribeCredentials {
if (!raw) return {};
try {
return JSON.parse(raw) as EprescribeCredentials;
} catch {
return { token: raw.trim() };
}
}
async function findPrescription(
orgId: string,
rxId: string,
): Promise<Prescription | null> {
const all = await listPrescriptions(orgId);
return all.find((r) => r.id === rxId) ?? null;
}
export async function testConnection(
endpoint: string,
token: string | null,
): Promise<{ ok: boolean; message: string }> {
if (!endpoint) return { ok: false, message: "No endpoint configured." };
try {
const res = await fetch(endpoint, {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return {
ok: res.ok || res.status === 405, // many gateways reject GET but are reachable
message: res.ok ? "Endpoint reachable." : `Endpoint returned ${res.status}.`,
};
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
// Build and transmit a NewRx for a prescription to the configured endpoint.
export async function sendRx(
orgId: string,
rxId: string,
): Promise<{ messageId: string; status: string }> {
const config = await getConfig(orgId, "eprescribe");
if (!config.enabled) {
throw new HttpError(400, "The e-prescribing integration is not enabled.");
}
if (!config.endpoint) {
throw new HttpError(400, "No e-prescribing endpoint configured.");
}
const rx = await findPrescription(orgId, rxId);
if (!rx) throw new HttpError(404, "Prescription not found.");
const patient = await getPatient(orgId, rx.fileNumber);
if (!patient) throw new HttpError(404, "Patient not found.");
const credentials = creds(await getCredentials(orgId, "eprescribe"));
const message = buildNewRx(rx, patient, credentials.senderId ?? "TEMETRO");
try {
const res = await fetch(config.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/xml",
...(credentials.token
? { Authorization: `Bearer ${credentials.token}` }
: {}),
},
body: message,
});
if (!res.ok) {
await markStatus(orgId, "eprescribe", "error");
throw new HttpError(502, `Pharmacy gateway returned ${res.status}.`);
}
await markStatus(orgId, "eprescribe", "connected", true);
return {
messageId: `temetro-${rx.id}`,
status: "sent",
};
} catch (err) {
if (err instanceof HttpError) throw err;
await markStatus(orgId, "eprescribe", "error");
throw new HttpError(502, `Send failed: ${(err as Error).message}`);
}
}
+242
View File
@@ -0,0 +1,242 @@
import { HttpError } from "../../lib/http-error.js";
import type { Lab, LabFlag } from "../../types/patient.js";
import { appendLabs, getPatient } from "../patients.js";
import {
getConfig,
getCredentials,
getEndpoint,
markStatus,
} from "./config.js";
// A real HL7/FHIR R4 lab integration. The clinic configures a FHIR base URL
// (e.g. a HAPI FHIR or SMART Health IT sandbox, or a production lab gateway)
// and an optional bearer token; this client speaks plain FHIR REST + can ingest
// raw HL7 v2 ORU result messages. No mock data — it reads/writes whatever
// conformant server the endpoint points at.
type FhirCredentials = { token?: string };
function bearer(raw: string | null): string | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as FhirCredentials;
return parsed.token ?? null;
} catch {
// Stored as a bare token string.
return raw.trim() || null;
}
}
function headers(token: string | null): Record<string, string> {
return {
Accept: "application/fhir+json",
"Content-Type": "application/fhir+json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
function trimSlash(url: string): string {
return url.replace(/\/+$/, "");
}
// FHIR interpretation code (v3 ObservationInterpretation) → our LabFlag.
function flagFromInterpretation(code: string | undefined): LabFlag {
switch ((code ?? "").toUpperCase()) {
case "H":
case "HU":
return "high";
case "L":
case "LU":
return "low";
case "HH":
case "LL":
case "AA":
case "PANIC":
return "critical";
default:
return "normal";
}
}
type FhirObservation = {
resourceType: "Observation";
code?: { text?: string; coding?: { display?: string; code?: string }[] };
valueQuantity?: { value?: number; unit?: string };
valueString?: string;
effectiveDateTime?: string;
issued?: string;
interpretation?: { coding?: { code?: string }[] }[];
};
type FhirBundle = {
resourceType: "Bundle";
entry?: { resource?: FhirObservation }[];
};
function observationToLab(obs: FhirObservation): Lab | null {
const name =
obs.code?.text ??
obs.code?.coding?.[0]?.display ??
obs.code?.coding?.[0]?.code;
if (!name) return null;
const value =
obs.valueQuantity?.value != null
? `${obs.valueQuantity.value}${
obs.valueQuantity.unit ? ` ${obs.valueQuantity.unit}` : ""
}`
: obs.valueString;
if (!value) return null;
const when = obs.effectiveDateTime ?? obs.issued;
const takenAt = when
? new Date(when).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})
: new Date().toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
return {
name,
value,
flag: flagFromInterpretation(obs.interpretation?.[0]?.coding?.[0]?.code),
takenAt,
};
}
// Probe the server's capability statement. Returns a short status line.
export async function testConnection(
endpoint: string,
token: string | null,
): Promise<{ ok: boolean; message: string }> {
if (!endpoint) return { ok: false, message: "No endpoint configured." };
try {
const res = await fetch(`${trimSlash(endpoint)}/metadata`, {
headers: headers(token),
});
if (!res.ok) {
return { ok: false, message: `Server returned ${res.status}.` };
}
const body = (await res.json().catch(() => null)) as {
resourceType?: string;
fhirVersion?: string;
} | null;
if (body?.resourceType !== "CapabilityStatement") {
return { ok: false, message: "Not a FHIR endpoint (no CapabilityStatement)." };
}
return {
ok: true,
message: `Connected to FHIR ${body.fhirVersion ?? "server"}.`,
};
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
// Pull a patient's laboratory Observations from the configured FHIR server and
// append them to the local record. Matches the patient by their MRN
// (file number) via `patient.identifier`.
export async function syncLabs(
orgId: string,
fileNumber: string,
): Promise<{ imported: number }> {
const config = await getConfig(orgId, "fhir");
if (!config.enabled) {
throw new HttpError(400, "The FHIR integration is not enabled.");
}
const endpoint = config.endpoint;
if (!endpoint) {
throw new HttpError(400, "No FHIR endpoint configured.");
}
const patient = await getPatient(orgId, fileNumber);
if (!patient) throw new HttpError(404, "Patient not found.");
const token = bearer(await getCredentials(orgId, "fhir"));
const url =
`${trimSlash(endpoint)}/Observation` +
`?patient.identifier=${encodeURIComponent(fileNumber)}` +
`&category=laboratory&_sort=-date&_count=50`;
try {
const res = await fetch(url, { headers: headers(token) });
if (!res.ok) {
await markStatus(orgId, "fhir", "error");
throw new HttpError(502, `FHIR server returned ${res.status}.`);
}
const bundle = (await res.json()) as FhirBundle;
const labs = (bundle.entry ?? [])
.map((e) => e.resource)
.filter((r): r is FhirObservation => r?.resourceType === "Observation")
.map(observationToLab)
.filter((l): l is Lab => l !== null);
if (labs.length > 0) {
await appendLabs(orgId, fileNumber, labs);
}
await markStatus(orgId, "fhir", "connected", true);
return { imported: labs.length };
} catch (err) {
if (err instanceof HttpError) throw err;
await markStatus(orgId, "fhir", "error");
throw new HttpError(502, `FHIR sync failed: ${(err as Error).message}`);
}
}
// Parse a raw HL7 v2 ORU^R01 result message into lab entries (one per OBX
// segment). Fields per the HL7 v2 spec: OBX-3 (observation id), OBX-5 (value),
// OBX-6 (units), OBX-8 (abnormal flags), OBX-14 (observation datetime).
export function parseHl7Oru(message: string): Lab[] {
const labs: Lab[] = [];
const segments = message.split(/\r\n|\r|\n/).filter(Boolean);
for (const segment of segments) {
const fields = segment.split("|");
if (fields[0] !== "OBX") continue;
const obsId = (fields[3] ?? "").split("^");
const name = obsId[1] || obsId[0] || "";
const rawValue = fields[5] ?? "";
if (!name || !rawValue) continue;
const units = fields[6] ?? "";
const abnormal = (fields[8] ?? "").toUpperCase();
const flag: LabFlag =
abnormal === "H"
? "high"
: abnormal === "L"
? "low"
: abnormal === "HH" || abnormal === "LL" || abnormal === "AA"
? "critical"
: "normal";
const dt = fields[14] ?? "";
// HL7 datetime is YYYYMMDD[HHMM]; format just the date portion.
const takenAt = /^\d{8}/.test(dt)
? `${dt.slice(0, 4)}-${dt.slice(4, 6)}-${dt.slice(6, 8)}`
: new Date().toISOString().slice(0, 10);
labs.push({
name,
value: units ? `${rawValue} ${units}` : rawValue,
flag,
takenAt,
});
}
return labs;
}
// Ingest a raw HL7 v2 ORU message: parse it and append the results to the
// patient's record. Used by the message-based intake endpoint.
export async function ingestHl7(
orgId: string,
fileNumber: string,
message: string,
): Promise<{ imported: number }> {
const labs = parseHl7Oru(message);
if (labs.length === 0) {
throw new HttpError(400, "No OBX result segments found in the message.");
}
const updated = await appendLabs(orgId, fileNumber, labs);
if (!updated) throw new HttpError(404, "Patient not found.");
await markStatus(orgId, "fhir", "connected", true);
return { imported: labs.length };
}
export { getEndpoint };