mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +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:
@@ -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
@@ -155,6 +155,13 @@
|
||||
"when": 1781801972208,
|
||||
"tag": "0021_milky_blur",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1781802557475,
|
||||
"tag": "0022_damp_synch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user