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:
Khalid Abdi
2026-07-04 01:27:32 +03:00
parent bb536ba6da
commit 0d2494d67a
23 changed files with 6230 additions and 1 deletions
@@ -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,
},
],
};
}
+110
View File
@@ -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 }],
};
}
+168
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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" }]
: []),
],
};
}