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
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "fhir_api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_used_at" timestamp,
"revoked_at" timestamp,
CONSTRAINT "fhir_api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
ALTER TABLE "fhir_api_keys" ADD CONSTRAINT "fhir_api_keys_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fhir_api_keys" ADD CONSTRAINT "fhir_api_keys_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "fhir_api_keys_org_idx" ON "fhir_api_keys" USING btree ("organization_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1783093188246,
"tag": "0030_medical_blur",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1783117115021,
"tag": "0031_stiff_gateway",
"breakpoints": true
}
]
}
+30
View File
@@ -0,0 +1,30 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// Per-organization API keys for the read-only FHIR R4 server (`/fhir`). These
// are machine-to-machine credentials (no Better Auth session): a caller sends
// `Authorization: Bearer tmf_<secret>` and every query is scoped to the owning
// clinic. Only the SHA-256 *hash* of the secret is stored — the plaintext key is
// shown once at creation and never again. Revoking sets `revokedAt` (kept for
// audit rather than hard-deleted).
export const fhirApiKeys = pgTable(
"fhir_api_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// Hex SHA-256 of the full `tmf_…` secret. Unique so a lookup is a single
// indexed probe and two keys can never collide.
keyHash: text("key_hash").notNull().unique(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
lastUsedAt: timestamp("last_used_at"),
revokedAt: timestamp("revoked_at"),
},
(t) => [index("fhir_api_keys_org_idx").on(t.organizationId)],
);
+1
View File
@@ -22,3 +22,4 @@ export * from "./meetings.js";
export * from "./signing.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
export * from "./fhir-keys.js";
+7
View File
@@ -18,6 +18,7 @@ import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { conversationsRouter } from "./routes/conversations.js";
import { dispensesRouter } from "./routes/dispenses.js";
import { fhirRouter } from "./routes/fhir.js";
import { integrationsRouter } from "./routes/integrations.js";
import { inventoryRouter } from "./routes/inventory.js";
import { invoicesRouter } from "./routes/invoices.js";
@@ -110,6 +111,11 @@ app.use("/api/integrations", integrationsRouter);
app.use("/api/portal", portalRouter);
app.use("/api/auth-helpers", authHelpersRouter);
// Read-only FHIR R4 server, mounted OUTSIDE /api. Bearer-only (per-clinic API
// keys), no Better Auth session/cookie coupling. Errors are FHIR
// OperationOutcomes, not our standard error JSON.
app.use("/fhir", fhirRouter);
app.use(notFound);
app.use(errorHandler);
@@ -146,6 +152,7 @@ server.listen(env.PORT, () => {
console.log(` • chat: /api/chat (LLM agent)`);
console.log(` • integr.: /api/integrations (FHIR / e-Rx / claims)`);
console.log(` • portal: /api/portal (public clinic kiosk)`);
console.log(` • fhir: /fhir (read-only FHIR R4 server, API-key auth)`);
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`);
});
+49
View File
@@ -0,0 +1,49 @@
import type { NextFunction, Request, Response } from "express";
import { resolveKey } from "../services/fhir-server/keys.js";
import {
FHIR_CONTENT_TYPE,
operationOutcome,
} from "../services/fhir-server/outcome.js";
// Bearer-token auth for the read-only FHIR server. Unlike the rest of the API
// (Better Auth session cookies), the `/fhir` endpoints authenticate with a
// per-clinic API key: `Authorization: Bearer tmf_<secret>`. On success the
// caller's organization is attached to `req.organizationId` and every downstream
// query is scoped to it. Failures return a FHIR OperationOutcome, not our
// standard error JSON.
export async function requireFhirKey(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const header = req.headers.authorization ?? "";
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
const secret = match?.[1]?.trim();
const unauthorized = (diagnostics: string) => {
res
.status(401)
.type(FHIR_CONTENT_TYPE)
.set("WWW-Authenticate", "Bearer")
.json(operationOutcome("error", "login", diagnostics));
};
if (!secret) {
unauthorized("Missing bearer token. Send Authorization: Bearer tmf_…");
return;
}
try {
const resolved = await resolveKey(secret);
if (!resolved) {
unauthorized("Invalid or revoked API key.");
return;
}
req.organizationId = resolved.orgId;
req.fhirKey = { id: resolved.keyId, name: resolved.keyName };
next();
} catch (err) {
next(err);
}
}
+275
View File
@@ -0,0 +1,275 @@
import { createRequire } from "node:module";
import { Router } from "express";
import type { Request, Response } from "express";
import type { ParsedQs } from "qs";
import { env } from "../env.js";
import { requireFhirKey } from "../middleware/fhir-auth.js";
import { recordActivity } from "../services/activity.js";
import {
paginate,
parseCount,
parseOffset,
searchsetBundle,
} from "../services/fhir-server/bundle.js";
import { capabilityStatement } from "../services/fhir-server/capability.js";
import {
FHIR_CONTENT_TYPE,
operationOutcome,
type IssueCode,
type IssueSeverity,
} from "../services/fhir-server/outcome.js";
import * as q from "../services/fhir-server/queries.js";
import {
allergyResource,
appointmentResource,
conditionResource,
encounterResource,
labObservation,
medicationRequestResource,
patientResource,
vitalObservations,
type FhirResource,
} from "../services/fhir-server/resources.js";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json") as { version?: string };
const VERSION = env.APP_VERSION ?? pkg.version ?? "0.0.0";
export const fhirRouter = Router();
// --- helpers ----------------------------------------------------------------
function baseUrl(req: Request): string {
return `${req.protocol}://${req.get("host")}/fhir`;
}
function sendResource(res: Response, resource: unknown): void {
res.type(FHIR_CONTENT_TYPE).json(resource);
}
function sendOutcome(
res: Response,
status: number,
severity: IssueSeverity,
code: IssueCode,
diagnostics: string,
): void {
res
.status(status)
.type(FHIR_CONTENT_TYPE)
.json(operationOutcome(severity, code, diagnostics));
}
function qstr(v: string | ParsedQs | (string | ParsedQs)[] | undefined): string | undefined {
if (typeof v === "string") return v.trim() || undefined;
if (Array.isArray(v) && typeof v[0] === "string") return v[0].trim() || undefined;
return undefined;
}
// Best-effort audit: every FHIR request is logged with the key name + result
// count, scoped to the org. Access to PHI over the API must leave a trail.
function audit(req: Request, resourceType: string, count: number): void {
void recordActivity({
orgId: req.organizationId!,
actor: { name: `FHIR API · ${req.fhirKey?.name ?? "key"}` },
action: `Read ${resourceType} via the FHIR API (${count} result${count === 1 ? "" : "s"})`,
entityType: "patient",
});
}
// Materialize a page from a full resource array + emit a searchset Bundle.
function respondSearch(
req: Request,
res: Response,
resourceType: string,
all: FhirResource[],
): void {
const count = parseCount(qstr(req.query._count as never));
const offset = parseOffset(qstr(req.query._offset as never));
const { page, total } = paginate(all, count, offset);
const params = new URLSearchParams();
for (const [k, v] of Object.entries(req.query)) {
if (k === "_count" || k === "_offset") continue;
const s = qstr(v as never);
if (s !== undefined) params.set(k, s);
}
audit(req, resourceType, total);
sendResource(
res,
searchsetBundle({ baseUrl: baseUrl(req), resourceType, page, total, count, offset, params }),
);
}
// Resolve the `patient` / `patient.identifier` search parameter to a patient row
// (org-scoped). Returns undefined when the param is absent or matches nobody.
async function patientFromQuery(req: Request) {
const patientId = qstr(req.query.patient as never);
const identifier = qstr(req.query["patient.identifier"] as never);
if (!patientId && !identifier) return undefined;
return q.resolvePatientRef(req.organizationId!, { patientId, identifier });
}
// --- CapabilityStatement (unauthenticated, per FHIR convention) -------------
fhirRouter.get("/metadata", (req, res) => {
sendResource(res, capabilityStatement(baseUrl(req), VERSION));
});
// Everything below requires a valid per-clinic API key.
fhirRouter.use(requireFhirKey);
// --- Patient ----------------------------------------------------------------
fhirRouter.get("/Patient", async (req, res, next) => {
try {
const count = parseCount(qstr(req.query._count as never));
const offset = parseOffset(qstr(req.query._offset as never));
const { rows, total } = await q.searchPatients(req.organizationId!, {
identifier: qstr(req.query.identifier as never),
name: qstr(req.query.name as never),
limit: count,
offset,
});
const params = new URLSearchParams();
if (qstr(req.query.identifier as never))
params.set("identifier", qstr(req.query.identifier as never)!);
if (qstr(req.query.name as never)) params.set("name", qstr(req.query.name as never)!);
audit(req, "Patient", total);
sendResource(
res,
searchsetBundle({
baseUrl: baseUrl(req),
resourceType: "Patient",
page: rows.map(patientResource),
total,
count,
offset,
params,
}),
);
} catch (err) {
next(err);
}
});
fhirRouter.get("/Patient/:id", async (req, res, next) => {
try {
const row = await q.patientById(req.organizationId!, String(req.params.id));
if (!row) {
sendOutcome(res, 404, "error", "not-found", "Patient not found.");
return;
}
audit(req, "Patient", 1);
sendResource(res, patientResource(row));
} catch (err) {
next(err);
}
});
// --- Observation (labs + vitals) --------------------------------------------
fhirRouter.get("/Observation", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) {
respondSearch(req, res, "Observation", []);
return;
}
const category = qstr(req.query.category as never);
const all: FhirResource[] = [];
if (category !== "vital-signs") {
const rows = await q.labsForPatient(patient.id);
all.push(...rows.map((r) => labObservation(r, patient)));
}
if (category !== "laboratory") {
all.push(...vitalObservations(patient));
}
respondSearch(req, res, "Observation", all);
} catch (err) {
next(err);
}
});
// --- AllergyIntolerance -----------------------------------------------------
fhirRouter.get("/AllergyIntolerance", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "AllergyIntolerance", []);
const rows = await q.allergiesForPatient(patient.id);
respondSearch(req, res, "AllergyIntolerance", rows.map((r) => allergyResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- Condition --------------------------------------------------------------
fhirRouter.get("/Condition", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "Condition", []);
const rows = await q.problemsForPatient(patient.id);
respondSearch(req, res, "Condition", rows.map((r) => conditionResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- MedicationRequest ------------------------------------------------------
fhirRouter.get("/MedicationRequest", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "MedicationRequest", []);
const rows = await q.prescriptionsForFile(req.organizationId!, patient.fileNumber);
respondSearch(
req,
res,
"MedicationRequest",
rows.map((r) => medicationRequestResource(r, patient)),
);
} catch (err) {
next(err);
}
});
// --- Encounter --------------------------------------------------------------
fhirRouter.get("/Encounter", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "Encounter", []);
const rows = await q.encountersForPatient(patient.id);
respondSearch(req, res, "Encounter", rows.map((r) => encounterResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- Appointment ------------------------------------------------------------
fhirRouter.get("/Appointment", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "Appointment", []);
const rows = await q.appointmentsForFile(req.organizationId!, patient.fileNumber);
respondSearch(req, res, "Appointment", rows.map((r) => appointmentResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- Unknown resource / path -> OperationOutcome ----------------------------
fhirRouter.use((req, res) => {
sendOutcome(
res,
404,
"error",
"not-supported",
`Unsupported FHIR path or resource: ${req.method} ${req.path}.`,
);
});
+70
View File
@@ -18,6 +18,7 @@ import {
listConfigs,
saveConfig,
} from "../services/integrations/config.js";
import { createKey, listKeys, revokeKey } from "../services/fhir-server/keys.js";
import * as eprescribe from "../services/integrations/eprescribe.js";
import * as fhir from "../services/integrations/fhir.js";
@@ -119,6 +120,75 @@ integrationsRouter.post(
},
);
// --- FHIR server API keys (owner/admin only) --------------------------------
// These credential the read-only /fhir server. The plaintext secret is returned
// exactly once (on creation) and only its hash is stored.
integrationsRouter.get(
"/fhir-server/keys",
requireAuth,
requireOrg,
async (req, res, next) => {
try {
assertAdmin(req.memberRole);
res.json(await listKeys(req.organizationId!));
} catch (err) {
next(err);
}
},
);
const createKeySchema = z.object({ name: z.string().trim().min(1).max(120) });
integrationsRouter.post(
"/fhir-server/keys",
requireAuth,
requireOrg,
async (req, res, next) => {
try {
assertAdmin(req.memberRole);
const { name } = createKeySchema.parse(req.body);
const { secret, key } = await createKey(
req.organizationId!,
name,
req.user!.id,
);
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Created a FHIR API key ("${key.name}")`,
entityType: "settings",
});
// `secret` is present only in this response — the client must show it now.
res.status(201).json({ ...key, secret });
} catch (err) {
next(err);
}
},
);
integrationsRouter.delete(
"/fhir-server/keys/:id",
requireAuth,
requireOrg,
async (req, res, next) => {
try {
assertAdmin(req.memberRole);
const revoked = await revokeKey(req.organizationId!, String(req.params.id));
if (!revoked) throw new HttpError(404, "API key not found.");
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Revoked a FHIR API key",
entityType: "settings",
});
res.json({ revoked: true });
} catch (err) {
next(err);
}
},
);
// --- Actions ----------------------------------------------------------------
const syncSchema = z.object({ fileNumber: z.string().trim().min(1) });
@@ -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" }]
: []),
],
};
}
+3
View File
@@ -15,6 +15,9 @@ declare global {
};
organizationId?: string;
memberRole?: string;
// Set by the FHIR bearer-auth middleware (machine-to-machine API key)
// instead of a Better Auth session; used for org scoping + audit.
fhirKey?: { id: string; name: string };
}
}
}