mirror of
https://github.com/temetro/temetro.git
synced 2026-08-10 02:29:58 +00:00
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:
@@ -15,3 +15,4 @@ export * from "./ai.js";
|
||||
export * from "./ai-chat.js";
|
||||
export * from "./org-ai-policy.js";
|
||||
export * from "./attachments.js";
|
||||
export * from "./integrations.js";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
boolean,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization } from "./auth.js";
|
||||
|
||||
// External healthcare-system integrations, configured per clinic. One row per
|
||||
// (organization, type):
|
||||
// fhir → HL7/FHIR lab-system connection (lab results in/out)
|
||||
// eprescribe → NCPDP SCRIPT e-prescribing to pharmacies
|
||||
// claims → X12 837/835 insurance claims clearinghouse
|
||||
// `credentials` is an encrypted JSON blob (API key / bearer token / submitter
|
||||
// IDs — shape depends on the type); `endpoint` is the base URL the clinic
|
||||
// points the integration at (a vendor sandbox or production gateway).
|
||||
export const integrations = pgTable(
|
||||
"integrations",
|
||||
{
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull(),
|
||||
endpoint: text("endpoint").notNull().default(""),
|
||||
credentials: text("credentials"),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
// "unconfigured" | "connected" | "error" — last known connection state.
|
||||
status: text("status").notNull().default("unconfigured"),
|
||||
lastSyncAt: timestamp("last_sync_at"),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.organizationId, table.type] }),
|
||||
],
|
||||
);
|
||||
@@ -16,6 +16,7 @@ import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { chatRouter } from "./routes/chat.js";
|
||||
import { conversationsRouter } from "./routes/conversations.js";
|
||||
import { dispensesRouter } from "./routes/dispenses.js";
|
||||
import { integrationsRouter } from "./routes/integrations.js";
|
||||
import { inventoryRouter } from "./routes/inventory.js";
|
||||
import { invoicesRouter } from "./routes/invoices.js";
|
||||
import { notesRouter } from "./routes/notes.js";
|
||||
@@ -80,6 +81,7 @@ app.use("/api/notifications", notificationsRouter);
|
||||
app.use("/api/settings", settingsRouter);
|
||||
app.use("/api/ai", aiRouter);
|
||||
app.use("/api/chat", chatRouter);
|
||||
app.use("/api/integrations", integrationsRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
@@ -107,4 +109,5 @@ server.listen(env.PORT, () => {
|
||||
console.log(` • settings: /api/settings`);
|
||||
console.log(` • ai: /api/ai (config + import)`);
|
||||
console.log(` • chat: /api/chat (LLM agent)`);
|
||||
console.log(` • integr.: /api/integrations (FHIR / e-Rx / claims)`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import {
|
||||
requireAnyPermission,
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as claims from "../services/integrations/claims.js";
|
||||
import {
|
||||
getConfig,
|
||||
getCredentials,
|
||||
type IntegrationType,
|
||||
INTEGRATION_TYPES,
|
||||
listConfigs,
|
||||
saveConfig,
|
||||
} from "../services/integrations/config.js";
|
||||
import * as eprescribe from "../services/integrations/eprescribe.js";
|
||||
import * as fhir from "../services/integrations/fhir.js";
|
||||
|
||||
export const integrationsRouter = Router();
|
||||
|
||||
function parseType(value: string): IntegrationType {
|
||||
if ((INTEGRATION_TYPES as readonly string[]).includes(value)) {
|
||||
return value as IntegrationType;
|
||||
}
|
||||
throw new HttpError(404, "Unknown integration.");
|
||||
}
|
||||
|
||||
function assertAdmin(role: string | undefined): void {
|
||||
const isAdmin = String(role ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.some((r) => r === "owner" || r === "admin");
|
||||
if (!isAdmin) {
|
||||
throw new HttpError(403, "Only owners and admins can change integrations.");
|
||||
}
|
||||
}
|
||||
|
||||
// Test a connection against the credentials/endpoint the type's service expects.
|
||||
function bearerFromCreds(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return (JSON.parse(raw) as { token?: string }).token ?? null;
|
||||
} catch {
|
||||
return raw.trim() || null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config (read for any member; write for owners/admins) ------------------
|
||||
|
||||
integrationsRouter.get(
|
||||
"/",
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listConfigs(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const configSchema = z.object({
|
||||
endpoint: z.string().trim().max(2048).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
// Empty string clears the stored secret; omitted leaves it unchanged.
|
||||
credentials: z.string().max(8192).optional(),
|
||||
});
|
||||
|
||||
integrationsRouter.put(
|
||||
"/:type",
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
assertAdmin(req.memberRole);
|
||||
const type = parseType(String(req.params.type));
|
||||
const input = configSchema.parse(req.body);
|
||||
const saved = await saveConfig(req.organizationId!, type, input);
|
||||
void recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Updated the ${type} integration`,
|
||||
entityType: "patient",
|
||||
});
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
integrationsRouter.post(
|
||||
"/:type/test",
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
assertAdmin(req.memberRole);
|
||||
const type = parseType(String(req.params.type));
|
||||
const orgId = req.organizationId!;
|
||||
const config = await getConfig(orgId, type);
|
||||
const token = bearerFromCreds(await getCredentials(orgId, type));
|
||||
const tester =
|
||||
type === "fhir"
|
||||
? fhir.testConnection
|
||||
: type === "eprescribe"
|
||||
? eprescribe.testConnection
|
||||
: claims.testConnection;
|
||||
res.json(await tester(config.endpoint, token));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// --- Actions ----------------------------------------------------------------
|
||||
|
||||
const syncSchema = z.object({ fileNumber: z.string().trim().min(1) });
|
||||
|
||||
// Pull lab results for a patient from the FHIR server.
|
||||
integrationsRouter.post(
|
||||
"/fhir/sync",
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { fileNumber } = syncSchema.parse(req.body);
|
||||
res.json(await fhir.syncLabs(req.organizationId!, fileNumber));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const ingestSchema = z.object({
|
||||
fileNumber: z.string().trim().min(1),
|
||||
message: z.string().min(1),
|
||||
});
|
||||
|
||||
// Ingest a raw HL7 v2 ORU result message for a patient.
|
||||
integrationsRouter.post(
|
||||
"/fhir/ingest",
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { fileNumber, message } = ingestSchema.parse(req.body);
|
||||
res.json(await fhir.ingestHl7(req.organizationId!, fileNumber, message));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const sendRxSchema = z.object({ rxId: z.string().trim().min(1) });
|
||||
|
||||
// Transmit a prescription to a pharmacy (NCPDP SCRIPT NewRx).
|
||||
integrationsRouter.post(
|
||||
"/eprescribe/send",
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission({ prescription: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { rxId } = sendRxSchema.parse(req.body);
|
||||
res.json(await eprescribe.sendRx(req.organizationId!, rxId));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const submitClaimSchema = z.object({ invoiceId: z.string().trim().min(1) });
|
||||
|
||||
// Submit an insurance claim for an invoice (X12 837P) + read the remittance.
|
||||
integrationsRouter.post(
|
||||
"/claims/submit",
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission({ invoice: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { invoiceId } = submitClaimSchema.parse(req.body);
|
||||
res.json(await claims.submitClaim(req.organizationId!, invoiceId));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user