mirror of
https://github.com/temetro/temetro.git
synced 2026-08-10 10:37:43 +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>
147 lines
3.9 KiB
TypeScript
147 lines
3.9 KiB
TypeScript
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),
|
|
),
|
|
);
|
|
}
|