Compare commits

...

5 Commits

Author SHA1 Message Date
Khalid Abdi 99aa534e88 feat: multi-clinic Temetro Network with per-clinic identity (v0.8.0)
The relay is now multi-clinic. Each clinic authenticates to the /hub
namespace by signing a challenge with its own Ed25519 clinic signing key
(a per-clinic identity, not a shared RELAY_TOKEN), and the relay routes
every device response back to only the clinic that originated the request
(keyed by requestId) — so clinics never see each other's traffic.

Backend:
- clinic_signing_keys.network_enabled + GET/PUT /api/signing/network
  (owner/admin) to join/leave the network.
- relay-client keeps one authenticated hub connection per network-enabled
  org (connectOrg/disconnectOrg, hubs map keyed by orgId); emitToWallet/
  sendToWallet take orgId; offline flush is org-scoped.
- Wallet import/push return 409 until a clinic joins.
- RELAY_TOKEN is now optional/legacy (open relay needs no shared secret).

Frontend:
- "Join Temetro Network" toggle in Settings → Signing, localized in all
  five languages (en, fr, de, so, ar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:30:19 +03:00
Khalid Abdi ef76afc3ca feat: route wallet traffic through the Temetro Network relay (v0.7.0)
Devices no longer connect to the backend directly. The /wallet Socket.io
namespace is removed from realtime.ts; a new services/relay-client.ts connects
to the standalone Temetro Network relay's /hub namespace (RELAY_TOKEN-auth),
emitToWallet delegates to its sendToWallet, and device responses + wallet:online
replay are handled there via the same wallet-share/wallet-updates services.

- Add RELAY_URL + RELAY_TOKEN env (env.ts, .env.example, docker-compose.yml);
  the wallet-import QR (resolveRelayUrl) now points at RELAY_URL.
- Add socket.io-client dependency.
- Document the Temetro Network folder/service in root + backend CLAUDE.md.
- Bump root/backend/frontend to 0.7.0; CHANGELOG entry.

The relay service itself lives in github.com/temetro/temetro-network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 03:04:23 +03:00
Khalid Abdi d79f7f7c06 chore(release): v0.6.0
Read-only FHIR R4 server at /fhir with per-clinic API keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:34:24 +03:00
Khalid Abdi 0d2494d67a 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>
2026-07-04 01:27:32 +03:00
Khalid Abdi bb536ba6da feat: clinic→wallet record-update push
A clinician can push an updated record to a wallet-linked patient (permanent
share). The snapshot is signed with the clinic Ed25519 key and sealed to the
wallet's X25519 key — derived from its Ed25519 wallet number via the birational
map, verified byte-for-byte against the wallet's own derivation. Stored pending,
delivered over the /wallet relay live and on the wallet's next authenticated
connect (offline catch-up). The patient approves/denies in-app; the wallet signs
its decision, the backend verifies it, and the record is replaced only on
approval. Wallet pins the clinic key (TOFU) and warns on change.

Backend: walletRecordUpdates table + service, ed25519PubToX25519Hex helper,
POST /api/patients/wallet/push, GET .../link/:fileNumber|updates|updates/:id,
wallet:update-request / wallet:update-response relay events.
Frontend: "Push to wallet" dialog with live status, wallet-link gating on the
patient sheet, "Sent updates" list under Settings → Signing, walletPush /
walletUpdatesList locale namespaces across all five languages. Bumps to v0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:57:38 +03:00
52 changed files with 17045 additions and 134 deletions
+82
View File
@@ -7,6 +7,88 @@ for how releases are cut and published.
## [Unreleased]
## [0.8.0] — 2026-07-05
### Added
- **Multi-clinic Temetro Network.** The relay now serves many self-hosted clinics at once. Each
clinic authenticates to the `/hub` namespace by **signing a challenge with its own Ed25519 clinic
signing key** (`services/signing.ts`) — a per-clinic identity, not a shared password — and the
relay routes every device response back to only the clinic that originated the request (keyed by
`requestId`), so clinics never see each other's traffic. `wallet:online` is fanned out only to
clinics with pending work for that wallet.
- **"Join Temetro Network" opt-in.** A per-clinic toggle in **Settings → Signing** (backed by
`clinic_signing_keys.network_enabled`, `GET`/`PUT /api/signing/network`, owner/admin only). Off by
default; enabling opens the clinic's relay connection, disabling tears it down. Wallet
import/push endpoints return **409** while a clinic hasn't joined. Localised in all five languages.
### Changed
- **The backend keeps one authenticated relay connection per network-enabled org**
(`services/relay-client.ts``connectOrg`/`disconnectOrg`, a `hubs` map keyed by `orgId`), instead
of a single shared-token connection. `emitToWallet`/`sendToWallet` now take an `orgId`, and the
offline-flush (`pendingUpdatesForWallet`) is org-scoped.
- **`RELAY_TOKEN` is now optional/legacy.** Clinics authenticate with their signing key, so an open
relay needs no shared secret; `RELAY_TOKEN` only gates an optional *private* relay.
## [0.7.0] — 2026-07-05
### Added
- **Temetro Network** — a standalone, high-performance **relay** (Rust + Axum + socketioxide) that
connects the backend to patient wallet apps, in its own repo
([github.com/temetro/temetro-network](https://github.com/temetro/temetro-network)) and deployable
on Railway. It replaces the flaky Cloudflare quick-tunnel that used to expose the backend's
embedded `/wallet` Socket.io namespace to phones. The relay is a **dumb, stateless pipe**: a
`/wallet` namespace for devices (challenge/Ed25519-signature auth, room keyed by wallet number)
and a `RELAY_TOKEN`-authenticated `/hub` namespace for the backend. It forwards sealed ciphertext
verbatim, keeps no database, and its only crypto is verifying a device's auth signature (proven
byte-for-byte compatible with `wallet-crypto.ts`).
### Changed
- **The backend is now a client of the relay, not the wallet server.** The `/wallet` Socket.io
namespace was removed from `src/realtime.ts`; a new `src/services/relay-client.ts` connects to the
relay's `/hub` (`emitToWallet` delegates to its `sendToWallet`), handles device responses
(`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) and flushes missed updates on
`wallet:online` — calling the same `wallet-share` / `wallet-updates` services as before. New
`RELAY_URL` + `RELAY_TOKEN` env vars; the wallet-import QR now points at `RELAY_URL`.
## [0.6.0] — 2026-07-04
### Added
- **Read-only FHIR R4 server** — temetro can now be a FHIR **server**, not just a client.
A new endpoint tree at **`/fhir`** (mounted outside `/api`, bearer-only) exposes each
clinic's records as FHIR R4: **Patient**, **Observation** (labs + synthesized vital signs),
**AllergyIntolerance**, **Condition**, **MedicationRequest**, **Encounter** and
**Appointment**, plus an unauthenticated **`GET /fhir/metadata`** CapabilityStatement.
Searches return searchset `Bundle`s with `_count`/`_offset` pagination and self/next/prev
links. Because temetro stores free-text clinical values, every `CodeableConcept` is
**text-only** (no SNOMED/LOINC) and patients carry an **age** extension rather than a
`birthDate` — documented in the CapabilityStatement and API docs.
- **Per-clinic FHIR API keys** — machine-to-machine auth via `Authorization: Bearer tmf_…`.
Keys are created/revoked under **Settings → Integrations → FHIR server** (owner/admin),
**SHA-256-hashed** at rest, and shown **once** at creation. Every FHIR request is
org-scoped (no cross-clinic reads) and written to the activity log with the key name and
result count. New `fhir_api_keys` table, `middleware/fhir-auth.ts`, the
`services/fhir-server/` mapping module (queries, resources, bundle, capability, keys), the
`/fhir` router, and `GET/POST/DELETE /api/integrations/fhir-server/keys`. New `fhirServer`
locale namespace across all five languages.
## [0.5.0] — 2026-07-03
### Added
- **Clinic → wallet record-update push** — a clinician can push an updated record to a
**wallet-linked** patient (a permanent, approved share). The record snapshot is **signed**
with the clinic's Ed25519 key and **sealed** to the wallet's X25519 key (derived from its
Ed25519 wallet number via the birational map — verified byte-for-byte against the wallet's
own derivation), stored `pending`, and delivered over the `/wallet` relay live **and** on
the wallet's next authenticated connect (so an offline phone catches up). The patient
reviews it in a **pending-updates inbox** and approves/denies; the wallet signs its
decision, the backend verifies it, and the on-device record is replaced only on approval.
The wallet **pins** the clinic key (TOFU) and warns on a key change. New
`POST /api/patients/wallet/push`, `GET /api/patients/wallet/{link/:fileNumber,updates,updates/:id}`,
`walletRecordUpdates` table + service, `wallet:update-request` / `wallet:update-response`
relay events, a "Push to wallet" dialog with live status, and a "Sent updates" list under
Settings → Signing. New `walletPush` / `walletUpdatesList` locale namespaces across all five
languages. (The wallet app half ships in the sibling `temetro-app` repo.)
## [0.4.0] — 2026-07-03
### Added
+28 -5
View File
@@ -27,12 +27,13 @@ repository (published as `temetro`).
> "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow:
> clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`),
> and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over
> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is
> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`.
> the **Temetro Network** relay (see below), the patient approves on their phone, and the sealed record
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
>
> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing,
> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**.
> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
> shares. The AI chat is still **mock replies**. Email verification is wired but currently **not
> enforced** at sign-in (see `backend/CLAUDE.md`).
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
@@ -47,6 +48,28 @@ here means keys + data live on the patient's device and the relay only ever forw
is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be
deleted). Commit/push that app inside its own repo, separately from this one.
## Temetro Network (sibling repo/folder `~/Desktop/Temetro-network`)
The **relay** that connects this backend to patient wallet apps. It is its **own git repo** on the
Desktop (folder `~/Desktop/Temetro-network`, pushed to `github.com/temetro/temetro-network`), **not**
in this monorepo — a standalone **Rust + Axum + socketioxide** service meant to run always-on (e.g.
on **Railway**). It replaces the old flaky Cloudflare quick-tunnel that used to expose the backend's
embedded `/wallet` Socket.io namespace to phones.
It is a **dumb, stateless pipe**: two Socket.io namespaces — `/wallet` for devices
(challenge/Ed25519-signature auth, room keyed by wallet number) and `/hub` for this backend
(`RELAY_TOKEN`-authenticated). Devices and the backend both connect to it; it **forwards sealed
ciphertext verbatim** and never opens bundles or touches a database. Its only crypto is verifying a
device's auth signature (mirrors `backend/src/lib/wallet-crypto.ts`). The backend connects to it as a
`/hub` client via `backend/src/services/relay-client.ts` (its `sendToWallet` is what `emitToWallet`
now calls); configure with `RELAY_URL` + `RELAY_TOKEN`. Commit/push that service inside its own repo,
separately from this one.
> **Note:** in this sandbox the `~/Desktop/Temetro-network` folder blocks directory enumeration
> (`ls`/`getcwd`/git inside it return EPERM) though plain file writes work. Develop/build/commit it
> in an accessible copy and mirror the tree in with `tar`; drive git there via
> `GIT_DIR`/`GIT_WORK_TREE` from an accessible cwd.
## Layout
`frontend/` and `backend/` were previously separate per-folder git repos; they have been **merged
+13 -7
View File
@@ -32,13 +32,19 @@ POSTGRES_PORT=5432
BACKEND_PORT=4000
FRONTEND_PORT=3000
# --- Patient wallet relay -------------------------------------------------
# The URL baked into the QR a patient scans to import their record. Their phone
# must be able to reach it — so localhost will NOT work from a real device. If
# unset, the backend derives it from the request host (fine when you open the
# web app over your LAN IP, e.g. http://192.168.1.20:3000). Otherwise set it
# explicitly to a phone-reachable address: your machine's LAN IP or a public
# tunnel URL.
# --- Temetro Network relay ------------------------------------------------
# The standalone relay (github.com/temetro/temetro-network) that connects this
# backend to patient phones. Deploy it (e.g. on Railway) and point both this
# backend and the wallet app at it. RELAY_URL is the relay's public URL (also
# baked into the QR a patient scans). This clinic authenticates to the relay's
# /hub with its own Ed25519 signing key, so no shared secret is needed.
# RELAY_TOKEN is OPTIONAL/LEGACY — set it only for a private relay that also
# gates on a shared token (then use the SAME value here and on the relay).
RELAY_URL=http://localhost:8080
RELAY_TOKEN=
# (Legacy, pre-relay self-hosting.) A phone-reachable URL for the QR when NOT
# using the Temetro Network relay. RELAY_URL takes precedence over this.
# PUBLIC_RELAY_URL=http://192.168.1.20:4000
# --- Email (optional) -----------------------------------------------------
+12
View File
@@ -60,6 +60,18 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
- **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`), which is **multi-clinic**.
This backend connects to it as a `/hub` client in **`src/services/relay-client.ts`**, keeping **one
authenticated connection per network-enabled org** (`hubs` map keyed by `orgId`). Each org
authenticates by signing the relay's `hub:challenge` with its clinic signing key
(`signWithClinicKey`) — no shared `RELAY_TOKEN` needed (it's now optional/legacy, only for a
private relay). `emitToWallet(orgId, …)` (realtime.ts) delegates to `sendToWallet(orgId, …)`, and
device responses (`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) +
`wallet:online` replay are handled per-org there, calling the same `wallet-share` /
`wallet-updates` services the old `/wallet` namespace did. A clinic opts in via **"Join Temetro
Network"** (Settings → Signing → `PUT /api/signing/network`, `clinic_signing_keys.network_enabled`);
`connectOrg`/`disconnectOrg` open/close its connection, and wallet routes 409 when it's off.
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
## Gotchas / conventions
+5
View File
@@ -68,6 +68,11 @@ services:
BETTER_AUTH_URL: http://localhost:4000
FRONTEND_URL: http://localhost:3000
PORT: "4000"
# Temetro Network relay (github.com/temetro/temetro-network). Set RELAY_URL
# to your deployed relay's public URL and RELAY_TOKEN to the shared secret
# you configured on it — both are required for patient-wallet import.
RELAY_URL: ${RELAY_URL:-}
RELAY_TOKEN: ${RELAY_TOKEN:-}
NODE_ENV: production
# Uploaded patient/lab files live here, on the temetro_uploads volume.
UPLOAD_DIR: /var/lib/temetro/uploads
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "wallet_record_updates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"created_by" text NOT NULL,
"file_number" text NOT NULL,
"wallet_number" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"payload_sealed" text NOT NULL,
"clinic_signature" text NOT NULL,
"clinic_public_key" text NOT NULL,
"clinic_fingerprint" text NOT NULL,
"changes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"delivered_at" timestamp,
"resolved_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "wallet_updates_org_idx" ON "wallet_record_updates" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "wallet_updates_wallet_idx" ON "wallet_record_updates" USING btree ("wallet_number");
+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");
@@ -0,0 +1 @@
ALTER TABLE "clinic_signing_keys" ADD COLUMN "network_enabled" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -211,6 +211,27 @@
"when": 1782057030557,
"tag": "0029_tiny_starhawk",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1783093188246,
"tag": "0030_medical_blur",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1783117115021,
"tag": "0031_stiff_gateway",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1783263738631,
"tag": "0032_closed_dakota_north",
"breakpoints": true
}
]
}
+60 -2
View File
@@ -1,12 +1,12 @@
{
"name": "temetro-backend",
"version": "0.1.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "temetro-backend",
"version": "0.1.0",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.84",
@@ -28,6 +28,7 @@
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -3339,6 +3340,40 @@
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@@ -4947,6 +4982,21 @@
"ws": "~8.20.1"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
@@ -5779,6 +5829,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.4.0",
"version": "0.8.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
@@ -41,6 +41,7 @@
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
+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)],
);
+2
View File
@@ -21,3 +21,5 @@ export * from "./staff-profile.js";
export * from "./meetings.js";
export * from "./signing.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
export * from "./fhir-keys.js";
+6 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
@@ -16,6 +16,11 @@ export const clinicSigningKeys = pgTable("clinic_signing_keys", {
fingerprint: text("fingerprint").notNull(),
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
privateKeyEnc: text("private_key_enc").notNull(),
// Whether this clinic has joined the Temetro Network relay ("Join Temetro
// Network" in Settings → Signing). Off by default: only when enabled does the
// backend open this clinic's relay hub connection and expose wallet features.
// The relay identity *is* this signing key, so the flag lives on the same row.
networkEnabled: boolean("network_enabled").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
rotatedAt: timestamp("rotated_at"),
});
+53
View File
@@ -0,0 +1,53 @@
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
export type WalletUpdateStatus =
| "pending"
| "delivered"
| "approved"
| "denied";
// One row per clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient they can push the updated record to the patient's app;
// it lands here as `pending`, is sealed to the wallet's (X25519-from-Ed25519)
// key and signed with the clinic's Ed25519 key. The relay delivers it live if
// the device is connected, and again on the wallet's next authenticated connect
// (so an offline phone still receives it). The patient reviews the change,
// verifies the clinic signature, and approves/denies in-app — only then is the
// on-device record replaced. The clinic polls `status` for delivery/approval.
export const walletRecordUpdates = pgTable(
"wallet_record_updates",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdBy: text("created_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileNumber: text("file_number").notNull(),
walletNumber: text("wallet_number").notNull(),
status: text("status")
.$type<WalletUpdateStatus>()
.notNull()
.default("pending"),
// base64 sealed box of the full updated patient snapshot (sealed to the
// wallet's derived X25519 key).
payloadSealed: text("payload_sealed").notNull(),
// The clinic's Ed25519 signature over the plaintext bundle bytes + its
// public key + fingerprint, so the wallet can verify provenance (TOFU pin).
clinicSignature: text("clinic_signature").notNull(),
clinicPublicKey: text("clinic_public_key").notNull(),
clinicFingerprint: text("clinic_fingerprint").notNull(),
// Human-readable summary of what changed (shown in the wallet inbox).
changes: jsonb("changes").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at").defaultNow().notNull(),
deliveredAt: timestamp("delivered_at"),
resolvedAt: timestamp("resolved_at"),
},
(t) => [
index("wallet_updates_org_idx").on(t.organizationId),
index("wallet_updates_wallet_idx").on(t.walletNumber),
],
);
+12
View File
@@ -28,6 +28,15 @@ const schema = z.object({
// Overrides the version reported by GET /api/version. Normally derived from
// package.json; the release pipeline can pin it explicitly.
APP_VERSION: z.string().optional(),
// Temetro Network relay (github.com/temetro/temetro-network). Both this
// backend and patient phones connect to it; it routes the encrypted wallet
// messages between them. RELAY_URL is the relay's public URL (also baked into
// the QR a patient scans). Each clinic authenticates to the relay's /hub with
// its own Ed25519 signing key, so no shared secret is needed. RELAY_TOKEN is
// now *optional/legacy* — set it only for a private relay that also gates on a
// shared token (must then match the relay's RELAY_TOKEN).
RELAY_URL: z.string().min(1).default("http://localhost:8080"),
RELAY_TOKEN: z.string().default(""),
// Public, device-reachable URL of this backend's wallet relay, baked into the
// QR a patient scans. Optional — when unset we derive it from the request host
// (so opening the web app over the LAN yields a reachable LAN URL).
@@ -80,6 +89,9 @@ if (env.NODE_ENV === "production") {
);
process.exit(1);
}
// RELAY_TOKEN is optional now: clinics authenticate to the relay with their
// own Ed25519 signing key, so an unset token is the normal "open relay" case —
// no warning needed.
}
export const isProd = env.NODE_ENV === "production";
+14 -1
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";
@@ -35,6 +36,7 @@ import { staffRouter } from "./routes/staff.js";
import { networkRouter } from "./routes/network.js";
import { tasksRouter } from "./routes/tasks.js";
import { versionRouter } from "./routes/version.js";
import { initRelayClient } from "./services/relay-client.js";
import { beginQuickTunnelDiscovery } from "./services/relay-url.js";
import { sweepExpiredShares } from "./services/wallet-share.js";
@@ -110,6 +112,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);
@@ -117,6 +124,11 @@ app.use(errorHandler);
const server = createServer(app);
initRealtime(server);
// Connect to the Temetro Network relay (the device-facing hub). Patient phones
// no longer connect to this backend directly — they connect to the relay, and
// we push to / receive from them over its /hub namespace.
initRelayClient();
// Sweep expired temporary patient-wallet shares (auto-delete) every 5 minutes.
const SHARE_SWEEP_INTERVAL = 5 * 60 * 1000;
setInterval(() => {
@@ -146,8 +158,9 @@ 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)`);
console.log(` • wallet: /api/patients/wallet (via Temetro Network relay: ${env.RELAY_URL})`);
});
// Dockerized off-network testing: learn our public Cloudflare quick-tunnel URL
+49
View File
@@ -0,0 +1,49 @@
import { bytesToHex } from "@noble/hashes/utils.js";
// Convert an Ed25519 public key to the matching X25519 (Montgomery) public key,
// so the clinic can `seal()` a record update to a wallet that only publishes an
// Ed25519 identity (its wallet number). The patient wallet derives the matching
// X25519 *private* key from its Ed25519 seed (SHA-512 clamp) to `open()` it —
// this file MUST stay byte-for-byte compatible with the wallet app's
// src/lib/crypto.ts. @noble/curves does not export edwardsToMontgomery in the
// pinned version, so the birational map u = (1 + y) / (1 - y) mod p is done here
// with BigInt. Verified: edPubToMontU(A) === x25519.getPublicKey(edClamp(seed)).
const P = 2n ** 255n - 19n;
function modpow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
let b = base % mod;
let e = exp;
while (e > 0n) {
if (e & 1n) result = (result * b) % mod;
b = (b * b) % mod;
e >>= 1n;
}
return result;
}
// Modular inverse via Fermat's little theorem (p is prime).
function inv(a: bigint): bigint {
return modpow(((a % P) + P) % P, P - 2n, P);
}
// Ed25519 public key (compressed, little-endian y with the x-sign in the high
// bit) → X25519 u-coordinate, returned as 32-byte little-endian hex.
export function ed25519PubToX25519Hex(edPub: Uint8Array): string {
if (edPub.length !== 32) throw new Error("Ed25519 public key must be 32 bytes.");
const bytes = edPub.slice();
bytes[31] = (bytes[31] as number) & 0x7f; // clear the x sign bit
let y = 0n;
for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i] as number);
y %= P;
// u = (1 + y) / (1 - y) (mod p)
const u = ((1n + y) * inv((1n - y + P) % P)) % P;
const out = new Uint8Array(32);
let v = u;
for (let i = 0; i < 32; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return bytesToHex(out);
}
+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);
}
}
+8 -109
View File
@@ -1,16 +1,14 @@
import type { Server as HttpServer } from "node:http";
import { fromNodeHeaders } from "better-auth/node";
import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { Server, type Socket } from "socket.io";
import { auth } from "./auth.js";
import { env } from "./env.js";
import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js";
import * as meetings from "./services/meetings.js";
import * as messaging from "./services/messaging.js";
import { createNotification } from "./services/notifications.js";
import * as walletShare from "./services/wallet-share.js";
import { sendToWallet } from "./services/relay-client.js";
import type { MessageAttachment } from "./types/messaging.js";
let io: Server | null = null;
@@ -19,7 +17,6 @@ const userRoom = (userId: string) => `user:${userId}`;
const convRoom = (conversationId: string) => `conv:${conversationId}`;
const callRoom = (roomId: string) => `call:${roomId}`;
const orgRoom = (orgId: string) => `org:${orgId}`;
const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`;
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
// past that the room is closed to new joiners.
@@ -43,15 +40,18 @@ export function emitToConversation(
io?.to(convRoom(conversationId)).emit(event, data);
}
// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet
// namespace, room keyed by wallet number). The relay only ever forwards
// ciphertext — it cannot read the record bundle.
// Relay an end-to-end-encrypted message to a patient wallet device. Devices no
// longer connect to this server directly — they connect to the standalone
// Temetro Network relay, which forwards to the room keyed by wallet number. We
// push over the relay's /hub namespace (see services/relay-client.ts). The
// relay only ever forwards ciphertext — it cannot read the record bundle.
export function emitToWallet(
orgId: string,
walletNumber: string,
event: string,
data: unknown,
): void {
io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data);
sendToWallet(orgId, walletNumber, event, data);
}
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
@@ -285,106 +285,5 @@ export function initRealtime(httpServer: HttpServer): Server {
});
});
// --- Patient wallet relay (/wallet namespace) ----------------------------
// Devices have no clinic session, so this namespace is NOT cookie-gated.
// Instead a device proves control of its wallet keypair: the server issues a
// random challenge, the device signs it with its Ed25519 key, and only then
// may it join its own wallet room. The relay forwards encrypted share
// requests/responses without ever reading the record bundle.
const walletNs = io.of("/wallet");
walletNs.on("connection", (socket: Socket) => {
const challenge = bytesToHex(randomBytes(32));
socket.data.challenge = challenge;
socket.data.walletNumber = null as string | null;
socket.emit("wallet:challenge", { challenge });
socket.on(
"wallet:auth",
(payload: { walletNumber?: string; signature?: string }, ack?: Ack) => {
try {
const walletNumber = String(payload?.walletNumber ?? "");
const signature = String(payload?.signature ?? "");
const publicKey = decodeWalletNumber(walletNumber);
const ok = verifySignature(
publicKey,
signature,
utf8ToBytes(socket.data.challenge as string),
);
if (!ok) {
ack?.({ ok: false });
return;
}
socket.data.walletNumber = walletNumber;
socket.join(walletRoom(walletNumber));
ack?.({ ok: true });
} catch {
ack?.({ ok: false });
}
},
);
// The patient approved/denied a share on their device; the sealed bundle (if
// approved) rides along and is decrypted + verified server-side.
socket.on(
"wallet:share-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
sealed?: string;
signature?: string;
},
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const view = await walletShare.applyShareResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.sealed,
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// The patient revoked a previously shared record; delete it from the clinic.
socket.on(
"wallet:revoke",
async (
payload: { requestId?: string; walletNumber?: string },
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const result = await walletShare.revokeShare(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
);
ack?.({ ok: !!result });
} catch {
ack?.({ ok: false });
}
},
);
});
return io;
}
+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) });
+110 -3
View File
@@ -19,16 +19,33 @@ import { emitToWallet } from "../realtime.js";
import { recordActivity } from "../services/activity.js";
import * as patientService from "../services/patients.js";
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
import { getNetworkEnabled } from "../services/signing.js";
import * as walletShare from "../services/wallet-share.js";
import * as walletUpdates from "../services/wallet-updates.js";
export const patientsWalletRouter = Router();
patientsWalletRouter.use(requireAuth, requireOrg);
// Wallet sharing rides the Temetro Network relay, which a clinic must opt into
// ("Join Temetro Network" in Settings → Signing). Guard the actions that need a
// live relay connection so a disabled clinic gets a clear message, not silence.
async function requireNetwork(orgId: string): Promise<void> {
if (!(await getNetworkEnabled(orgId))) {
throw new HttpError(
409,
"This clinic hasn't joined the Temetro Network. Enable it in Settings → Signing to share with patient wallets.",
);
}
}
// The device-reachable URL the patient's app should connect to (baked into the
// QR). Prefer an explicit PUBLIC_RELAY_URL; otherwise derive it from the request
// host so that opening the web app over the LAN yields a reachable LAN URL.
// QR). Devices connect to the standalone Temetro Network relay — the same relay
// this backend is hubbed to — so RELAY_URL is the canonical answer. The legacy
// PUBLIC_RELAY_URL / cloudflared / request-host fallbacks remain for pre-relay
// self-hosting.
async function resolveRelayUrl(req: Request): Promise<string> {
if (env.RELAY_URL) return env.RELAY_URL;
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
// A cloudflared quick tunnel (`npm run docker:tunnel`). Wait briefly for it to
// become reachable so the QR never carries a not-yet-live URL.
@@ -93,6 +110,7 @@ patientsWalletRouter.post(
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
await requireNetwork(req.organizationId!);
const input = requestSchema.parse(req.body);
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
req.organizationId!,
@@ -105,7 +123,7 @@ patientsWalletRouter.post(
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, req.organizationId!));
emitToWallet(input.walletNumber, "wallet:share-request", {
emitToWallet(req.organizationId!, input.walletNumber, "wallet:share-request", {
requestId: view.id,
clinicName: org?.name ?? "A clinic",
requestedBy: req.user!.name,
@@ -138,6 +156,95 @@ patientsWalletRouter.get(
},
);
// --- Clinic → wallet record-update push ------------------------------------
// Whether a patient is linked to a wallet (drives the "Push update" button).
// Returns the wallet number when linked, 404 otherwise.
patientsWalletRouter.get(
"/link/:fileNumber",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const walletNumber = await walletUpdates.walletNumberForPatient(
req.organizationId!,
req.params.fileNumber as string,
);
if (!walletNumber) throw new HttpError(404, "Not wallet-linked.");
res.json({ walletNumber });
} catch (err) {
next(err);
}
},
);
const pushSchema = z.object({
fileNumber: z.string().trim().min(1),
changes: z.array(z.string().trim().min(1)).min(1).max(50),
});
// Push the current record snapshot to the linked wallet. Seals + signs it,
// stores it pending, and delivers live if the device is connected (it is also
// re-sent on the wallet's next connect). The patient must approve in-app.
patientsWalletRouter.post(
"/push",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
await requireNetwork(req.organizationId!);
const input = pushSchema.parse(req.body);
const row = await walletUpdates.createRecordUpdate(
req.organizationId!,
req.user!.id,
input.fileNumber,
input.changes,
);
const event = await walletUpdates.toEvent(row);
emitToWallet(req.organizationId!, row.walletNumber, "wallet:update-request", event);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Pushed a record update to a patient wallet (#${row.fileNumber})`,
entityType: "patient",
entityId: row.fileNumber,
patientFileNumber: row.fileNumber,
});
res.status(201).json(walletUpdates.viewOf(row));
} catch (err) {
next(err);
}
},
);
// The clinic's recent update pushes (Signing panel + status polling).
patientsWalletRouter.get(
"/updates",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await walletUpdates.listUpdates(req.organizationId!));
} catch (err) {
next(err);
}
},
);
patientsWalletRouter.get(
"/updates/:id",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const view = await walletUpdates.getUpdate(
req.organizationId!,
req.params.id as string,
);
if (!view) throw new HttpError(404, "Update not found.");
res.json(view);
} catch (err) {
next(err);
}
},
);
// Commit the (possibly clinician-edited) draft into a real patient record. The
// temporary-share metadata (origin + auto-delete deadline) is taken from the
// request server-side, so the clinic can't quietly keep a temporary record.
+48
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { z } from "zod";
import {
requireAuth,
@@ -6,6 +7,7 @@ import {
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import { connectOrg, disconnectOrg } from "../services/relay-client.js";
import * as signing from "../services/signing.js";
import * as walletShare from "../services/wallet-share.js";
@@ -48,6 +50,52 @@ signingRouter.post(
},
);
// Whether this clinic has joined the Temetro Network relay. Readable by any
// clinician (the panel shows the toggle state + connection status).
signingRouter.get(
"/network",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json({ enabled: await signing.getNetworkEnabled(req.organizationId!) });
} catch (err) {
next(err);
}
},
);
// Join / leave the Temetro Network — owner/admin only (same gate as key
// rotation). Enabling opens this clinic's relay hub connection; disabling tears
// it down.
const networkSchema = z.object({ enabled: z.boolean() });
signingRouter.put(
"/network",
requirePermission({ organization: ["update"] }),
async (req, res, next) => {
try {
const { enabled } = networkSchema.parse(req.body);
await signing.setNetworkEnabled(req.organizationId!, enabled);
if (enabled) {
await connectOrg(req.organizationId!);
} else {
disconnectOrg(req.organizationId!);
}
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: enabled
? "Joined the Temetro Network"
: "Left the Temetro Network",
entityType: "settings",
});
res.json({ enabled });
} catch (err) {
next(err);
}
},
);
// Recent records shared from patient wallets — feeds the panel's shared-records
// list.
signingRouter.get(
@@ -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" }]
: []),
],
};
}
+200
View File
@@ -0,0 +1,200 @@
// Client connection to the Temetro Network relay
// (github.com/temetro/temetro-network), a standalone Rust service that routes
// encrypted wallet messages between this backend and patient phones.
//
// This backend was previously the device-facing Socket.io server itself (the
// `/wallet` namespace in realtime.ts). Now it is a *client* of the relay's
// `/hub` namespace: it pushes messages to devices via `sendToWallet` and handles
// their responses here, calling the same wallet service functions the old socket
// handlers did. Sealed bundles are decrypted here (we hold the ephemeral key);
// the relay only ever forwards ciphertext.
//
// The relay is **multi-clinic**: each clinic (organization) authenticates to
// `/hub` with its own Ed25519 signing key (a per-clinic identity, not a shared
// password), and the relay routes each device response back only to the clinic
// that originated the request. So this backend keeps **one hub connection per
// network-enabled org**, opened when the org joins the network ("Join Temetro
// Network" in Settings → Signing) and torn down when it leaves.
import { io as connect, type Socket } from "socket.io-client";
import { env } from "../env.js";
import { networkEnabledOrgs, signWithClinicKey } from "./signing.js";
import * as walletShare from "./wallet-share.js";
import * as walletUpdates from "./wallet-updates.js";
// One authenticated hub connection per network-enabled organization.
const hubs = new Map<string, Socket>();
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
// Push an end-to-end-encrypted message to a patient wallet device via the given
// clinic's relay connection (the relay forwards it to the room keyed by wallet
// number). A no-op if the clinic isn't on the network / not connected yet — the
// device replays anything it missed on its next connect (see `wallet:online`).
export function sendToWallet(
orgId: string,
walletNumber: string,
event: string,
data: unknown,
): void {
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
}
// Open (and authenticate) a hub connection for a clinic, if not already open.
// Idempotent — safe to call on startup and again when an org joins the network.
export async function connectOrg(orgId: string): Promise<void> {
if (hubs.has(orgId)) return;
const hub = connect(`${env.RELAY_URL}/hub`, {
transports: ["websocket"],
reconnection: true,
reconnectionDelayMax: 10_000,
});
hubs.set(orgId, hub);
registerHubHandlers(orgId, hub);
}
// Leave the network for a clinic: close and forget its hub connection.
export function disconnectOrg(orgId: string): void {
const hub = hubs.get(orgId);
if (!hub) return;
hub.disconnect();
hubs.delete(orgId);
}
// Open a hub connection for every clinic already on the network. Called once at
// startup; runtime joins/leaves go through connectOrg/disconnectOrg.
export async function initRelayClient(): Promise<void> {
try {
const orgs = await networkEnabledOrgs();
await Promise.all(orgs.map((orgId) => connectOrg(orgId)));
} catch (err) {
console.warn(`Temetro Network: failed to open hub connections: ${(err as Error).message}`);
}
}
// Wire up auth + device-response handlers for one clinic's hub socket.
function registerHubHandlers(orgId: string, hub: Socket): void {
// Authenticate by signing the relay's challenge with this clinic's signing
// key. `clinicId` is that key's public half (hex), which is how the relay
// identifies and routes to this clinic.
hub.on("hub:challenge", async (payload: { challenge?: string }) => {
const challenge = String(payload?.challenge ?? "");
if (!challenge) return;
try {
const { signature, publicKey } = await signWithClinicKey(
orgId,
new TextEncoder().encode(challenge),
);
hub.emit(
"hub:auth",
// `token` is only meaningful for a private relay (optional shared gate);
// an empty value is ignored by an open relay.
{ clinicId: publicKey, signature, token: env.RELAY_TOKEN || undefined },
(ack: { ok?: boolean } | undefined) => {
if (ack?.ok) {
console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`);
} else {
console.warn(`Temetro Network: relay rejected clinic ${orgId}`);
}
},
);
} catch (err) {
console.warn(`Temetro Network: failed to sign relay challenge for ${orgId}: ${(err as Error).message}`);
}
});
hub.on("connect_error", (err) => {
console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}) for ${orgId}: ${err.message}`);
});
// A device authenticated on the relay — flush any record updates it missed
// while offline (scoped to this clinic; the relay only delivers this to
// clinics with pending work for the wallet).
hub.on("wallet:online", async (payload: { walletNumber?: string }) => {
const walletNumber = String(payload?.walletNumber ?? "");
if (!walletNumber) return;
try {
const rows = await walletUpdates.pendingUpdatesForWallet(orgId, walletNumber);
for (const row of rows) {
sendToWallet(orgId, walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
await walletUpdates.markDelivered(row.id);
}
} catch {
/* best-effort */
}
});
// The patient approved/denied a clinic→wallet record update. Verify the
// wallet's signature over the decision and resolve the row.
hub.on(
"wallet:update-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
signature?: string;
},
ack?: Ack,
) => {
try {
const view = await walletUpdates.applyUpdateResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// The patient approved/denied a share; the sealed bundle (if approved) rides
// along and is decrypted + verified here.
hub.on(
"wallet:share-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
sealed?: string;
signature?: string;
},
ack?: Ack,
) => {
try {
const view = await walletShare.applyShareResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.sealed,
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// The patient revoked a previously shared record; delete it from the clinic.
hub.on(
"wallet:revoke",
async (payload: { requestId?: string; walletNumber?: string }, ack?: Ack) => {
try {
const result = await walletShare.revokeShare(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
);
ack?.({ ok: !!result });
} catch {
ack?.({ ok: false });
}
},
);
}
+34
View File
@@ -74,6 +74,40 @@ export async function rotateKey(orgId: string): Promise<SigningKeyView> {
return mintKey(orgId, true);
}
// Whether this clinic has joined the Temetro Network relay. Defaults to `false`
// when the clinic has no signing key yet (it hasn't opted in).
export async function getNetworkEnabled(orgId: string): Promise<boolean> {
const [row] = await db
.select({ networkEnabled: clinicSigningKeys.networkEnabled })
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.organizationId, orgId));
return row?.networkEnabled ?? false;
}
// Org ids of every clinic currently on the network — used at startup to open a
// relay hub connection for each.
export async function networkEnabledOrgs(): Promise<string[]> {
const rows = await db
.select({ organizationId: clinicSigningKeys.organizationId })
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.networkEnabled, true));
return rows.map((r) => r.organizationId);
}
// Join or leave the Temetro Network. Ensures the clinic has a signing key first
// (the relay authenticates with it), then flips the flag. Returns the new state.
export async function setNetworkEnabled(
orgId: string,
enabled: boolean,
): Promise<boolean> {
await getOrCreateKey(orgId);
await db
.update(clinicSigningKeys)
.set({ networkEnabled: enabled })
.where(eq(clinicSigningKeys.organizationId, orgId));
return enabled;
}
// Sign a message with the clinic's signing key (creating one if needed). Returns
// the signature + public key so a verifier can check provenance.
export async function signWithClinicKey(
+239
View File
@@ -0,0 +1,239 @@
import { hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { db } from "../db/index.js";
import { organization } from "../db/schema/auth.js";
import { walletRecordUpdates } from "../db/schema/wallet-updates.js";
import { walletShareRequests } from "../db/schema/wallet-share.js";
import { HttpError } from "../lib/http-error.js";
import {
decodeWalletNumber,
fingerprint,
seal,
verifySignature,
} from "../lib/wallet-crypto.js";
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
import { getPatient } from "./patients.js";
import { signWithClinicKey } from "./signing.js";
type UpdateRow = typeof walletRecordUpdates.$inferSelect;
// The payload the relay pushes to a wallet. `sealed` is the encrypted patient
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
// provenance (TOFU pin) before applying.
export type WalletUpdateEvent = {
requestId: string;
clinicName: string;
sealed: string;
signature: string;
clinicPublicKey: string;
fingerprint: string;
changes: string[];
createdAt: string;
};
// The clinic-facing view (no ciphertext) for the "Sent updates" list + polling.
export type WalletUpdateView = {
id: string;
fileNumber: string;
walletNumber: string;
status: UpdateRow["status"];
changes: string[];
createdAt: string;
deliveredAt: string | null;
resolvedAt: string | null;
};
export function viewOf(row: UpdateRow): WalletUpdateView {
return toView(row);
}
function toView(row: UpdateRow): WalletUpdateView {
return {
id: row.id,
fileNumber: row.fileNumber,
walletNumber: row.walletNumber,
status: row.status,
changes: row.changes,
createdAt: row.createdAt.toISOString(),
deliveredAt: row.deliveredAt ? row.deliveredAt.toISOString() : null,
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
};
}
// The wallet number a patient's record is linked to, or null when it isn't
// wallet-backed. Only *permanent, approved, committed* shares qualify —
// temporary shares auto-delete, so pushing an update to them is meaningless.
export async function walletNumberForPatient(
orgId: string,
fileNumber: string,
): Promise<string | null> {
const [row] = await db
.select({ walletNumber: walletShareRequests.walletNumber })
.from(walletShareRequests)
.where(
and(
eq(walletShareRequests.organizationId, orgId),
eq(walletShareRequests.committedFileNumber, fileNumber),
eq(walletShareRequests.status, "approved"),
eq(walletShareRequests.shareMode, "permanent"),
isNotNull(walletShareRequests.walletNumber),
),
)
.limit(1);
return row?.walletNumber ?? null;
}
// Compose, seal and sign a record-update push, and store it as pending. Loads
// the current patient snapshot, seals it to the wallet's derived X25519 key, and
// signs the plaintext bundle with the clinic's Ed25519 key. Returns the row.
export async function createRecordUpdate(
orgId: string,
userId: string,
fileNumber: string,
changes: string[],
): Promise<UpdateRow> {
const walletNumber = await walletNumberForPatient(orgId, fileNumber);
if (!walletNumber) {
throw new HttpError(409, "This patient is not linked to a wallet.");
}
const patient = await getPatient(orgId, fileNumber);
if (!patient) throw new HttpError(404, "Patient not found.");
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient`.
const bundle = utf8ToBytes(JSON.stringify({ patient, changes }));
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
const sealed = seal(x25519Hex, bundle);
const [row] = await db
.insert(walletRecordUpdates)
.values({
organizationId: orgId,
createdBy: userId,
fileNumber,
walletNumber,
payloadSealed: sealed,
clinicSignature: signature,
clinicPublicKey: publicKey,
clinicFingerprint: fingerprint(hexToBytes(publicKey)),
changes,
})
.returning();
return row!;
}
// Build the wire event for a stored update row (joins the clinic name).
export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, row.organizationId));
return {
requestId: row.id,
clinicName: org?.name ?? "A clinic",
sealed: row.payloadSealed,
signature: row.clinicSignature,
clinicPublicKey: row.clinicPublicKey,
fingerprint: row.clinicFingerprint,
changes: row.changes,
createdAt: row.createdAt.toISOString(),
};
}
// Every unresolved update for a wallet — re-sent on each authenticated connect
// so an offline device eventually receives what it missed.
// Pending updates a wallet missed, scoped to one clinic — the relay delivers a
// `wallet:online` over that clinic's own hub connection, so a clinic only ever
// re-sends its *own* updates (never another clinic's).
export async function pendingUpdatesForWallet(
orgId: string,
walletNumber: string,
): Promise<UpdateRow[]> {
return db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.organizationId, orgId),
eq(walletRecordUpdates.walletNumber, walletNumber),
isNull(walletRecordUpdates.resolvedAt),
),
)
.orderBy(walletRecordUpdates.createdAt);
}
// Mark a pending update delivered (best-effort; only advances from pending).
export async function markDelivered(id: string): Promise<void> {
await db
.update(walletRecordUpdates)
.set({ status: "delivered", deliveredAt: new Date() })
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.status, "pending"),
),
);
}
// Apply the patient's decision relayed back from the wallet. Verifies the
// wallet's Ed25519 signature over `${decision}:${requestId}` (provenance) before
// resolving. Returns the resolved view, or null when unknown/already resolved.
export async function applyUpdateResponse(
requestId: string,
walletNumber: string,
decision: "approved" | "denied",
signatureHex?: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.id, requestId));
if (!row || row.resolvedAt) return null;
if (row.walletNumber !== walletNumber.trim()) return null;
if (!signatureHex) return null;
const publicKey = decodeWalletNumber(walletNumber);
const message = utf8ToBytes(`${decision}:${requestId}`);
if (!verifySignature(publicKey, signatureHex, message)) {
throw new HttpError(400, "Response signature did not match the wallet.");
}
const [updated] = await db
.update(walletRecordUpdates)
.set({ status: decision, resolvedAt: new Date() })
.where(eq(walletRecordUpdates.id, requestId))
.returning();
return updated ? toView(updated) : null;
}
// Recent update pushes for the clinic (Signing panel "Sent updates" list).
export async function listUpdates(
orgId: string,
limit = 30,
): Promise<WalletUpdateView[]> {
const rows = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.organizationId, orgId))
.orderBy(desc(walletRecordUpdates.createdAt))
.limit(limit);
return rows.map(toView);
}
export async function getUpdate(
orgId: string,
id: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.organizationId, orgId),
),
);
return row ? toView(row) : null;
}
+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 };
}
}
}
@@ -9,6 +9,7 @@ import { RecordGraph } from "@/components/graph/record-graph";
import { PatientDetail } from "@/components/patients/patient-detail";
import { ScribeDialog } from "@/components/patients/scribe-dialog";
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
import { WalletPushDialog } from "@/components/patients/wallet-push-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Dialog,
@@ -32,6 +33,7 @@ import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
import { useAiAccess } from "@/lib/ai-policy";
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
import { getWalletLink } from "@/lib/wallet-updates";
type Status = "loading" | "ready" | "not-found";
@@ -86,6 +88,9 @@ export function PatientDetailSheet({
const [status, setStatus] = useState<Status>("loading");
const [editOpen, setEditOpen] = useState(false);
const [scribeOpen, setScribeOpen] = useState(false);
const [walletPushOpen, setWalletPushOpen] = useState(false);
// Set once we confirm this patient is linked to a wallet (permanent share).
const [walletLinked, setWalletLinked] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
// Graph popped out of the sheet into its own dialog (the sheet closes first).
@@ -131,6 +136,21 @@ export function PatientDetailSheet({
};
}, [open, fileNumber]);
// Whether this patient is wallet-linked (drives the "Push update" button).
// Separate from the main load so it re-checks once the role resolves without
// refetching the record. Only clinicians can push.
useEffect(() => {
setWalletLinked(false);
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
let active = true;
getWalletLink(fileNumber)
.then(() => active && setWalletLinked(true))
.catch(() => {});
return () => {
active = false;
};
}, [open, fileNumber, role]);
const remove = async () => {
if (!patient) return;
try {
@@ -179,6 +199,9 @@ export function PatientDetailSheet({
setEditOpen(true);
}}
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
onWalletPush={
walletLinked ? () => setWalletPushOpen(true) : undefined
}
onOpenGraph={() => {
onOpenChange(false);
setGraphOpen(true);
@@ -214,6 +237,14 @@ export function PatientDetailSheet({
/>
)}
{patient && (
<WalletPushDialog
onOpenChange={setWalletPushOpen}
open={walletPushOpen}
patient={patient}
/>
)}
{patient && (
<TransferPatientDialog
onOpenChange={setTransferOpen}
@@ -6,6 +6,7 @@ import {
Mic,
Network,
Pencil,
Send,
Trash2,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
@@ -181,6 +182,7 @@ export function PatientDetail({
patient,
onEdit,
onScribe,
onWalletPush,
onTransfer,
onDelete,
onOpenGraph,
@@ -192,6 +194,8 @@ export function PatientDetail({
onEdit?: () => void;
// Opens the ambient AI visit scribe (record/transcribe → draft note).
onScribe?: () => void;
// Pushes the record to the patient's wallet (only when wallet-linked).
onWalletPush?: () => void;
onTransfer?: () => void;
onDelete?: () => void;
// Pops the record graph out into its own dialog (closing this sheet).
@@ -309,6 +313,17 @@ export function PatientDetail({
{t("patientCard.edit")}
</Button>
)}
{onWalletPush && (
<Button
onClick={onWalletPush}
size="sm"
type="button"
variant="outline"
>
<Send className="size-4" />
{t("walletPush.action")}
</Button>
)}
{onDelete && (
<Button
aria-label={t("patients.delete.action")}
@@ -0,0 +1,242 @@
"use client";
import { Check, Loader2, Send, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { ApiError } from "@/lib/api-client";
import type { Patient } from "@/lib/patients";
import { cn } from "@/lib/utils";
import {
getWalletUpdate,
pushWalletUpdate,
type WalletUpdate,
} from "@/lib/wallet-updates";
// The record sections a clinician can flag as changed. The labels double as the
// human-readable change summary the patient sees when approving.
const SECTION_KEYS = [
"demographics",
"problems",
"medications",
"allergies",
"labs",
"vitals",
"visits",
] as const;
type Phase = "compose" | "sent";
// Push the current record to a wallet-linked patient's app. The patient must
// approve it on their phone before their on-device record is replaced.
export function WalletPushDialog({
patient,
open,
onOpenChange,
}: {
patient: Patient;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("compose");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [note, setNote] = useState("");
const [update, setUpdate] = useState<WalletUpdate | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const reset = () => {
setPhase("compose");
setSelected(new Set());
setNote("");
setUpdate(null);
setBusy(false);
setError(null);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
// Poll the update's status until the patient approves/denies (or the dialog
// closes). Live pushes usually resolve within seconds.
useEffect(() => {
if (phase !== "sent" || !update || update.resolvedAt) return;
let active = true;
const timer = setInterval(async () => {
try {
const fresh = await getWalletUpdate(update.id);
if (!active) return;
setUpdate(fresh);
if (fresh.resolvedAt) clearInterval(timer);
} catch {
/* keep polling */
}
}, 3000);
return () => {
active = false;
clearInterval(timer);
};
}, [phase, update]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const push = async () => {
setBusy(true);
setError(null);
try {
const changes = [
...[...selected].map((k) => t(`walletPush.sections.${k}`)),
...(note.trim() ? [note.trim()] : []),
];
const created = await pushWalletUpdate({
fileNumber: patient.fileNumber,
changes,
});
setUpdate(created);
setPhase("sent");
} catch (err) {
setError(
err instanceof ApiError ? err.message : t("walletPush.errors.generic"),
);
} finally {
setBusy(false);
}
};
const canPush = selected.size > 0 || note.trim().length > 0;
const status = update?.status ?? "pending";
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="size-4 text-primary" />
{t("walletPush.title")}
</DialogTitle>
<DialogDescription>
{t("walletPush.subtitle", { name: patient.name })}
</DialogDescription>
</DialogHeader>
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
{phase === "compose" ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>{t("walletPush.sectionsLabel")}</Label>
<div className="flex flex-wrap gap-2">
{SECTION_KEYS.map((key) => {
const on = selected.has(key);
return (
<button
className={cn(
"rounded-full border px-3 py-1 text-sm transition-colors",
on
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-accent",
)}
key={key}
onClick={() => toggle(key)}
type="button"
>
{t(`walletPush.sections.${key}`)}
</button>
);
})}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wallet-push-note">
{t("walletPush.noteLabel")}
</Label>
<Textarea
className="min-h-20"
id="wallet-push-note"
onChange={(e) => setNote(e.target.value)}
placeholder={t("walletPush.notePlaceholder")}
value={note}
/>
</div>
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
{t("walletPush.notice")}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-4 py-6 text-center">
{status === "approved" ? (
<div className="flex size-12 items-center justify-center rounded-full bg-success/15 text-success">
<Check className="size-6" />
</div>
) : status === "denied" ? (
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/15 text-destructive">
<X className="size-6" />
</div>
) : (
<Loader2 className="size-8 animate-spin text-muted-foreground" />
)}
<div className="flex flex-col gap-1">
<p className="font-medium text-foreground text-sm">
{t(`walletPush.status.${status}.title`)}
</p>
<p className="text-muted-foreground text-sm">
{t(`walletPush.status.${status}.body`)}
</p>
</div>
</div>
)}
{error && (
<p className="mt-3 text-destructive text-sm" role="alert">
{error}
</p>
)}
</DialogPanel>
<DialogFooter>
{phase === "compose" ? (
<>
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
{t("walletPush.cancel")}
</Button>
<Button disabled={busy || !canPush} onClick={push} type="button">
{busy && <Spinner className="size-4" />}
{t("walletPush.send")}
</Button>
</>
) : (
<Button onClick={() => handleOpenChange(false)} type="button">
{t("walletPush.done")}
</Button>
)}
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
CopyField,
SettingsCard,
@@ -13,12 +14,15 @@ import {
whiteButton,
} from "@/components/settings/settings-parts";
import {
getNetworkEnabled,
getSigningKey,
listSignedRecords,
rotateSigningKey,
setNetworkEnabled,
type SharedRecord,
type SigningKey,
} from "@/lib/signing";
import { listWalletUpdates, type WalletUpdate } from "@/lib/wallet-updates";
import { notify } from "@/lib/toast";
function formatDate(iso: string): string {
@@ -33,17 +37,27 @@ export function SigningPanel() {
const { t } = useTranslation();
const [key, setKey] = useState<SigningKey | null>(null);
const [records, setRecords] = useState<SharedRecord[]>([]);
const [updates, setUpdates] = useState<WalletUpdate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rotating, setRotating] = useState(false);
const [networkOn, setNetworkOn] = useState(false);
const [networkSaving, setNetworkSaving] = useState(false);
useEffect(() => {
let active = true;
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
.then(([k, r]) => {
Promise.all([
getSigningKey(),
listSignedRecords().catch(() => []),
listWalletUpdates().catch(() => []),
getNetworkEnabled().catch(() => false),
])
.then(([k, r, u, n]) => {
if (!active) return;
setKey(k);
setRecords(r);
setUpdates(u);
setNetworkOn(n);
setError(null);
})
.catch(() => {
@@ -76,6 +90,32 @@ export function SigningPanel() {
}
};
const toggleNetwork = async (next: boolean) => {
setNetworkSaving(true);
// Optimistic — revert on failure.
setNetworkOn(next);
try {
const saved = await setNetworkEnabled(next);
setNetworkOn(saved);
notify.success(
next
? t("settings.network.joinedTitle")
: t("settings.network.leftTitle"),
next
? t("settings.network.joinedBody")
: t("settings.network.leftBody"),
);
} catch {
setNetworkOn(!next);
notify.error(
t("settings.network.errorTitle"),
t("settings.network.error"),
);
} finally {
setNetworkSaving(false);
}
};
const recordStatusLabel = (status: SharedRecord["status"]): string =>
t(
`settings.signing.records.status${
@@ -130,6 +170,41 @@ export function SigningPanel() {
</div>
</SettingsCard>
<SettingsSection
description={t("settings.network.description")}
title={t("settings.network.title")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-5">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{t("settings.network.toggleLabel")}
</p>
<Badge
className={cn(
networkOn
? "bg-emerald-500/15 text-emerald-400"
: "bg-muted text-muted-foreground",
)}
>
{networkOn
? t("settings.network.statusConnected")
: t("settings.network.statusOff")}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{t("settings.network.toggleDesc")}
</p>
</div>
<Switch
aria-label={t("settings.network.toggleLabel")}
checked={networkOn}
disabled={loading || networkSaving}
onCheckedChange={toggleNetwork}
/>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.signing.identityDescription")}
title={t("settings.signing.identityTitle")}
@@ -223,6 +298,40 @@ export function SigningPanel() {
</SettingsCard>
)}
</SettingsSection>
<SettingsSection
description={t("walletUpdatesList.description")}
title={t("walletUpdatesList.title")}
>
{updates.length === 0 ? (
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("walletUpdatesList.none")}
</p>
</SettingsCard>
) : (
<SettingsCard className="divide-y divide-border">
{updates.map((update) => (
<div
className="flex items-center justify-between gap-3 px-4 py-3.5"
key={update.id}
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm">
{update.changes.join(" · ") || `#${update.fileNumber}`}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
#{update.fileNumber} · {formatDate(update.createdAt)}
</p>
</div>
<Badge variant="secondary">
{t(`walletPush.status.${update.status}.title`)}
</Badge>
</div>
))}
</SettingsCard>
)}
</SettingsSection>
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { CheckCircle2, CircleDashed, XCircle } from "lucide-react";
import { CheckCircle2, CircleDashed, Copy, KeyRound, Trash2, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -13,10 +13,15 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { API_BASE_URL } from "@/lib/api-client";
import {
createFhirKey,
type FhirApiKey,
type IntegrationConfig,
type IntegrationType,
listFhirKeys,
listIntegrations,
revokeFhirKey,
saveIntegration,
testIntegration,
} from "@/lib/integrations";
@@ -191,6 +196,216 @@ function IntegrationCard({
);
}
// The read-only FHIR R4 server. Unlike the integration cards above (which make
// temetro a FHIR *client*), this exposes temetro's own records over `/fhir` to
// external systems, authenticated with per-clinic API keys. Owner/admin only:
// the component self-gates by hiding when the keys fetch is forbidden.
function FhirServerCard() {
const { t } = useTranslation();
const [keys, setKeys] = useState<FhirApiKey[] | null>(null);
const [allowed, setAllowed] = useState(true);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [freshSecret, setFreshSecret] = useState<string | null>(null);
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
const baseUrl = `${API_BASE_URL}/fhir`;
useEffect(() => {
let active = true;
listFhirKeys()
.then((rows) => active && setKeys(rows))
.catch(() => {
if (active) {
setAllowed(false);
setKeys([]);
}
});
return () => {
active = false;
};
}, []);
const create = async () => {
if (!name.trim() || creating) return;
setCreating(true);
try {
const created = await createFhirKey(name.trim());
setFreshSecret(created.secret);
setKeys((prev) => [created, ...(prev ?? [])]);
setName("");
} catch {
notify.error(
t("settings.integrations.fhirServer.createFailed"),
t("settings.integrations.fhirServer.createFailedBody"),
);
} finally {
setCreating(false);
}
};
const revoke = async (id: string) => {
try {
await revokeFhirKey(id);
setKeys((prev) =>
(prev ?? []).map((k) => (k.id === id ? { ...k, revoked: true } : k)),
);
} catch {
notify.error(
t("settings.integrations.fhirServer.revokeFailed"),
t("settings.integrations.fhirServer.revokeFailedBody"),
);
} finally {
setConfirmRevoke(null);
}
};
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
notify.success(label, "");
} catch {
// Clipboard blocked — no-op; the value is visible for manual copy.
}
};
if (!allowed) return null;
return (
<SettingsSection
description={t("settings.integrations.fhirServer.description")}
title={t("settings.integrations.fhirServer.title")}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.fhirServer.baseUrl")}</FieldLabel>
<div className="flex items-center gap-2">
<Input readOnly value={baseUrl} />
<Button
onClick={() =>
copy(baseUrl, t("settings.integrations.fhirServer.copiedUrl"))
}
size="icon"
variant="outline"
>
<Copy className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.baseUrlHint")}
</p>
</div>
{freshSecret ? (
<div className="space-y-2 rounded-2xl border border-primary/40 bg-primary/5 p-4">
<p className="text-sm font-medium">
{t("settings.integrations.fhirServer.secretTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.secretHint")}
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg bg-muted px-3 py-2 font-mono text-xs">
{freshSecret}
</code>
<Button
onClick={() =>
copy(
freshSecret,
t("settings.integrations.fhirServer.copiedSecret"),
)
}
size="icon"
variant="outline"
>
<Copy className="size-4" />
</Button>
</div>
<Button onClick={() => setFreshSecret(null)} size="sm" variant="ghost">
{t("settings.integrations.fhirServer.dismissSecret")}
</Button>
</div>
) : null}
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.fhirServer.newKey")}</FieldLabel>
<div className="flex items-center gap-2">
<Input
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
placeholder={t("settings.integrations.fhirServer.newKeyPlaceholder")}
value={name}
/>
<Button disabled={creating || !name.trim()} onClick={create} size="sm">
<KeyRound className="size-4" />
{creating
? t("settings.integrations.fhirServer.creating")
: t("settings.integrations.fhirServer.create")}
</Button>
</div>
</div>
{keys && keys.length > 0 ? (
<ul className="divide-y rounded-2xl border">
{keys.map((k) => (
<li
key={k.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{k.name}</p>
<p className="text-xs text-muted-foreground">
{k.lastUsedAt
? t("settings.integrations.fhirServer.lastUsed", {
when: new Date(k.lastUsedAt).toLocaleString(),
})
: t("settings.integrations.fhirServer.neverUsed")}
</p>
</div>
{k.revoked ? (
<Badge variant="outline">
{t("settings.integrations.fhirServer.revoked")}
</Badge>
) : confirmRevoke === k.id ? (
<div className="flex items-center gap-2">
<Button
onClick={() => revoke(k.id)}
size="sm"
variant="destructive"
>
{t("settings.integrations.fhirServer.confirmRevoke")}
</Button>
<Button
onClick={() => setConfirmRevoke(null)}
size="sm"
variant="ghost"
>
{t("settings.integrations.fhirServer.cancel")}
</Button>
</div>
) : (
<Button
onClick={() => setConfirmRevoke(k.id)}
size="sm"
variant="outline"
>
<Trash2 className="size-4" />
{t("settings.integrations.fhirServer.revoke")}
</Button>
)}
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.noKeys")}
</p>
)}
</SettingsCard>
</SettingsSection>
);
}
export function IntegrationsPanel() {
const { t } = useTranslation();
const [configs, setConfigs] = useState<IntegrationConfig[] | null>(null);
@@ -231,6 +446,7 @@ export function IntegrationsPanel() {
} satisfies IntegrationConfig);
return <IntegrationCard initial={initial} key={type} type={type} />;
})}
<FhirServerCard />
</div>
);
}
@@ -43,6 +43,53 @@
"generic": "حدث خطأ ما. يُرجى المحاولة مرة أخرى."
}
},
"walletUpdatesList": {
"title": "التحديثات المُرسَلة إلى المحافظ",
"description": "تحديثات السجلات التي أرسلتها إلى محافظ المرضى، وما إذا كان المريض قد وافق عليها.",
"none": "لم ترسل أي تحديثات إلى المحافظ بعد."
},
"walletPush": {
"action": "إرسال إلى المحفظة",
"title": "إرسال التحديث إلى المحفظة",
"subtitle": "أرسل سجل {{name}} المُحدَّث إلى تطبيق المحفظة الخاص به. يجب أن يوافق عليه على هاتفه قبل أن يحل محل النسخة الموجودة على جهازه.",
"sectionsLabel": "ما الذي تغيّر",
"sections": {
"demographics": "البيانات الديموغرافية",
"problems": "المشكلات",
"medications": "الأدوية",
"allergies": "الحساسية",
"labs": "التحاليل",
"vitals": "العلامات الحيوية",
"visits": "الزيارات"
},
"noteLabel": "ملاحظة (اختياري)",
"notePlaceholder": "ملاحظة قصيرة عن هذا التحديث…",
"notice": "يُرسِل هذا السجل الحالي كاملاً، موقَّعًا بمفتاح عيادتك. ولا يحل محل النسخة الموجودة على جهاز المريض إلا بعد موافقته.",
"cancel": "إلغاء",
"send": "إرسال التحديث",
"done": "تم",
"errors": {
"generic": "تعذّر إرسال التحديث. يُرجى المحاولة مرة أخرى."
},
"status": {
"pending": {
"title": "بانتظار المريض",
"body": "تم إرسال التحديث. سيصل إلى هاتف المريض الآن، أو في المرة التالية التي يفتح فيها التطبيق."
},
"delivered": {
"title": "تم التسليم إلى الهاتف",
"body": "استلم المريض التحديث ويلزمه الموافقة عليه على جهازه."
},
"approved": {
"title": "وافق المريض",
"body": "وافق المريض على التحديث، وسجلّه على الجهاز محدَّث الآن."
},
"denied": {
"title": "رفض المريض",
"body": "رفض المريض هذا التحديث، لذا بقي سجلّه على الجهاز دون تغيير."
}
}
},
"common": {
"appName": "temetro",
"email": "البريد الإلكتروني",
@@ -1645,6 +1692,32 @@
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON مع رمز مركز المقاصة ومعرّفات المرسل/المستقبل."
},
"fhirServer": {
"title": "خادم FHIR (مشاركة سجلاتك)",
"description": "اعرض سجلات هذه العيادة للأنظمة الخارجية كخادم FHIR R4 للقراءة فقط، مع المصادقة عبر مفاتيح API خاصة بكل عيادة.",
"baseUrl": "عنوان URL الأساسي",
"baseUrlHint": "وجّه عميل FHIR إلى هذا العنوان. يقدّم Patient وObservation وCondition وAllergyIntolerance وMedicationRequest وEncounter وAppointment.",
"copiedUrl": "تم نسخ العنوان الأساسي",
"newKey": "إنشاء مفتاح API",
"newKeyPlaceholder": "اسم المفتاح (مثل مستودع الأبحاث)",
"create": "إنشاء",
"creating": "جارٍ الإنشاء…",
"createFailed": "تعذّر إنشاء المفتاح",
"createFailedBody": "يرجى المحاولة مرة أخرى.",
"secretTitle": "انسخ مفتاح API الآن",
"secretHint": "هذه هي المرة الوحيدة التي يظهر فيها السر. احفظه في مكان آمن — لا يمكن استرجاعه لاحقًا.",
"copiedSecret": "تم نسخ مفتاح API",
"dismissSecret": "تم",
"noKeys": "لا توجد مفاتيح API بعد. أنشئ واحدًا للسماح لعميل FHIR بالاتصال.",
"lastUsed": "آخر استخدام {{when}}",
"neverUsed": "لم يُستخدم قط",
"revoke": "إبطال",
"confirmRevoke": "إبطال المفتاح",
"revoked": "تم الإبطال",
"revokeFailed": "تعذّر إبطال المفتاح",
"revokeFailedBody": "يرجى المحاولة مرة أخرى.",
"cancel": "إلغاء"
}
},
"empty": "لا شيء هنا بعد.",
@@ -1923,6 +1996,20 @@
"errorTitle": "تعذّر إضافة العضو"
}
},
"network": {
"title": "شبكة Temetro",
"description": "يتيح مُرحّل شبكة Temetro لتطبيقات محفظة المرضى الاتصال بعيادتك لمشاركة السجلات والموافقة عليها — مشفّرة من طرف إلى طرف، مع تعريف عيادتك بمفتاح التوقيع الخاص بها.",
"toggleLabel": "الانضمام إلى شبكة Temetro",
"toggleDesc": "عند التفعيل، تتصل هذه العيادة بالمُرحّل لاستيراد السجلات من تطبيقات المرضى وإرسال التحديثات إلى محافظهم.",
"statusConnected": "متصل",
"statusOff": "معطّل",
"joinedTitle": "تم الانضمام إلى شبكة Temetro",
"joinedBody": "يمكن الآن لتطبيقات محفظة المرضى الاتصال بهذه العيادة.",
"leftTitle": "تم مغادرة شبكة Temetro",
"leftBody": "لم تعد هذه العيادة متصلة بالمُرحّل.",
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
"error": "يرجى المحاولة مرة أخرى."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
"active": "نشط",
@@ -43,6 +43,53 @@
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
}
},
"walletUpdatesList": {
"title": "An Wallets gesendete Updates",
"description": "Aktenupdates, die Sie an Patienten-Wallets gesendet haben, und ob der Patient sie bestätigt hat.",
"none": "Sie haben noch keine Wallet-Updates gesendet."
},
"walletPush": {
"action": "An Wallet senden",
"title": "Update an Wallet senden",
"subtitle": "Senden Sie {{name}}s aktualisierte Akte an die Wallet-App. Der Patient muss sie auf dem Telefon bestätigen, bevor sie die Kopie auf dem Gerät ersetzt.",
"sectionsLabel": "Was sich geändert hat",
"sections": {
"demographics": "Stammdaten",
"problems": "Probleme",
"medications": "Medikamente",
"allergies": "Allergien",
"labs": "Labor",
"vitals": "Vitalwerte",
"visits": "Besuche"
},
"noteLabel": "Notiz (optional)",
"notePlaceholder": "Eine kurze Notiz zu diesem Update…",
"notice": "Dies sendet die vollständige aktuelle Akte, signiert mit Ihrem Klinikschlüssel. Sie ersetzt die Kopie auf dem Gerät des Patienten erst nach dessen Zustimmung.",
"cancel": "Abbrechen",
"send": "Update senden",
"done": "Fertig",
"errors": {
"generic": "Update konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
},
"status": {
"pending": {
"title": "Warten auf den Patienten",
"body": "Das Update wurde gesendet. Es erreicht das Telefon des Patienten jetzt oder beim nächsten Öffnen der App."
},
"delivered": {
"title": "An das Telefon zugestellt",
"body": "Der Patient hat das Update erhalten und muss es auf seinem Gerät bestätigen."
},
"approved": {
"title": "Vom Patienten bestätigt",
"body": "Der Patient hat das Update bestätigt; seine Akte auf dem Gerät ist nun aktuell."
},
"denied": {
"title": "Vom Patienten abgelehnt",
"body": "Der Patient hat dieses Update abgelehnt; seine Akte auf dem Gerät bleibt unverändert."
}
}
},
"common": {
"appName": "temetro",
"email": "E-Mail",
@@ -1625,6 +1672,32 @@
"endpointPlaceholder": "https://ihre-verrechnungsstelle.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON mit Ihrem Token und Absender-/Empfänger-IDs der Verrechnungsstelle."
},
"fhirServer": {
"title": "FHIR-Server (Datensätze teilen)",
"description": "Stellen Sie die Datensätze dieser Praxis externen Systemen als schreibgeschützten FHIR-R4-Server bereit, authentifiziert über praxiseigene API-Schlüssel.",
"baseUrl": "Basis-URL",
"baseUrlHint": "Richten Sie einen FHIR-Client auf diese URL. Sie liefert Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter und Appointment.",
"copiedUrl": "Basis-URL kopiert",
"newKey": "API-Schlüssel erstellen",
"newKeyPlaceholder": "Schlüsselname (z. B. Forschungslager)",
"create": "Erstellen",
"creating": "Wird erstellt…",
"createFailed": "Schlüssel konnte nicht erstellt werden",
"createFailedBody": "Bitte erneut versuchen.",
"secretTitle": "Kopieren Sie Ihren API-Schlüssel jetzt",
"secretHint": "Das Geheimnis wird nur dieses eine Mal angezeigt. Bewahren Sie es sicher auf — es kann später nicht abgerufen werden.",
"copiedSecret": "API-Schlüssel kopiert",
"dismissSecret": "Fertig",
"noKeys": "Noch keine API-Schlüssel. Erstellen Sie einen, damit sich ein FHIR-Client verbinden kann.",
"lastUsed": "Zuletzt verwendet {{when}}",
"neverUsed": "Nie verwendet",
"revoke": "Widerrufen",
"confirmRevoke": "Schlüssel widerrufen",
"revoked": "Widerrufen",
"revokeFailed": "Schlüssel konnte nicht widerrufen werden",
"revokeFailedBody": "Bitte erneut versuchen.",
"cancel": "Abbrechen"
}
},
"empty": "Hier gibt es noch nichts.",
@@ -1903,6 +1976,20 @@
"errorTitle": "Mitglied konnte nicht hinzugefügt werden"
}
},
"network": {
"title": "Temetro-Netzwerk",
"description": "Das Temetro-Netzwerk-Relay ermöglicht es den Wallet-Apps der Patienten, sich mit Ihrer Praxis zu verbinden, um Akten zu teilen und zu genehmigen — Ende-zu-Ende-verschlüsselt, wobei Ihre Praxis über ihren Signierschlüssel identifiziert wird.",
"toggleLabel": "Temetro-Netzwerk beitreten",
"toggleDesc": "Wenn aktiviert, verbindet sich diese Praxis mit dem Relay, sodass Sie Akten aus Patienten-Apps importieren und Aktualisierungen an deren Wallets senden können.",
"statusConnected": "Verbunden",
"statusOff": "Aus",
"joinedTitle": "Temetro-Netzwerk beigetreten",
"joinedBody": "Wallet-Apps der Patienten können sich jetzt mit dieser Praxis verbinden.",
"leftTitle": "Temetro-Netzwerk verlassen",
"leftBody": "Diese Praxis ist nicht mehr mit dem Relay verbunden.",
"errorTitle": "Netzwerkzugriff konnte nicht aktualisiert werden",
"error": "Bitte versuchen Sie es erneut."
},
"signing": {
"keyTitle": "Signierschlüssel",
"active": "Aktiv",
@@ -43,6 +43,53 @@
"generic": "Something went wrong. Please try again."
}
},
"walletUpdatesList": {
"title": "Updates sent to wallets",
"description": "Record updates you've pushed to patient wallets, and whether the patient has approved them.",
"none": "You haven't pushed any wallet updates yet."
},
"walletPush": {
"action": "Push to wallet",
"title": "Push update to wallet",
"subtitle": "Send {{name}}'s updated record to their wallet app. They must approve it on their phone before it replaces the copy on their device.",
"sectionsLabel": "What changed",
"sections": {
"demographics": "Demographics",
"problems": "Problems",
"medications": "Medications",
"allergies": "Allergies",
"labs": "Labs",
"vitals": "Vitals",
"visits": "Visits"
},
"noteLabel": "Note (optional)",
"notePlaceholder": "A short note about this update…",
"notice": "This sends the full current record, signed with your clinic key. It replaces the patient's on-device copy only after they approve it.",
"cancel": "Cancel",
"send": "Send update",
"done": "Done",
"errors": {
"generic": "Couldn't send the update. Please try again."
},
"status": {
"pending": {
"title": "Waiting for the patient",
"body": "The update was sent. It will arrive on the patient's phone now, or the next time they open the app."
},
"delivered": {
"title": "Delivered to the phone",
"body": "The patient has received the update and needs to approve it on their device."
},
"approved": {
"title": "Approved by the patient",
"body": "The patient approved the update and their on-device record is now up to date."
},
"denied": {
"title": "Declined by the patient",
"body": "The patient declined this update, so their on-device record is unchanged."
}
}
},
"common": {
"appName": "temetro",
"email": "Email",
@@ -1625,6 +1672,32 @@
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids."
},
"fhirServer": {
"title": "FHIR server (share your records)",
"description": "Expose this clinic's records to external systems as a read-only FHIR R4 server, authenticated with per-clinic API keys.",
"baseUrl": "Base URL",
"baseUrlHint": "Point a FHIR client at this URL. It serves Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter and Appointment.",
"copiedUrl": "Base URL copied",
"newKey": "Create an API key",
"newKeyPlaceholder": "Key name (e.g. Research warehouse)",
"create": "Create",
"creating": "Creating…",
"createFailed": "Couldn't create the key",
"createFailedBody": "Please try again.",
"secretTitle": "Copy your API key now",
"secretHint": "This is the only time the secret is shown. Store it somewhere safe — it can't be retrieved later.",
"copiedSecret": "API key copied",
"dismissSecret": "Done",
"noKeys": "No API keys yet. Create one to let a FHIR client connect.",
"lastUsed": "Last used {{when}}",
"neverUsed": "Never used",
"revoke": "Revoke",
"confirmRevoke": "Revoke key",
"revoked": "Revoked",
"revokeFailed": "Couldn't revoke the key",
"revokeFailedBody": "Please try again.",
"cancel": "Cancel"
}
},
"empty": "Nothing here yet.",
@@ -1903,6 +1976,20 @@
"errorTitle": "Could not add member"
}
},
"network": {
"title": "Temetro Network",
"description": "The Temetro Network relay lets patients' wallet apps connect to your clinic to share and approve records — end-to-end encrypted, with your clinic identified by its signing key.",
"toggleLabel": "Join Temetro Network",
"toggleDesc": "When on, this clinic connects to the relay so you can import records from patient apps and push updates to their wallets.",
"statusConnected": "Connected",
"statusOff": "Off",
"joinedTitle": "Joined the Temetro Network",
"joinedBody": "Patient wallet apps can now connect to this clinic.",
"leftTitle": "Left the Temetro Network",
"leftBody": "This clinic is no longer connected to the relay.",
"errorTitle": "Couldn't update network access",
"error": "Please try again."
},
"signing": {
"keyTitle": "Signing key",
"active": "Active",
@@ -43,6 +43,53 @@
"generic": "Une erreur s'est produite. Veuillez réessayer."
}
},
"walletUpdatesList": {
"title": "Mises à jour envoyées aux portefeuilles",
"description": "Les mises à jour de dossiers que vous avez envoyées aux portefeuilles des patients, et si le patient les a approuvées.",
"none": "Vous n'avez encore envoyé aucune mise à jour de portefeuille."
},
"walletPush": {
"action": "Envoyer au portefeuille",
"title": "Envoyer la mise à jour au portefeuille",
"subtitle": "Envoyez le dossier mis à jour de {{name}} vers son application portefeuille. Le patient doit l'approuver sur son téléphone avant qu'elle ne remplace la copie sur son appareil.",
"sectionsLabel": "Ce qui a changé",
"sections": {
"demographics": "Données démographiques",
"problems": "Problèmes",
"medications": "Médicaments",
"allergies": "Allergies",
"labs": "Analyses",
"vitals": "Signes vitaux",
"visits": "Visites"
},
"noteLabel": "Note (facultatif)",
"notePlaceholder": "Une courte note sur cette mise à jour…",
"notice": "Ceci envoie le dossier actuel complet, signé avec la clé de votre clinique. Il ne remplace la copie sur l'appareil du patient qu'après son approbation.",
"cancel": "Annuler",
"send": "Envoyer la mise à jour",
"done": "Terminé",
"errors": {
"generic": "Impossible d'envoyer la mise à jour. Veuillez réessayer."
},
"status": {
"pending": {
"title": "En attente du patient",
"body": "La mise à jour a été envoyée. Elle arrivera sur le téléphone du patient maintenant, ou à la prochaine ouverture de l'application."
},
"delivered": {
"title": "Reçue sur le téléphone",
"body": "Le patient a reçu la mise à jour et doit l'approuver sur son appareil."
},
"approved": {
"title": "Approuvée par le patient",
"body": "Le patient a approuvé la mise à jour ; son dossier sur l'appareil est maintenant à jour."
},
"denied": {
"title": "Refusée par le patient",
"body": "Le patient a refusé cette mise à jour ; son dossier sur l'appareil est inchangé."
}
}
},
"common": {
"appName": "temetro",
"email": "E-mail",
@@ -1625,6 +1672,32 @@
"endpointPlaceholder": "https://votre-chambre-compensation.example/claims",
"credentialsPlaceholder": "JSON : {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON avec le jeton de votre chambre de compensation et les identifiants d'expéditeur/destinataire."
},
"fhirServer": {
"title": "Serveur FHIR (partager vos dossiers)",
"description": "Exposez les dossiers de cette clinique à des systèmes externes via un serveur FHIR R4 en lecture seule, authentifié par des clés d'API propres à la clinique.",
"baseUrl": "URL de base",
"baseUrlHint": "Pointez un client FHIR vers cette URL. Elle expose Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter et Appointment.",
"copiedUrl": "URL de base copiée",
"newKey": "Créer une clé d'API",
"newKeyPlaceholder": "Nom de la clé (ex. Entrepôt de recherche)",
"create": "Créer",
"creating": "Création…",
"createFailed": "Impossible de créer la clé",
"createFailedBody": "Veuillez réessayer.",
"secretTitle": "Copiez votre clé d'API maintenant",
"secretHint": "Le secret n'est affiché qu'une seule fois. Conservez-le en lieu sûr — il ne pourra pas être récupéré ensuite.",
"copiedSecret": "Clé d'API copiée",
"dismissSecret": "Terminé",
"noKeys": "Aucune clé d'API pour l'instant. Créez-en une pour qu'un client FHIR puisse se connecter.",
"lastUsed": "Dernière utilisation {{when}}",
"neverUsed": "Jamais utilisée",
"revoke": "Révoquer",
"confirmRevoke": "Révoquer la clé",
"revoked": "Révoquée",
"revokeFailed": "Impossible de révoquer la clé",
"revokeFailedBody": "Veuillez réessayer.",
"cancel": "Annuler"
}
},
"empty": "Rien ici pour le moment.",
@@ -1903,6 +1976,20 @@
"errorTitle": "Impossible d'ajouter le membre"
}
},
"network": {
"title": "Réseau Temetro",
"description": "Le relais Réseau Temetro permet aux applications portefeuille des patients de se connecter à votre clinique pour partager et approuver des dossiers — chiffrés de bout en bout, votre clinique étant identifiée par sa clé de signature.",
"toggleLabel": "Rejoindre le Réseau Temetro",
"toggleDesc": "Une fois activé, cette clinique se connecte au relais pour importer des dossiers depuis les applications des patients et envoyer des mises à jour vers leurs portefeuilles.",
"statusConnected": "Connecté",
"statusOff": "Désactivé",
"joinedTitle": "Réseau Temetro rejoint",
"joinedBody": "Les applications portefeuille des patients peuvent désormais se connecter à cette clinique.",
"leftTitle": "Réseau Temetro quitté",
"leftBody": "Cette clinique n'est plus connectée au relais.",
"errorTitle": "Impossible de mettre à jour l'accès au réseau",
"error": "Veuillez réessayer."
},
"signing": {
"keyTitle": "Clé de signature",
"active": "Active",
@@ -43,6 +43,53 @@
"generic": "Wax baa qaldamay. Fadlan isku day mar kale."
}
},
"walletUpdatesList": {
"title": "Cusboonaysiimaha loo diray walletyada",
"description": "Cusboonaysiimaha diiwaanka ee aad u dirtay walletyada bukaannada, iyo haddii bukaanku ansixiyay.",
"none": "Weli ma aadan dirin wax cusboonaysiin walletka ah."
},
"walletPush": {
"action": "U dir walletka",
"title": "U dir cusboonaysiinta walletka",
"subtitle": "U dir diiwaanka la cusboonaysiiyay ee {{name}} abka walletkooda. Waa inay taleefankooda ku ansixiyaan ka hor inta uusan bedelin nuqulka qalabkooda ku jira.",
"sectionsLabel": "Waxa isbedelay",
"sections": {
"demographics": "Xogta shakhsi",
"problems": "Dhibaatooyinka",
"medications": "Daawooyinka",
"allergies": "Xasaasiyadda",
"labs": "Baaritaannada",
"vitals": "Calaamadaha muhiimka ah",
"visits": "Booqashooyinka"
},
"noteLabel": "Qoraal (ikhtiyaari)",
"notePlaceholder": "Qoraal gaaban oo ku saabsan cusboonaysiintan…",
"notice": "Tan waxay dirtaa diiwaanka hadda oo dhan, oo lagu saxeexay furaha rugtaada. Waxay bedeshaa nuqulka qalabka bukaanka oo keliya ka dib marka ay ansixiyaan.",
"cancel": "Jooji",
"send": "Dir cusboonaysiinta",
"done": "Dhammaystiran",
"errors": {
"generic": "Lama diri karin cusboonaysiinta. Fadlan isku day mar kale."
},
"status": {
"pending": {
"title": "Sugaya bukaanka",
"body": "Cusboonaysiintii waa la diray. Waxay ku iman doontaa taleefanka bukaanka hadda, ama marka xigta ee ay furaan abka."
},
"delivered": {
"title": "La gaarsiiyay taleefanka",
"body": "Bukaanku wuu helay cusboonaysiinta wuxuuna u baahan yahay inuu ku ansixiyo qalabkiisa."
},
"approved": {
"title": "Bukaanku wuu ansixiyay",
"body": "Bukaanku wuu ansixiyay cusboonaysiinta, diiwaanka qalabkooduna hadda waa cusub yahay."
},
"denied": {
"title": "Bukaanku wuu diiday",
"body": "Bukaanku wuu diiday cusboonaysiintan, sidaas darteed diiwaanka qalabkoodu isma bedelin."
}
}
},
"common": {
"appName": "temetro",
"email": "Iimayl",
@@ -1625,6 +1672,32 @@
"endpointPlaceholder": "https://xarunta-xisaabintaada.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON leh token-ka xarunta xisaabinta iyo aqoonsiyada diraha/qaataha."
},
"fhirServer": {
"title": "Serfarka FHIR (la wadaag diiwaannadaada)",
"description": "U soo bandhig diiwaannada rugtan nidaamyada dibadda ah sida serfar FHIR R4 akhris-oo-keliya, oo lagu ansixiyo furayaal API oo rug walba gaar u ah.",
"baseUrl": "URL-ka aasaasiga ah",
"baseUrlHint": "U tilmaam macmiil FHIR URL-kan. Wuxuu adeegaa Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter iyo Appointment.",
"copiedUrl": "URL-ka aasaasiga waa la koobiyeeyay",
"newKey": "Samee fure API",
"newKeyPlaceholder": "Magaca furaha (tusaale, Bakhaarka cilmi-baarista)",
"create": "Samee",
"creating": "Waa la samaynayaa…",
"createFailed": "Furaha lama abuuri karin",
"createFailedBody": "Fadlan mar kale isku day.",
"secretTitle": "Hadda koobiyee furahaaga API",
"secretHint": "Tanu waa markii kaliya ee sirta la muujiyo. Meel ammaan ah ku kaydi — lama soo ceshan karo dabadeed.",
"copiedSecret": "Furaha API waa la koobiyeeyay",
"dismissSecret": "Diyaar",
"noKeys": "Weli ma jiraan furayaal API ah. Mid samee si macmiil FHIR u xidho.",
"lastUsed": "Markii ugu dambeysay la isticmaalay {{when}}",
"neverUsed": "Weligeed lama isticmaalin",
"revoke": "Baabbi'i",
"confirmRevoke": "Baabbi'i furaha",
"revoked": "La baabbi'iyay",
"revokeFailed": "Furaha lama baabbi'in karin",
"revokeFailedBody": "Fadlan mar kale isku day.",
"cancel": "Jooji"
}
},
"empty": "Weli halkan waxba ma jiraan.",
@@ -1903,6 +1976,20 @@
"errorTitle": "Xubinta lama ku dari karin"
}
},
"network": {
"title": "Shabakadda Temetro",
"description": "Gudbiyaha Shabakadda Temetro wuxuu u oggolaanayaa abaabulka boorsada bukaannada inay ku xidhaan rugtaada si ay u wadaagaan oo u ansixiyaan diiwaannada — sir gaba-gabo ah, iyadoo rugtaada lagu aqoonsado furaheeda saxiixa.",
"toggleLabel": "Ku biir Shabakadda Temetro",
"toggleDesc": "Marka la shido, rugtan waxay ku xidhmaysaa gudbiyaha si aad u soo dejiso diiwaannada abaabulka bukaannada oo aad cusboonaysiin ugu dirto boorsadooda.",
"statusConnected": "La xidhiidhay",
"statusOff": "Daminaan",
"joinedTitle": "Waxaad ku biirtay Shabakadda Temetro",
"joinedBody": "Abaabulka boorsada bukaannada hadda way ku xidhmi karaan rugtan.",
"leftTitle": "Waxaad ka baxday Shabakadda Temetro",
"leftBody": "Rugtan hadda kuma xidhna gudbiyaha.",
"errorTitle": "Lama cusboonaysiin karin gelitaanka shabakadda",
"error": "Fadlan isku day mar kale."
},
"signing": {
"keyTitle": "Furaha saxiixa",
"active": "Firfircoon",
+31
View File
@@ -64,6 +64,37 @@ export function submitInsuranceClaim(
});
}
// --- FHIR server API keys (owner/admin only) --------------------------------
export type FhirApiKey = {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
revoked: boolean;
};
// A freshly created key includes the one-time plaintext secret; it is never
// returned again.
export type CreatedFhirApiKey = FhirApiKey & { secret: string };
export function listFhirKeys(): Promise<FhirApiKey[]> {
return apiFetch<FhirApiKey[]>("/api/integrations/fhir-server/keys");
}
export function createFhirKey(name: string): Promise<CreatedFhirApiKey> {
return apiFetch<CreatedFhirApiKey>("/api/integrations/fhir-server/keys", {
method: "POST",
body: JSON.stringify({ name }),
});
}
export function revokeFhirKey(id: string): Promise<{ revoked: boolean }> {
return apiFetch(`/api/integrations/fhir-server/keys/${id}`, {
method: "DELETE",
});
}
// Convenience hook-style fetch reused by the on-page sections: returns the
// config for one type (or null while loading/absent).
export async function getIntegration(
+18
View File
@@ -38,3 +38,21 @@ export async function rotateSigningKey(): Promise<SigningKey> {
export async function listSignedRecords(): Promise<SharedRecord[]> {
return apiFetch<SharedRecord[]>("/api/signing/records");
}
// Whether this clinic has joined the Temetro Network relay (patient-wallet
// sharing rides it). Readable by any clinician.
export async function getNetworkEnabled(): Promise<boolean> {
const { enabled } = await apiFetch<{ enabled: boolean }>(
"/api/signing/network",
);
return enabled;
}
// Join or leave the Temetro Network (owner/admin only). Returns the new state.
export async function setNetworkEnabled(enabled: boolean): Promise<boolean> {
const res = await apiFetch<{ enabled: boolean }>("/api/signing/network", {
method: "PUT",
body: JSON.stringify({ enabled }),
});
return res.enabled;
}
+47
View File
@@ -0,0 +1,47 @@
// Client for the clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient, they can push the updated record to the patient's app;
// it stays pending until the patient approves it on their phone.
import { apiFetch } from "@/lib/api-client";
export type WalletUpdateStatus = "pending" | "delivered" | "approved" | "denied";
export type WalletUpdate = {
id: string;
fileNumber: string;
walletNumber: string;
status: WalletUpdateStatus;
changes: string[];
createdAt: string;
deliveredAt: string | null;
resolvedAt: string | null;
};
// Resolve the wallet a patient is linked to. Rejects (404) when not wallet-backed.
export function getWalletLink(
fileNumber: string,
): Promise<{ walletNumber: string }> {
return apiFetch<{ walletNumber: string }>(
`/api/patients/wallet/link/${encodeURIComponent(fileNumber)}`,
);
}
export function pushWalletUpdate(input: {
fileNumber: string;
changes: string[];
}): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>("/api/patients/wallet/push", {
method: "POST",
body: JSON.stringify(input),
});
}
export function listWalletUpdates(): Promise<WalletUpdate[]> {
return apiFetch<WalletUpdate[]>("/api/patients/wallet/updates");
}
export function getWalletUpdate(id: string): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>(
`/api/patients/wallet/updates/${encodeURIComponent(id)}`,
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.4.0",
"version": "0.8.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.4.0",
"version": "0.8.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"