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
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "integrations" (
"organization_id" text NOT NULL,
"type" text NOT NULL,
"endpoint" text DEFAULT '' NOT NULL,
"credentials" text,
"enabled" boolean DEFAULT false NOT NULL,
"status" text DEFAULT 'unconfigured' NOT NULL,
"last_sync_at" timestamp,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "integrations_organization_id_type_pk" PRIMARY KEY("organization_id","type")
);
--> statement-breakpoint
ALTER TABLE "integrations" ADD CONSTRAINT "integrations_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -155,6 +155,13 @@
"when": 1781801972208,
"tag": "0021_milky_blur",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1781802557475,
"tag": "0022_damp_synch",
"breakpoints": true
}
]
}
+1
View File
@@ -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";
+40
View File
@@ -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] }),
],
);
+3
View File
@@ -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)`);
});
+197
View File
@@ -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);
}
},
);
+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 };
@@ -0,0 +1,115 @@
"use client";
import { FileCheck, Send } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ApiError } from "@/lib/api-client";
import {
getIntegration,
sendEprescription,
submitInsuranceClaim,
} from "@/lib/integrations";
import { notify } from "@/lib/toast";
// Renders only when the e-prescribing integration is enabled. Transmits the
// prescription as an NCPDP SCRIPT NewRx to the configured pharmacy gateway.
export function SendToPharmacyButton({ rxId }: { rxId: string }) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
const [sending, setSending] = useState(false);
useEffect(() => {
let active = true;
getIntegration("eprescribe").then(
(c) => active && setEnabled(Boolean(c?.enabled)),
);
return () => {
active = false;
};
}, []);
if (!enabled) return null;
const send = async () => {
setSending(true);
try {
await sendEprescription(rxId);
notify.success(
t("integrations.eRx.sentTitle"),
t("integrations.eRx.sentBody"),
);
} catch (err) {
notify.error(
t("integrations.eRx.failedTitle"),
err instanceof ApiError ? err.message : t("integrations.eRx.failedBody"),
);
} finally {
setSending(false);
}
};
return (
<Button disabled={sending} onClick={send} type="button">
<Send className="size-4" />
{sending ? t("integrations.eRx.sending") : t("integrations.eRx.send")}
</Button>
);
}
// Renders only when the claims integration is enabled. Submits an X12 837P claim
// for the invoice to the configured clearinghouse and reports the remittance.
export function SubmitClaimButton({ invoiceId }: { invoiceId: string }) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
getIntegration("claims").then(
(c) => active && setEnabled(Boolean(c?.enabled)),
);
return () => {
active = false;
};
}, []);
if (!enabled) return null;
const submit = async () => {
setSubmitting(true);
try {
const result = await submitInsuranceClaim(invoiceId);
notify.success(
t("integrations.claims.submittedTitle"),
t("integrations.claims.submittedBody", {
status: result.claimStatus,
}),
);
} catch (err) {
notify.error(
t("integrations.claims.failedTitle"),
err instanceof ApiError
? err.message
: t("integrations.claims.failedBody"),
);
} finally {
setSubmitting(false);
}
};
return (
<Button
disabled={submitting}
onClick={submit}
type="button"
variant="outline"
>
<FileCheck className="size-4" />
{submitting
? t("integrations.claims.submitting")
: t("integrations.claims.submit")}
</Button>
);
}
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
import { SubmitClaimButton } from "@/components/integrations/integration-actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -357,6 +358,7 @@ export function InvoiceDetailSheet({
<Download className="size-4" />
{t("invoices.sheet.download")}
</Button>
<SubmitClaimButton invoiceId={invoice.id} />
{isSettled ? null : (
<Button disabled={busy} onClick={payAll} type="button">
<CircleCheck className="size-4" />
@@ -0,0 +1,176 @@
"use client";
import { Cable, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ApiError } from "@/lib/api-client";
import {
getIntegration,
type IntegrationConfig,
syncFhirLabs,
} from "@/lib/integrations";
import type { Patient } from "@/lib/patients";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
// The Lab page's HL7/FHIR connection panel: shows the connection status and, when
// enabled, lets staff pull a patient's results from the configured lab system.
export function LabIntegrationCard({
patients,
onSynced,
}: {
patients: Patient[];
onSynced: () => void;
}) {
const { t } = useTranslation();
const [config, setConfig] = useState<IntegrationConfig | null>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Patient | null>(null);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
let active = true;
getIntegration("fhir").then((c) => active && setConfig(c));
return () => {
active = false;
};
}, []);
const search = query.trim().toLowerCase();
const matches = useMemo(() => {
if (!search) return [];
return patients
.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.fileNumber.includes(search),
)
.slice(0, 5);
}, [patients, search]);
// Hide entirely until we know the config (avoids a flash); render a muted
// "configure me" card when present but disabled.
if (!config) return null;
const sync = async () => {
if (!selected) return;
setSyncing(true);
try {
const { imported } = await syncFhirLabs(selected.fileNumber);
notify.success(
t("integrations.fhir.syncedTitle"),
t("integrations.fhir.syncedBody", {
count: imported,
name: selected.name,
}),
);
setSelected(null);
setQuery("");
onSynced();
} catch (err) {
notify.error(
t("integrations.fhir.failedTitle"),
err instanceof ApiError
? err.message
: t("integrations.fhir.failedBody"),
);
} finally {
setSyncing(false);
}
};
return (
<section className="flex flex-col gap-3 rounded-2xl border bg-card/30 p-4">
<div className="flex items-center gap-2">
<Cable className="size-4 text-muted-foreground" />
<h2 className="font-medium text-foreground text-sm">
{t("integrations.fhir.cardTitle")}
</h2>
<Badge
className="ml-auto"
variant={
config.status === "connected"
? "secondary"
: config.status === "error"
? "destructive"
: "outline"
}
>
{t(`settings.integrations.status.${config.status}`)}
</Badge>
</div>
{!config.enabled ? (
<p className="text-muted-foreground text-sm">
{t("integrations.fhir.disabledHint")}
</p>
) : selected ? (
<div className="flex items-center gap-3 rounded-xl border bg-background/40 px-3 py-2">
<Avatar className="size-8">
<AvatarFallback>{selected.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
#{selected.fileNumber}
</span>
</div>
<Button
disabled={syncing}
onClick={() => setSelected(null)}
size="sm"
type="button"
variant="ghost"
>
{t("integrations.fhir.change")}
</Button>
<Button disabled={syncing} onClick={sync} size="sm" type="button">
<RefreshCw className={cn("size-4", syncing && "animate-spin")} />
{syncing
? t("integrations.fhir.syncing")
: t("integrations.fhir.sync")}
</Button>
</div>
) : (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="pl-9"
onChange={(e) => setQuery(e.target.value)}
placeholder={t("integrations.fhir.searchPlaceholder")}
value={query}
/>
</div>
{matches.length > 0 && (
<div className="flex flex-col gap-1">
{matches.map((p) => (
<button
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
key={p.fileNumber}
onClick={() => setSelected(p)}
type="button"
>
<Avatar className="size-7">
<AvatarFallback>{p.initials}</AvatarFallback>
</Avatar>
<span className="min-w-0 truncate text-sm">{p.name}</span>
<span className="ms-auto text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</button>
))}
</div>
)}
</div>
)}
</section>
);
}
+13
View File
@@ -39,6 +39,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { LabIntegrationCard } from "@/components/lab/lab-integration-card";
import { StagedFilesField } from "@/components/patients/patient-files";
import { uploadAttachment } from "@/lib/attachments";
import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses";
@@ -609,6 +610,18 @@ export function LabView() {
</Button>
</div>
<LabIntegrationCard
onSynced={() =>
listPatients()
.then((data) => {
setPatients(data);
setRecent(buildRecent(data));
})
.catch(() => {})
}
patients={patients}
/>
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">
@@ -3,6 +3,7 @@
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendToPharmacyButton } from "@/components/integrations/integration-actions";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -142,12 +143,20 @@ export function PrescriptionDetailSheet({
</div>
)}
</SheetPanel>
{rx && onDelete && (
{rx && (
<SheetFooter>
<Button onClick={onDelete} type="button" variant="destructive">
<Trash2 className="size-4" />
{t("prescriptions.detail.delete")}
</Button>
{onDelete && (
<Button
className="sm:mr-auto"
onClick={onDelete}
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
{t("prescriptions.detail.delete")}
</Button>
)}
<SendToPharmacyButton rxId={rx.id} />
</SheetFooter>
)}
</SheetPopup>
@@ -0,0 +1,236 @@
"use client";
import { CheckCircle2, CircleDashed, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FieldLabel,
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
type IntegrationConfig,
type IntegrationType,
listIntegrations,
saveIntegration,
testIntegration,
} from "@/lib/integrations";
import { notify } from "@/lib/toast";
const TYPES: IntegrationType[] = ["fhir", "eprescribe", "claims"];
function StatusBadge({ config }: { config: IntegrationConfig }) {
const { t } = useTranslation();
if (config.status === "connected") {
return (
<Badge className="gap-1" variant="secondary">
<CheckCircle2 className="size-3" />
{t("settings.integrations.status.connected")}
</Badge>
);
}
if (config.status === "error") {
return (
<Badge className="gap-1" variant="destructive">
<XCircle className="size-3" />
{t("settings.integrations.status.error")}
</Badge>
);
}
return (
<Badge className="gap-1" variant="outline">
<CircleDashed className="size-3" />
{t("settings.integrations.status.unconfigured")}
</Badge>
);
}
function IntegrationCard({
type,
initial,
}: {
type: IntegrationType;
initial: IntegrationConfig;
}) {
const { t } = useTranslation();
const [endpoint, setEndpoint] = useState(initial.endpoint);
const [enabled, setEnabled] = useState(initial.enabled);
// Empty = leave the stored secret untouched; typing replaces it.
const [credentials, setCredentials] = useState("");
const [config, setConfig] = useState(initial);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const dirty =
endpoint !== config.endpoint ||
enabled !== config.enabled ||
credentials.length > 0;
const save = async () => {
setSaving(true);
try {
const saved = await saveIntegration(type, {
endpoint,
enabled,
...(credentials ? { credentials } : {}),
});
setConfig(saved);
setEndpoint(saved.endpoint);
setEnabled(saved.enabled);
setCredentials("");
notify.success(
t("settings.integrations.savedTitle"),
t(`settings.integrations.${type}.title`),
);
} catch {
notify.error(
t("settings.integrations.saveFailedTitle"),
t("settings.integrations.saveFailedBody"),
);
} finally {
setSaving(false);
}
};
const test = async () => {
setTesting(true);
try {
const result = await testIntegration(type);
if (result.ok) {
notify.success(t("settings.integrations.testOk"), result.message);
} else {
notify.error(t("settings.integrations.testFailed"), result.message);
}
} catch {
notify.error(
t("settings.integrations.testFailed"),
t("settings.integrations.testError"),
);
} finally {
setTesting(false);
}
};
return (
<SettingsSection
action={<StatusBadge config={config} />}
description={t(`settings.integrations.${type}.description`)}
title={t(`settings.integrations.${type}.title`)}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.endpoint")}</FieldLabel>
<Input
onChange={(e) => setEndpoint(e.target.value)}
placeholder={t(`settings.integrations.${type}.endpointPlaceholder`)}
value={endpoint}
/>
</div>
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.credentials")}</FieldLabel>
<Input
autoComplete="off"
onChange={(e) => setCredentials(e.target.value)}
placeholder={
config.hasCredentials
? t("settings.integrations.credentialsSet")
: t(`settings.integrations.${type}.credentialsPlaceholder`)
}
type="password"
value={credentials}
/>
<p className="text-xs text-muted-foreground">
{t(`settings.integrations.${type}.credentialsHint`)}
</p>
</div>
<label className="flex items-center justify-between gap-4 rounded-2xl border bg-card/30 px-4 py-3">
<span className="space-y-0.5">
<span className="block text-sm font-medium">
{t("settings.integrations.enable")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.integrations.enableHint")}
</span>
</span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</label>
<div className="flex items-center gap-2">
<Button disabled={saving || !dirty} onClick={save} size="sm">
{saving
? t("settings.integrations.saving")
: t("settings.integrations.save")}
</Button>
<Button
disabled={testing}
onClick={test}
size="sm"
variant="outline"
>
{testing
? t("settings.integrations.testing")
: t("settings.integrations.test")}
</Button>
{config.lastSyncAt ? (
<span className="ml-auto text-xs text-muted-foreground">
{t("settings.integrations.lastSync", {
when: new Date(config.lastSyncAt).toLocaleString(),
})}
</span>
) : null}
</div>
</SettingsCard>
</SettingsSection>
);
}
export function IntegrationsPanel() {
const { t } = useTranslation();
const [configs, setConfigs] = useState<IntegrationConfig[] | null>(null);
useEffect(() => {
let active = true;
listIntegrations()
.then((rows) => active && setConfigs(rows))
.catch(() => active && setConfigs([]));
return () => {
active = false;
};
}, []);
if (configs === null) {
return (
<p className="text-sm text-muted-foreground">
{t("settings.integrations.loading")}
</p>
);
}
return (
<div className="space-y-8">
<p className="text-sm text-muted-foreground">
{t("settings.integrations.intro")}
</p>
{TYPES.map((type) => {
const initial =
configs.find((c) => c.type === type) ??
({
type,
endpoint: "",
enabled: false,
status: "unconfigured",
hasCredentials: false,
lastSyncAt: null,
} satisfies IntegrationConfig);
return <IntegrationCard initial={initial} key={type} type={type} />;
})}
</div>
);
}
@@ -8,6 +8,7 @@ import { AIPanel } from "@/components/settings/settings-ai";
import { SigningPanel } from "@/components/settings/settings-billing";
import { CareTeamPanel } from "@/components/settings/settings-care-team";
import { DevelopersPanel } from "@/components/settings/settings-developers";
import { IntegrationsPanel } from "@/components/settings/settings-integrations";
import { ProfilePanel } from "@/components/settings/settings-preferences";
import { RecordsPanel } from "@/components/settings/settings-records";
import { useActiveRole } from "@/lib/roles";
@@ -18,6 +19,7 @@ const TABS = [
{ id: "records", labelKey: "settings.tabs.records" },
{ id: "signing", labelKey: "settings.tabs.signing" },
{ id: "careTeam", labelKey: "settings.tabs.careTeam" },
{ id: "integrations", labelKey: "settings.tabs.integrations" },
{ id: "developers", labelKey: "settings.tabs.developers" },
] as const;
@@ -70,6 +72,7 @@ export function SettingsView() {
{activeTab === "records" && <RecordsPanel />}
{activeTab === "signing" && <SigningPanel />}
{activeTab === "careTeam" && <CareTeamPanel />}
{activeTab === "integrations" && <IntegrationsPanel />}
{activeTab === "developers" && <DevelopersPanel />}
</div>
</div>
@@ -1103,6 +1103,36 @@
"uploadFailedTitle": "Some files didn't upload",
"uploadFailedBody": "The record was saved, but one or more files failed to upload. Try adding them again from the record."
},
"integrations": {
"fhir": {
"cardTitle": "Lab system (HL7/FHIR)",
"disabledHint": "Not enabled. An owner or admin can connect a lab system in Settings → Integrations.",
"searchPlaceholder": "Search a patient to pull results…",
"change": "Change",
"sync": "Sync results",
"syncing": "Syncing…",
"syncedTitle": "Results synced",
"syncedBody": "Imported {{count}} result(s) for {{name}}.",
"failedTitle": "Sync failed",
"failedBody": "Couldn't reach the lab system. Please try again."
},
"eRx": {
"send": "Send to pharmacy",
"sending": "Sending…",
"sentTitle": "Prescription sent",
"sentBody": "Transmitted to the pharmacy as an NCPDP SCRIPT NewRx.",
"failedTitle": "Couldn't send",
"failedBody": "The pharmacy gateway rejected the message or is unreachable."
},
"claims": {
"submit": "Submit claim",
"submitting": "Submitting…",
"submittedTitle": "Claim submitted",
"submittedBody": "Clearinghouse status: {{status}}.",
"failedTitle": "Couldn't submit claim",
"failedBody": "The clearinghouse rejected the claim or is unreachable."
}
},
"patientCard": {
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
@@ -1277,8 +1307,55 @@
"records": "Records",
"signing": "Signing",
"careTeam": "Care team",
"integrations": "Integrations",
"developers": "Developers"
},
"integrations": {
"loading": "Loading integrations…",
"intro": "Connect temetro to external healthcare systems. Point each integration at your vendor's sandbox or production endpoint and supply its credentials.",
"endpoint": "Endpoint URL",
"credentials": "Credentials",
"credentialsSet": "•••••••• (stored — type to replace)",
"enable": "Enable integration",
"enableHint": "When on, its actions appear on the relevant pages.",
"save": "Save",
"saving": "Saving…",
"test": "Test connection",
"testing": "Testing…",
"savedTitle": "Integration saved",
"saveFailedTitle": "Couldn't save",
"saveFailedBody": "Something went wrong, or you don't have permission. Please try again.",
"testOk": "Connection succeeded",
"testFailed": "Connection failed",
"testError": "Couldn't reach the endpoint.",
"lastSync": "Last activity {{when}}",
"status": {
"connected": "Connected",
"error": "Error",
"unconfigured": "Not configured"
},
"fhir": {
"title": "Lab system (HL7/FHIR)",
"description": "Read lab results from a FHIR R4 server or HL7 v2 feed (e.g. a HAPI FHIR / SMART Health IT sandbox, or your lab's gateway).",
"endpointPlaceholder": "https://hapi.fhir.org/baseR4",
"credentialsPlaceholder": "Bearer token (optional for open sandboxes)",
"credentialsHint": "Sent as a Bearer token, or JSON {\"token\":\"…\"}. Leave blank for public sandboxes."
},
"eprescribe": {
"title": "e-Prescribing (NCPDP SCRIPT)",
"description": "Transmit prescriptions to pharmacies as NCPDP SCRIPT NewRx messages. Production routing requires your Surescripts (or sandbox) account.",
"endpointPlaceholder": "https://your-pharmacy-gateway.example/script",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"senderId\":\"…\"}",
"credentialsHint": "JSON with your gateway token and sender id."
},
"claims": {
"title": "Insurance claims (X12 837/835)",
"description": "Submit professional claims (837P) to a clearinghouse and read remittances (835). Production requires your clearinghouse account.",
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids."
}
},
"empty": "Nothing here yet.",
"copy": "Copy",
"copied": "Copied",
+78
View File
@@ -0,0 +1,78 @@
// Client for the backend integrations API (HL7/FHIR labs, e-prescribing,
// insurance claims). Config is owner/admin-only to write; status is readable by
// any member so pages can gate their on-page actions.
import { apiFetch } from "@/lib/api-client";
export type IntegrationType = "fhir" | "eprescribe" | "claims";
export type IntegrationStatus = "unconfigured" | "connected" | "error";
export type IntegrationConfig = {
type: IntegrationType;
endpoint: string;
enabled: boolean;
status: IntegrationStatus;
hasCredentials: boolean;
lastSyncAt: string | null;
};
export function listIntegrations(): Promise<IntegrationConfig[]> {
return apiFetch<IntegrationConfig[]>("/api/integrations");
}
export function saveIntegration(
type: IntegrationType,
input: { endpoint?: string; enabled?: boolean; credentials?: string },
): Promise<IntegrationConfig> {
return apiFetch<IntegrationConfig>(`/api/integrations/${type}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function testIntegration(
type: IntegrationType,
): Promise<{ ok: boolean; message: string }> {
return apiFetch(`/api/integrations/${type}/test`, { method: "POST" });
}
// Pull a patient's lab results from the FHIR server.
export function syncFhirLabs(fileNumber: string): Promise<{ imported: number }> {
return apiFetch("/api/integrations/fhir/sync", {
method: "POST",
body: JSON.stringify({ fileNumber }),
});
}
// Transmit a prescription to a pharmacy (NCPDP SCRIPT NewRx).
export function sendEprescription(
rxId: string,
): Promise<{ messageId: string; status: string }> {
return apiFetch("/api/integrations/eprescribe/send", {
method: "POST",
body: JSON.stringify({ rxId }),
});
}
// Submit an insurance claim for an invoice (X12 837P) and read the remittance.
export function submitInsuranceClaim(
invoiceId: string,
): Promise<{ claimStatus: string; paidAmount: number; submitted: boolean }> {
return apiFetch("/api/integrations/claims/submit", {
method: "POST",
body: JSON.stringify({ invoiceId }),
});
}
// Convenience hook-style fetch reused by the on-page sections: returns the
// config for one type (or null while loading/absent).
export async function getIntegration(
type: IntegrationType,
): Promise<IntegrationConfig | null> {
try {
const all = await listIntegrations();
return all.find((c) => c.type === type) ?? null;
} catch {
return null;
}
}