Files
temetro/backend/src/routes/integrations.ts
T
Khalid Abdi 43eaccb97e 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>
2026-06-18 20:15:00 +03:00

198 lines
5.2 KiB
TypeScript

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);
}
},
);