mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 22:56:17 +00:00
feat: read-only FHIR R4 server (share records over /fhir)
Expose temetro's own records as a read-only FHIR R4 server at /fhir, authenticated with per-clinic API keys (tmf_… bearer tokens, SHA-256 hashed, shown once). Serves Patient, Observation (labs + vitals), AllergyIntolerance, Condition, MedicationRequest, Encounter and Appointment as text-only CodeableConcepts (temetro stores free-text clinical values); CapabilityStatement at /fhir/metadata (unauth). Searchset Bundles with _count/_offset pagination and self/next/prev links; every request is org-scoped and written to the activity log. Keys are created/revoked under Settings → Integrations (owner/admin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import type { FhirResource } from "./resources.js";
|
||||
|
||||
// searchset Bundle assembly + offset/limit pagination for the FHIR server.
|
||||
|
||||
export const DEFAULT_COUNT = 50;
|
||||
export const MAX_COUNT = 200;
|
||||
|
||||
// Clamp a client-supplied `_count` into [1, MAX_COUNT], defaulting when absent.
|
||||
export function parseCount(raw: string | undefined): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return DEFAULT_COUNT;
|
||||
return Math.min(Math.floor(n), MAX_COUNT);
|
||||
}
|
||||
|
||||
export function parseOffset(raw: string | undefined): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
// Slice an already-materialized resource array to the requested page.
|
||||
export function paginate<T>(
|
||||
all: T[],
|
||||
count: number,
|
||||
offset: number,
|
||||
): { page: T[]; total: number } {
|
||||
return { page: all.slice(offset, offset + count), total: all.length };
|
||||
}
|
||||
|
||||
export type SearchsetBundle = {
|
||||
resourceType: "Bundle";
|
||||
type: "searchset";
|
||||
total: number;
|
||||
link: { relation: string; url: string }[];
|
||||
entry: { fullUrl: string; resource: FhirResource; search: { mode: "match" } }[];
|
||||
};
|
||||
|
||||
// Build a FHIR searchset Bundle. `page` is the current slice; `total` the full
|
||||
// match count; `params` the effective query (already carrying `_count`/`_offset`)
|
||||
// used to derive self/next/prev links.
|
||||
export function searchsetBundle(opts: {
|
||||
baseUrl: string; // e.g. https://host/fhir
|
||||
resourceType: string;
|
||||
page: FhirResource[];
|
||||
total: number;
|
||||
count: number;
|
||||
offset: number;
|
||||
params: URLSearchParams;
|
||||
}): SearchsetBundle {
|
||||
const { baseUrl, resourceType, page, total, count, offset, params } = opts;
|
||||
|
||||
const linkFor = (nextOffset: number): string => {
|
||||
const q = new URLSearchParams(params);
|
||||
q.set("_count", String(count));
|
||||
q.set("_offset", String(nextOffset));
|
||||
return `${baseUrl}/${resourceType}?${q.toString()}`;
|
||||
};
|
||||
|
||||
const link: { relation: string; url: string }[] = [
|
||||
{ relation: "self", url: linkFor(offset) },
|
||||
];
|
||||
if (offset + count < total) link.push({ relation: "next", url: linkFor(offset + count) });
|
||||
if (offset > 0)
|
||||
link.push({ relation: "previous", url: linkFor(Math.max(0, offset - count)) });
|
||||
|
||||
return {
|
||||
resourceType: "Bundle",
|
||||
type: "searchset",
|
||||
total,
|
||||
link,
|
||||
entry: page.map((resource) => ({
|
||||
fullUrl: `${baseUrl}/${resource.resourceType}/${resource.id}`,
|
||||
resource,
|
||||
search: { mode: "match" },
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// A static CapabilityStatement describing exactly what this read-only FHIR R4
|
||||
// server supports. It is intentionally honest: only the resources and search
|
||||
// params implemented below are listed, everything is `read`/`search-type` only,
|
||||
// and clinical concepts are text-only (no SNOMED/LOINC coding).
|
||||
|
||||
type ResourceCapability = {
|
||||
type: string;
|
||||
interaction: { code: "read" | "search-type" }[];
|
||||
searchParam?: { name: string; type: "token" | "string" | "reference" }[];
|
||||
};
|
||||
|
||||
const RESOURCES: ResourceCapability[] = [
|
||||
{
|
||||
type: "Patient",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "identifier", type: "token" },
|
||||
{ name: "name", type: "string" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Observation",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
{ name: "category", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "AllergyIntolerance",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Condition",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "MedicationRequest",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Encounter",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Appointment",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function capabilityStatement(
|
||||
baseUrl: string,
|
||||
version: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
resourceType: "CapabilityStatement",
|
||||
status: "active",
|
||||
date: new Date().toISOString(),
|
||||
publisher: "temetro",
|
||||
kind: "instance",
|
||||
implementation: { description: "temetro FHIR server", url: baseUrl },
|
||||
software: { name: "temetro", version },
|
||||
fhirVersion: "4.0.1",
|
||||
format: ["application/fhir+json", "json"],
|
||||
rest: [
|
||||
{
|
||||
mode: "server",
|
||||
documentation:
|
||||
"Read-only FHIR R4 server. Authenticate with a per-clinic API key: " +
|
||||
"Authorization: Bearer tmf_…. Clinical values are text-only " +
|
||||
"CodeableConcepts (no SNOMED/LOINC). Patients expose age (extension), " +
|
||||
"not birthDate. Pagination via _count (default 50, max 200) and _offset.",
|
||||
security: {
|
||||
description: "Bearer token (per-organization API key, tmf_ prefix).",
|
||||
service: [
|
||||
{
|
||||
coding: [
|
||||
{
|
||||
system:
|
||||
"http://terminology.hl7.org/CodeSystem/restful-security-service",
|
||||
code: "OAuth",
|
||||
display: "OAuth",
|
||||
},
|
||||
],
|
||||
text: "API key bearer token",
|
||||
},
|
||||
],
|
||||
},
|
||||
resource: RESOURCES,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import { db } from "../../db/index.js";
|
||||
import { fhirApiKeys } from "../../db/schema/fhir-keys.js";
|
||||
|
||||
// FHIR-server API keys. The secret is `tmf_` + 32 random bytes (base64url); we
|
||||
// persist only its SHA-256 hash, so a leaked database never yields usable keys
|
||||
// and the plaintext is returned exactly once (at creation).
|
||||
|
||||
const PREFIX = "tmf_";
|
||||
|
||||
export type FhirKeyView = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revoked: boolean;
|
||||
};
|
||||
|
||||
function hashKey(secret: string): string {
|
||||
return createHash("sha256").update(secret).digest("hex");
|
||||
}
|
||||
|
||||
function toView(row: typeof fhirApiKeys.$inferSelect): FhirKeyView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
lastUsedAt: row.lastUsedAt ? row.lastUsedAt.toISOString() : null,
|
||||
revoked: row.revokedAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// List a clinic's keys (active first, then revoked), newest first. Never
|
||||
// exposes the hash.
|
||||
export async function listKeys(orgId: string): Promise<FhirKeyView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fhirApiKeys)
|
||||
.where(eq(fhirApiKeys.organizationId, orgId))
|
||||
.orderBy(desc(fhirApiKeys.createdAt));
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
// Mint a new key. Returns the one-time plaintext secret alongside the stored
|
||||
// view — the caller must surface the secret to the user immediately; it is not
|
||||
// recoverable afterwards.
|
||||
export async function createKey(
|
||||
orgId: string,
|
||||
name: string,
|
||||
createdBy: string,
|
||||
): Promise<{ secret: string; key: FhirKeyView }> {
|
||||
const secret = PREFIX + randomBytes(32).toString("base64url");
|
||||
const [row] = await db
|
||||
.insert(fhirApiKeys)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
name: name.trim() || "FHIR key",
|
||||
keyHash: hashKey(secret),
|
||||
createdBy,
|
||||
})
|
||||
.returning();
|
||||
return { secret, key: toView(row!) };
|
||||
}
|
||||
|
||||
// Revoke a key (idempotent). Scoped to the org so one clinic can't revoke
|
||||
// another's. Returns false if no such active key exists.
|
||||
export async function revokeKey(orgId: string, id: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(fhirApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(fhirApiKeys.id, id),
|
||||
eq(fhirApiKeys.organizationId, orgId),
|
||||
isNull(fhirApiKeys.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: fhirApiKeys.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
export type ResolvedKey = { orgId: string; keyId: string; keyName: string };
|
||||
|
||||
// Resolve a presented bearer secret to its owning organization (plus the key's
|
||||
// identity, for the audit log), or null when it is unknown or revoked. Bumps
|
||||
// `lastUsedAt` (throttled to once a minute) so the key list can show recent
|
||||
// activity without a write on every request.
|
||||
export async function resolveKey(secret: string): Promise<ResolvedKey | null> {
|
||||
if (!secret.startsWith(PREFIX)) return null;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(fhirApiKeys)
|
||||
.where(eq(fhirApiKeys.keyHash, hashKey(secret)))
|
||||
.limit(1);
|
||||
if (!row || row.revokedAt) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const last = row.lastUsedAt?.getTime() ?? 0;
|
||||
if (now - last > 60_000) {
|
||||
void db
|
||||
.update(fhirApiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(fhirApiKeys.id, row.id))
|
||||
.catch(() => {});
|
||||
}
|
||||
return { orgId: row.organizationId, keyId: row.id, keyName: row.name };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// FHIR OperationOutcome helpers. Errors on a FHIR endpoint are returned as an
|
||||
// OperationOutcome resource (not our usual `{ error }` JSON), with the
|
||||
// `application/fhir+json` content type, so conformant clients can parse them.
|
||||
|
||||
export const FHIR_CONTENT_TYPE = "application/fhir+json";
|
||||
|
||||
export type IssueSeverity = "fatal" | "error" | "warning" | "information";
|
||||
export type IssueCode =
|
||||
| "not-found"
|
||||
| "not-supported"
|
||||
| "security"
|
||||
| "login"
|
||||
| "forbidden"
|
||||
| "invalid"
|
||||
| "processing"
|
||||
| "exception";
|
||||
|
||||
export type OperationOutcome = {
|
||||
resourceType: "OperationOutcome";
|
||||
issue: {
|
||||
severity: IssueSeverity;
|
||||
code: IssueCode;
|
||||
diagnostics?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function operationOutcome(
|
||||
severity: IssueSeverity,
|
||||
code: IssueCode,
|
||||
diagnostics: string,
|
||||
): OperationOutcome {
|
||||
return {
|
||||
resourceType: "OperationOutcome",
|
||||
issue: [{ severity, code, diagnostics }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { and, asc, count, eq, ilike, sql } from "drizzle-orm";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
|
||||
import { db } from "../../db/index.js";
|
||||
import { appointments } from "../../db/schema/appointments.js";
|
||||
import {
|
||||
allergies,
|
||||
encounters,
|
||||
labs,
|
||||
medications,
|
||||
patients,
|
||||
problems,
|
||||
} from "../../db/schema/patients.js";
|
||||
import { prescriptions } from "../../db/schema/prescriptions.js";
|
||||
|
||||
// Narrow, org-scoped reads for the FHIR server. Deliberately separate from the
|
||||
// app's `services/patients.ts` (which returns the reshaped canonical Patient and
|
||||
// applies role redaction): the FHIR layer needs raw rows *with their UUIDs* to
|
||||
// mint stable resource ids, and offset/limit pagination the app service doesn't
|
||||
// expose. Every function is scoped to a single organization.
|
||||
|
||||
export type PatientRow = typeof patients.$inferSelect;
|
||||
export type LabRow = typeof labs.$inferSelect;
|
||||
export type AllergyRow = typeof allergies.$inferSelect;
|
||||
export type ProblemRow = typeof problems.$inferSelect;
|
||||
export type EncounterRow = typeof encounters.$inferSelect;
|
||||
export type PrescriptionRow = typeof prescriptions.$inferSelect;
|
||||
export type AppointmentRow = typeof appointments.$inferSelect;
|
||||
|
||||
// --- Patient ----------------------------------------------------------------
|
||||
|
||||
// Paginated Patient search. `identifier` matches the MRN (file number) exactly;
|
||||
// `name` is a case-insensitive substring. Returns the page plus the full total
|
||||
// for the searchset Bundle.
|
||||
export async function searchPatients(
|
||||
orgId: string,
|
||||
opts: { identifier?: string; name?: string; limit: number; offset: number },
|
||||
): Promise<{ rows: PatientRow[]; total: number }> {
|
||||
const filters: SQL[] = [eq(patients.organizationId, orgId)];
|
||||
if (opts.identifier) filters.push(eq(patients.fileNumber, opts.identifier));
|
||||
if (opts.name) filters.push(ilike(patients.name, `%${opts.name}%`));
|
||||
const where = and(...filters);
|
||||
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(patients)
|
||||
.where(where)
|
||||
.orderBy(asc(patients.fileNumber))
|
||||
.limit(opts.limit)
|
||||
.offset(opts.offset),
|
||||
db.select({ value: count() }).from(patients).where(where),
|
||||
]);
|
||||
|
||||
return { rows, total: totalRow?.value ?? 0 };
|
||||
}
|
||||
|
||||
// A single patient by FHIR logical id (the row UUID), scoped to the org.
|
||||
export async function patientById(
|
||||
orgId: string,
|
||||
id: string,
|
||||
): Promise<PatientRow | undefined> {
|
||||
// Guard against a non-UUID id: Postgres would otherwise error on the cast.
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) return undefined;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(patients)
|
||||
.where(and(eq(patients.organizationId, orgId), eq(patients.id, id)))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
// Resolve a `patient` search parameter to a patient row. Accepts either the FHIR
|
||||
// logical id (`patient=<uuid>`) or the MRN (`patient.identifier=<file#>`).
|
||||
export async function resolvePatientRef(
|
||||
orgId: string,
|
||||
ref: { patientId?: string; identifier?: string },
|
||||
): Promise<PatientRow | undefined> {
|
||||
if (ref.patientId) {
|
||||
// A reference may arrive as "Patient/<id>" or a bare id.
|
||||
const id = ref.patientId.replace(/^Patient\//, "");
|
||||
return patientById(orgId, id);
|
||||
}
|
||||
if (ref.identifier) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
eq(patients.organizationId, orgId),
|
||||
eq(patients.fileNumber, ref.identifier),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- Clinical child rows (by patient UUID) ----------------------------------
|
||||
|
||||
export function labsForPatient(patientId: string): Promise<LabRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(labs)
|
||||
.where(eq(labs.patientId, patientId))
|
||||
.orderBy(asc(labs.position));
|
||||
}
|
||||
|
||||
export function allergiesForPatient(patientId: string): Promise<AllergyRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(allergies)
|
||||
.where(eq(allergies.patientId, patientId))
|
||||
.orderBy(asc(allergies.position));
|
||||
}
|
||||
|
||||
export function problemsForPatient(patientId: string): Promise<ProblemRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(problems)
|
||||
.where(eq(problems.patientId, patientId))
|
||||
.orderBy(asc(problems.position));
|
||||
}
|
||||
|
||||
export function encountersForPatient(
|
||||
patientId: string,
|
||||
): Promise<EncounterRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(encounters)
|
||||
.where(eq(encounters.patientId, patientId))
|
||||
.orderBy(asc(encounters.position));
|
||||
}
|
||||
|
||||
// --- Denormalized resources (linked to the patient by MRN / file number) ----
|
||||
|
||||
export function prescriptionsForFile(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
): Promise<PrescriptionRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(prescriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(prescriptions.organizationId, orgId),
|
||||
eq(prescriptions.patientFileNumber, fileNumber),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${prescriptions.prescribedAt} desc`);
|
||||
}
|
||||
|
||||
export function appointmentsForFile(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
): Promise<AppointmentRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.organizationId, orgId),
|
||||
eq(appointments.patientFileNumber, fileNumber),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${appointments.date} desc, ${appointments.time} desc`);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { LabFlag } from "../../types/patient.js";
|
||||
import type {
|
||||
AllergyRow,
|
||||
AppointmentRow,
|
||||
EncounterRow,
|
||||
LabRow,
|
||||
PatientRow,
|
||||
PrescriptionRow,
|
||||
ProblemRow,
|
||||
} from "./queries.js";
|
||||
|
||||
// Pure mappers from temetro rows to FHIR R4 JSON. temetro stores clinical values
|
||||
// as **free text** (no SNOMED/LOINC coding), so every CodeableConcept here is
|
||||
// `text`-only — valid FHIR, deliberately un-coded (documented in the
|
||||
// CapabilityStatement and API docs). Resource ids are the rows' own UUIDs so
|
||||
// they are stable; synthesized vital-sign Observations derive their id from the
|
||||
// patient UUID.
|
||||
|
||||
export type FhirResource = {
|
||||
resourceType: string;
|
||||
id?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// System URIs.
|
||||
const MRN_SYSTEM = "urn:temetro:mrn";
|
||||
const INTERPRETATION_SYSTEM =
|
||||
"http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation";
|
||||
const OBS_CATEGORY_SYSTEM =
|
||||
"http://terminology.hl7.org/CodeSystem/observation-category";
|
||||
const AGE_EXTENSION =
|
||||
"https://temetro.app/fhir/StructureDefinition/patient-age-years";
|
||||
|
||||
// A FHIR dateTime from our stored strings. Passes date-only values (`YYYY-MM-DD`)
|
||||
// through unchanged (valid FHIR dateTime), otherwise parses display strings like
|
||||
// "Jun 28, 2025" to a full instant. Returns undefined when unparseable.
|
||||
function fhirDateTime(value: string | null | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
|
||||
const parsed = new Date(trimmed);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||
}
|
||||
|
||||
function humanName(full: string): Record<string, unknown>[] {
|
||||
const parts = full.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) return [{ text: full }];
|
||||
return [{ text: full, family: parts.at(-1), given: parts.slice(0, -1) }];
|
||||
}
|
||||
|
||||
function subjectRef(patient: PatientRow) {
|
||||
return { reference: `Patient/${patient.id}`, display: patient.name };
|
||||
}
|
||||
|
||||
// --- Patient ----------------------------------------------------------------
|
||||
|
||||
export function patientResource(row: PatientRow): FhirResource {
|
||||
return {
|
||||
resourceType: "Patient",
|
||||
id: row.id,
|
||||
identifier: [{ system: MRN_SYSTEM, value: row.fileNumber }],
|
||||
active: row.status !== "discharged",
|
||||
name: humanName(row.name),
|
||||
gender: row.sex === "M" ? "male" : "female",
|
||||
// temetro records age, not date of birth; expose it as an extension rather
|
||||
// than fabricate a birthDate.
|
||||
extension: [{ url: AGE_EXTENSION, valueInteger: row.age }],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Observation ------------------------------------------------------------
|
||||
|
||||
function interpretation(flag: LabFlag) {
|
||||
const map: Record<LabFlag, { code: string; display: string }> = {
|
||||
normal: { code: "N", display: "Normal" },
|
||||
high: { code: "H", display: "High" },
|
||||
low: { code: "L", display: "Low" },
|
||||
critical: { code: "HH", display: "Critical high" },
|
||||
};
|
||||
const { code, display } = map[flag];
|
||||
return [{ coding: [{ system: INTERPRETATION_SYSTEM, code, display }] }];
|
||||
}
|
||||
|
||||
export function labObservation(row: LabRow, patient: PatientRow): FhirResource {
|
||||
const effective = fhirDateTime(row.takenAt);
|
||||
return {
|
||||
resourceType: "Observation",
|
||||
id: row.id,
|
||||
status: "final",
|
||||
category: [
|
||||
{
|
||||
coding: [
|
||||
{
|
||||
system: OBS_CATEGORY_SYSTEM,
|
||||
code: "laboratory",
|
||||
display: "Laboratory",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
code: { text: row.name },
|
||||
subject: subjectRef(patient),
|
||||
...(effective ? { effectiveDateTime: effective } : {}),
|
||||
valueString: row.value,
|
||||
interpretation: interpretation(row.flag),
|
||||
};
|
||||
}
|
||||
|
||||
// Synthesize vital-sign Observations from the denormalized columns on the
|
||||
// patient row. Returns an empty array when vitals are blank (e.g. a
|
||||
// reception-registered patient with clinical fields stripped).
|
||||
export function vitalObservations(patient: PatientRow): FhirResource[] {
|
||||
const effective = fhirDateTime(patient.vitalsTakenAt);
|
||||
const base = (idSuffix: string, text: string) => ({
|
||||
resourceType: "Observation" as const,
|
||||
id: `${patient.id}-vital-${idSuffix}`,
|
||||
status: "final",
|
||||
category: [
|
||||
{
|
||||
coding: [
|
||||
{
|
||||
system: OBS_CATEGORY_SYSTEM,
|
||||
code: "vital-signs",
|
||||
display: "Vital Signs",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
code: { text },
|
||||
subject: subjectRef(patient),
|
||||
...(effective ? { effectiveDateTime: effective } : {}),
|
||||
});
|
||||
|
||||
const out: FhirResource[] = [];
|
||||
|
||||
if (patient.vitalsBp) {
|
||||
const bp = base("bp", "Blood pressure");
|
||||
const m = /^(\d+)\s*\/\s*(\d+)/.exec(patient.vitalsBp.trim());
|
||||
if (m) {
|
||||
out.push({
|
||||
...bp,
|
||||
component: [
|
||||
{
|
||||
code: { text: "Systolic blood pressure" },
|
||||
valueQuantity: { value: Number(m[1]), unit: "mmHg" },
|
||||
},
|
||||
{
|
||||
code: { text: "Diastolic blood pressure" },
|
||||
valueQuantity: { value: Number(m[2]), unit: "mmHg" },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
out.push({ ...bp, valueString: patient.vitalsBp });
|
||||
}
|
||||
}
|
||||
if (patient.vitalsHr)
|
||||
out.push({ ...base("hr", "Heart rate"), valueString: patient.vitalsHr });
|
||||
if (patient.vitalsTemp)
|
||||
out.push({
|
||||
...base("temp", "Body temperature"),
|
||||
valueString: patient.vitalsTemp,
|
||||
});
|
||||
if (patient.vitalsSpo2)
|
||||
out.push({
|
||||
...base("spo2", "Oxygen saturation"),
|
||||
valueString: patient.vitalsSpo2,
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- AllergyIntolerance -----------------------------------------------------
|
||||
|
||||
export function allergyResource(
|
||||
row: AllergyRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
return {
|
||||
resourceType: "AllergyIntolerance",
|
||||
id: row.id,
|
||||
clinicalStatus: {
|
||||
coding: [
|
||||
{
|
||||
system:
|
||||
"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
|
||||
code: "active",
|
||||
},
|
||||
],
|
||||
},
|
||||
code: { text: row.substance },
|
||||
patient: subjectRef(patient),
|
||||
criticality: row.severity === "severe" ? "high" : "low",
|
||||
reaction: [
|
||||
{ manifestation: [{ text: row.reaction }], severity: row.severity },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Condition --------------------------------------------------------------
|
||||
|
||||
export function conditionResource(
|
||||
row: ProblemRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
return {
|
||||
resourceType: "Condition",
|
||||
id: row.id,
|
||||
clinicalStatus: {
|
||||
coding: [
|
||||
{
|
||||
system: "http://terminology.hl7.org/CodeSystem/condition-clinical",
|
||||
code: "active",
|
||||
},
|
||||
],
|
||||
},
|
||||
code: { text: row.label },
|
||||
subject: subjectRef(patient),
|
||||
...(row.since ? { onsetString: row.since } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// --- MedicationRequest ------------------------------------------------------
|
||||
|
||||
export function medicationRequestResource(
|
||||
row: PrescriptionRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
const status =
|
||||
row.status === "completed"
|
||||
? "completed"
|
||||
: row.status === "expired"
|
||||
? "stopped"
|
||||
: "active";
|
||||
const dosageText = [row.dose, row.frequency].filter(Boolean).join(" ").trim();
|
||||
return {
|
||||
resourceType: "MedicationRequest",
|
||||
id: row.id,
|
||||
status,
|
||||
intent: "order",
|
||||
medicationCodeableConcept: { text: row.medication },
|
||||
subject: subjectRef(patient),
|
||||
...(row.prescribedAt ? { authoredOn: row.prescribedAt } : {}),
|
||||
requester: { display: row.prescriber },
|
||||
...(dosageText ? { dosageInstruction: [{ text: dosageText }] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Encounter --------------------------------------------------------------
|
||||
|
||||
function narrative(text: string): Record<string, unknown> {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return {
|
||||
status: "generated",
|
||||
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escaped}</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function encounterResource(
|
||||
row: EncounterRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
const start = fhirDateTime(row.date);
|
||||
return {
|
||||
resourceType: "Encounter",
|
||||
id: row.id,
|
||||
...(row.summary ? { text: narrative(row.summary) } : {}),
|
||||
status: "finished",
|
||||
class: {
|
||||
system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
|
||||
code: "AMB",
|
||||
display: "ambulatory",
|
||||
},
|
||||
type: [{ text: row.type }],
|
||||
subject: subjectRef(patient),
|
||||
...(start ? { period: { start } } : {}),
|
||||
participant: [{ individual: { display: row.provider } }],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Appointment ------------------------------------------------------------
|
||||
|
||||
export function appointmentResource(
|
||||
row: AppointmentRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
const statusMap: Record<string, string> = {
|
||||
confirmed: "booked",
|
||||
"checked-in": "arrived",
|
||||
completed: "fulfilled",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
// Combine local date + time into an instant; omit when unparseable.
|
||||
const startDate =
|
||||
row.date && row.time ? new Date(`${row.date}T${row.time}:00`) : null;
|
||||
const start =
|
||||
startDate && !Number.isNaN(startDate.getTime())
|
||||
? startDate.toISOString()
|
||||
: undefined;
|
||||
return {
|
||||
resourceType: "Appointment",
|
||||
id: row.id,
|
||||
status: statusMap[row.status] ?? "booked",
|
||||
description: row.type,
|
||||
...(start ? { start } : {}),
|
||||
participant: [
|
||||
{ actor: subjectRef(patient), status: "accepted" },
|
||||
...(row.provider
|
||||
? [{ actor: { display: row.provider }, status: "accepted" }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user