mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 03:26:53 +00:00
43eaccb97e
Real, standards-compliant integration clients that the clinic points at
its own (sandbox or production) endpoints — no mock data.
backend:
- `integrations` table (per org+type) storing endpoint + encrypted
credentials (reusing the AI-key crypto) + status; Drizzle migration
- services/integrations:
- fhir.ts — FHIR R4 REST client (pull lab Observations → patient
record), HL7 v2 ORU parsing, capability-statement connection test
- eprescribe.ts — NCPDP SCRIPT NewRx message build + transmit
- claims.ts — X12 837P claim generation + 835 remittance parsing
- `/api/integrations` route: config GET/PUT (owner/admin), connection
test, and the FHIR sync / HL7 ingest / e-Rx send / claim submit actions,
RBAC-gated (lab/patient, prescription, invoice)
frontend:
- lib/integrations.ts client
- Settings → Integrations tab to configure endpoints/credentials/enable
+ test each integration
- on-page actions, shown only when the integration is enabled:
Lab page → FHIR "Sync results" card; prescription sheet → "Send to
pharmacy"; invoice sheet → "Submit claim"
Production e-Rx/claims routing requires the clinic's own Surescripts /
clearinghouse credentials; the code transmits real messages once supplied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
5.2 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
);
|