mirror of
https://github.com/temetro/temetro.git
synced 2026-08-24 08:46:27 +00:00
43eaccb97e
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>
169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|