mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01dbc07e92 | |||
| 233ce9f854 | |||
| 99aa534e88 | |||
| ef76afc3ca | |||
| d79f7f7c06 | |||
| 0d2494d67a | |||
| bb536ba6da | |||
| b29fdff1cb | |||
| d237504af9 | |||
| 46c32b432c | |||
| 5bbfe551fc |
@@ -35,6 +35,11 @@ jobs:
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# QEMU lets the amd64 runner emulate arm64 so the images below build for
|
||||
# both platforms (Intel + Apple Silicon self-hosters).
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -49,6 +54,7 @@ jobs:
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-backend:latest
|
||||
@@ -58,6 +64,7 @@ jobs:
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:${{ steps.meta.outputs.version }}
|
||||
${{ env.REGISTRY_NAMESPACE }}/temetro-frontend:latest
|
||||
|
||||
+154
@@ -7,6 +7,160 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.2] — 2026-07-05
|
||||
|
||||
### Fixed
|
||||
- **`RELAY_URL` now defaults to the hosted relay** (`https://network.temetro.com`) instead of
|
||||
`http://localhost:8080`. The old default silently failed for anyone who joined the network without
|
||||
explicitly setting `RELAY_URL` — the backend's hub connection could never reach the relay (inside
|
||||
Docker `localhost` is the container itself), so it never authenticated and QR pairing generated a
|
||||
QR pointing at an unreachable `localhost`. Self-hosters running their own relay still override
|
||||
`RELAY_URL`. Updated `.env.example` accordingly.
|
||||
|
||||
### Changed
|
||||
- Generating a pairing QR (`POST /api/patients/wallet/pair`) now ensures the clinic's relay hub is
|
||||
connected before pre-registering the request, so the routing is set up even if the connection was
|
||||
opened lazily.
|
||||
|
||||
### Fixed
|
||||
- **QR "scan to connect" pairing** was broken by the multi-clinic relay routing (v0.8.0): pairing
|
||||
has no wallet number, so the clinic never sent a `wallet:send` to register the request, and the
|
||||
relay rejected the scanning device's response as "unknown or expired". The clinic now
|
||||
**pre-registers** the pairing request with the relay (a new `hub:expect { requestId }` event on
|
||||
`POST /api/patients/wallet/pair`), so the device's response routes back correctly. On hub
|
||||
(re)connect the backend re-registers its still-pending requests, so routing also survives a relay
|
||||
restart. `POST /pair` now also requires the clinic to have joined the network (clear 409 instead of
|
||||
a dead QR), surfaced in the import dialog.
|
||||
|
||||
### 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
|
||||
- **Ambient AI visit scribe** — a **Record visit** action on the patient sheet turns a
|
||||
clinician↔patient conversation into a draft **SOAP** encounter note. Record with the
|
||||
microphone (`MediaRecorder`, stored as an auditable patient attachment) or paste a
|
||||
transcript; the backend transcribes via the user's **OpenAI (Whisper)** or **Gemini**
|
||||
key, de-identifies the transcript + patient context through **Veil**, and the model
|
||||
drafts a structured note that the clinician **reviews and edits before saving** — the
|
||||
same write-approval gate as the chat agent. New `POST /api/scribe/{transcribe,draft,save}`
|
||||
(`backend/src/routes/scribe.ts`, `services/ai/transcribe.ts`), a `veil.redactText()`
|
||||
free-text redactor, and an `appendEncounter` service that adds one note without touching
|
||||
the rest of the record. Gated by `patient:write` + the clinic AI policy (reception and
|
||||
disabled-AI accounts don't see it). New `scribe` locale namespace across all five
|
||||
languages. Drafting also works with local Ollama from a pasted transcript.
|
||||
|
||||
## [0.3.0] — 2026-07-02
|
||||
|
||||
### Added
|
||||
- **Three new interface languages** — Somali (`so`), Arabic (`ar`) and German
|
||||
(`de`) join English and French, selectable in Settings → Profile → Language.
|
||||
Each locale carries a full translation of the ~1,660 UI strings, with native
|
||||
names shown in the selector (Soomaali, العربية, Deutsch).
|
||||
- **Right-to-left (RTL) support** — selecting Arabic sets `dir="rtl"` on the
|
||||
document (applied before first paint via an inline script, so no flash), flips
|
||||
physical spacing/alignment to logical CSS utilities, mirrors directional icons,
|
||||
and loads an Arabic-capable typeface (IBM Plex Sans Arabic) appended to the
|
||||
font stack for per-character fallback.
|
||||
- **Language roams across devices** — the chosen language is persisted to the
|
||||
per-user `user_settings` preferences and re-applied on sign-in, with
|
||||
localStorage remaining the offline source of truth.
|
||||
- **`frontend/scripts/check-locales.mjs`** (+ `npm run check-locales`) — a parity
|
||||
check that fails on missing/extra keys or `{{placeholder}}` mismatches across
|
||||
locales and warns when Arabic count-keys lack the full CLDR plural forms.
|
||||
|
||||
## [0.2.5] — 2026-07-01
|
||||
|
||||
### Fixed
|
||||
- **Multi-arch Docker images** — the `release` workflow now builds and publishes
|
||||
`khalidxv/temetro-backend` and `khalidxv/temetro-frontend` for both
|
||||
`linux/amd64` and `linux/arm64`. Previously the images were amd64-only, so
|
||||
`docker compose pull` on Apple Silicon failed with *no matching manifest for
|
||||
linux/arm64/v8* and fell back to building from source.
|
||||
|
||||
### Changed
|
||||
- **Language switcher** in Settings → Profile is now a **select** that asks for
|
||||
confirmation before switching the interface language, instead of applying the
|
||||
change instantly on a button tap.
|
||||
|
||||
## [0.2.4] — 2026-06-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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
|
||||
@@ -80,6 +103,11 @@ accurate (e.g. a new backend route needs an `content/docs/api/*.mdx` entry; a UI
|
||||
in the matching guide; status changes belong in the roadmap). Commit docs changes inside that
|
||||
repo, separately from this one.
|
||||
|
||||
**Every release must also get a dated entry in the docs changelog**
|
||||
(`content/docs/changelog.mdx`, newest first) — not just the monorepo `CHANGELOG.md`. When you cut a
|
||||
version (see "Always release after pushing"), add a matching, user-facing section to that page in the
|
||||
same session so `../temetro/docs` never falls behind the shipped version.
|
||||
|
||||
## Running the stack
|
||||
|
||||
From `backend/`: ensure a `.env` exists (`cp .env.example .env`, then set `BETTER_AUTH_SECRET` via
|
||||
|
||||
+17
-7
@@ -32,13 +32,23 @@ 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).
|
||||
# Defaults to the hosted relay (https://network.temetro.com) when unset, so
|
||||
# "Join Temetro Network" works out of the box; set RELAY_URL only to point at
|
||||
# your own relay. Do NOT use http://localhost — inside Docker that's the
|
||||
# container itself and the relay connection will silently fail.
|
||||
RELAY_URL=https://network.temetro.com
|
||||
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) -----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+60
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.2.4",
|
||||
"version": "0.8.2",
|
||||
"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": {
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -28,6 +28,19 @@ 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).
|
||||
//
|
||||
// Defaults to the hosted relay so "Join Temetro Network" works out of the box;
|
||||
// override only when running your own relay. (A `localhost` default silently
|
||||
// fails inside Docker, where localhost is the container itself.)
|
||||
RELAY_URL: z.string().min(1).default("https://network.temetro.com"),
|
||||
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 +93,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";
|
||||
|
||||
+16
-1
@@ -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";
|
||||
@@ -28,12 +29,14 @@ import { patientsRouter } from "./routes/patients.js";
|
||||
import { patientsWalletRouter } from "./routes/patients-wallet.js";
|
||||
import { portalRouter } from "./routes/portal.js";
|
||||
import { prescriptionsRouter } from "./routes/prescriptions.js";
|
||||
import { scribeRouter } from "./routes/scribe.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { signingRouter } from "./routes/signing.js";
|
||||
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";
|
||||
|
||||
@@ -104,10 +107,16 @@ app.use("/api/notifications", notificationsRouter);
|
||||
app.use("/api/settings", settingsRouter);
|
||||
app.use("/api/ai", aiRouter);
|
||||
app.use("/api/chat", chatRouter);
|
||||
app.use("/api/scribe", scribeRouter);
|
||||
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);
|
||||
|
||||
@@ -115,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(() => {
|
||||
@@ -144,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ const ALLOWED_MIME = new Set([
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
// Ambient visit-scribe recordings (stored as a patient attachment so they're
|
||||
// auditable). Voice Opus/AAC stays well under the 15 MB cap for a long visit.
|
||||
"audio/webm",
|
||||
"audio/ogg",
|
||||
"audio/mp4",
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/x-m4a",
|
||||
]);
|
||||
|
||||
// Disk storage under UPLOAD_DIR/<orgId>/, keyed by a random id so original
|
||||
|
||||
@@ -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}.`,
|
||||
);
|
||||
});
|
||||
@@ -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) });
|
||||
|
||||
@@ -17,18 +17,36 @@ import {
|
||||
} from "../middleware/auth.js";
|
||||
import { emitToWallet } from "../realtime.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { connectOrg, expectResponse } from "../services/relay-client.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.
|
||||
@@ -67,6 +85,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = pairSchema.parse(req.body);
|
||||
const { view, ephemeralPubKey } = await walletShare.createPairingRequest(
|
||||
req.organizationId!,
|
||||
@@ -74,6 +93,12 @@ patientsWalletRouter.post(
|
||||
input.mode,
|
||||
input.durationHours,
|
||||
);
|
||||
// No wallet number to `wallet:send` to yet, so pre-register the request id
|
||||
// with the relay so the scanning device's response routes back to us.
|
||||
// Ensure the hub is (re)connected first; if it's still mid-handshake the
|
||||
// on-auth re-registration of pending requests will catch this one.
|
||||
await connectOrg(req.organizationId!);
|
||||
expectResponse(req.organizationId!, view.id);
|
||||
res.status(201).json({
|
||||
...view,
|
||||
ephemeralPubKey,
|
||||
@@ -93,6 +118,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 +131,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 +164,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.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { generateText } from "ai";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { isReceptionOnly, providerScope } from "../lib/role-scope.js";
|
||||
import {
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { getAiSettings } from "../services/ai/config.js";
|
||||
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
|
||||
import { resolveModel } from "../services/ai/provider.js";
|
||||
import { transcribeAudio } from "../services/ai/transcribe.js";
|
||||
import { createVeil } from "../services/ai/veil.js";
|
||||
import { absolutePath, getAttachmentRow } from "../services/attachments.js";
|
||||
import { appendEncounter, getPatient } from "../services/patients.js";
|
||||
import type { Encounter } from "../types/patient.js";
|
||||
|
||||
export const scribeRouter = Router();
|
||||
|
||||
// The ambient scribe drafts and saves a clinical encounter note, so it needs
|
||||
// full clinical write access — never reception (demographics only).
|
||||
scribeRouter.use(
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission({ patient: ["write"] }),
|
||||
);
|
||||
|
||||
// Guard shared by every scribe route: the clinic AI kill-switch must allow this
|
||||
// member, and reception (demographics-only) can never draft clinical notes.
|
||||
async function ensureScribeAllowed(req: {
|
||||
organizationId?: string;
|
||||
memberRole?: string;
|
||||
}): Promise<void> {
|
||||
if (isReceptionOnly(req.memberRole)) {
|
||||
throw new HttpError(403, "The visit scribe is not available for your role.");
|
||||
}
|
||||
const policy = await getPolicy(req.organizationId!);
|
||||
if (!aiAllowedFor(policy, req.memberRole)) {
|
||||
throw new HttpError(403, "The AI assistant is disabled for your account.");
|
||||
}
|
||||
}
|
||||
|
||||
const transcribeSchema = z.object({
|
||||
attachmentId: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
// POST /api/scribe/transcribe — turn a stored audio attachment into a raw
|
||||
// transcript via the user's speech provider (OpenAI/Gemini). The audio does NOT
|
||||
// pass through Veil or the chat loop.
|
||||
scribeRouter.post("/transcribe", async (req, res, next) => {
|
||||
try {
|
||||
await ensureScribeAllowed(req);
|
||||
const { attachmentId } = transcribeSchema.parse(req.body);
|
||||
const row = await getAttachmentRow(req.organizationId!, attachmentId);
|
||||
if (!row) throw new HttpError(404, "Recording not found.");
|
||||
if (!row.mimeType.startsWith("audio/")) {
|
||||
throw new HttpError(400, "That attachment is not an audio recording.");
|
||||
}
|
||||
|
||||
const settings = await getAiSettings(req.user!.id);
|
||||
const buffer = await readFile(absolutePath(row.storagePath));
|
||||
const { transcript, provider } = await transcribeAudio(settings, {
|
||||
buffer,
|
||||
mimeType: row.mimeType,
|
||||
filename: row.filename,
|
||||
});
|
||||
|
||||
void recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Transcribed a visit recording (${provider})`,
|
||||
entityType: "patient",
|
||||
patientFileNumber: row.fileNumber,
|
||||
});
|
||||
|
||||
res.json({ transcript });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const draftSchema = z.object({
|
||||
fileNumber: z.string().trim().min(1),
|
||||
transcript: z.string().trim().min(1).max(100_000),
|
||||
visitType: z.string().trim().max(120).optional(),
|
||||
date: z.string().trim().max(40).optional(),
|
||||
});
|
||||
|
||||
// Strip ```json fences and parse; returns null if the text isn't JSON.
|
||||
function parseDraftJson(text: string): { type?: string; summary?: string } | null {
|
||||
const cleaned = text
|
||||
.replace(/^\s*```(?:json)?/i, "")
|
||||
.replace(/```\s*$/i, "")
|
||||
.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
return typeof parsed === "object" && parsed ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const DRAFT_SYSTEM = [
|
||||
"You are a clinical scribe. From a visit transcript, write a concise, structured",
|
||||
"encounter note in SOAP format (Subjective, Objective, Assessment, Plan).",
|
||||
"Rules:",
|
||||
"- Use ONLY what the transcript supports. Never invent vitals, doses, or findings.",
|
||||
"- If a SOAP section has nothing to report, write \"Not discussed.\" under it.",
|
||||
"- Identifiers may appear as tokens like [PATIENT_1] or [PROVIDER_1]; keep them",
|
||||
" verbatim — do not guess real names.",
|
||||
"Respond with a JSON object ONLY (no prose, no code fences):",
|
||||
'{ "type": "<short visit type, e.g. Follow-up / New patient / Telehealth>",',
|
||||
' "summary": "<the SOAP note as markdown with **Subjective** / **Objective** /',
|
||||
' **Assessment** / **Plan** headings>" }',
|
||||
].join("\n");
|
||||
|
||||
// POST /api/scribe/draft — draft an encounter note from a transcript. The
|
||||
// transcript + patient context are Veil-redacted before any external call and
|
||||
// the output is rehydrated. Nothing is written — the clinician reviews and
|
||||
// approves via POST /save (the same write-approval gate as the chat agent).
|
||||
scribeRouter.post("/draft", async (req, res, next) => {
|
||||
try {
|
||||
await ensureScribeAllowed(req);
|
||||
const { fileNumber, transcript, visitType, date } = draftSchema.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
const patient = await getPatient(
|
||||
req.organizationId!,
|
||||
fileNumber,
|
||||
false,
|
||||
providerScope(req.memberRole, req.user!.id),
|
||||
);
|
||||
if (!patient) throw new HttpError(404, "Patient not found.");
|
||||
|
||||
const settings = await getAiSettings(req.user!.id);
|
||||
const resolved = resolveModel(settings, settings.defaultModel);
|
||||
const veil = createVeil(settings.veilLevel, resolved.isExternal);
|
||||
|
||||
// Seed Veil's token maps with this patient's identifiers, then redact the
|
||||
// free-text transcript against them before it leaves the clinic.
|
||||
const redactedPatient = veil.redactPatient(patient);
|
||||
const redactedTranscript = veil.redactText(transcript);
|
||||
|
||||
const context = [
|
||||
`Patient: ${redactedPatient.name} (MRN ${redactedPatient.fileNumber}), ${patient.age}y ${patient.sex}.`,
|
||||
patient.problems.length
|
||||
? `Known problems: ${patient.problems.map((p) => p.label).join(", ")}.`
|
||||
: "",
|
||||
patient.medications.length
|
||||
? `Current medications: ${patient.medications
|
||||
.map((m) => `${m.name} ${m.dose}`)
|
||||
.join(", ")}.`
|
||||
: "",
|
||||
visitType ? `Visit type hint: ${visitType}.` : "",
|
||||
"",
|
||||
"Transcript:",
|
||||
redactedTranscript,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const result = await generateText({
|
||||
model: resolved.model,
|
||||
system: DRAFT_SYSTEM,
|
||||
prompt: context,
|
||||
});
|
||||
|
||||
const parsed = parseDraftJson(result.text);
|
||||
const summary = veil.rehydrate(
|
||||
(parsed?.summary ?? result.text ?? "").trim(),
|
||||
);
|
||||
const type = (parsed?.type ?? visitType ?? "Visit").trim() || "Visit";
|
||||
|
||||
const draft: Encounter = {
|
||||
date: date || new Date().toISOString().slice(0, 10),
|
||||
type,
|
||||
// The responsible clinician is the signed-in user, not the model's guess.
|
||||
provider: req.user!.name ?? "",
|
||||
summary,
|
||||
};
|
||||
|
||||
res.json({
|
||||
draft,
|
||||
veil: {
|
||||
active: veil.active,
|
||||
level: veil.level,
|
||||
classes: veil.usedClasses(),
|
||||
provider: resolved.providerLabel,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const saveSchema = z.object({
|
||||
fileNumber: z.string().trim().min(1),
|
||||
encounter: z.object({
|
||||
date: z.string().trim().min(1),
|
||||
type: z.string().trim().min(1),
|
||||
provider: z.string().trim().default(""),
|
||||
summary: z.string().trim().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
// POST /api/scribe/save — the approval step: append the reviewed encounter note
|
||||
// to the patient record. Re-validates server-side and audits like any add.
|
||||
scribeRouter.post("/save", async (req, res, next) => {
|
||||
try {
|
||||
await ensureScribeAllowed(req);
|
||||
const { fileNumber, encounter } = saveSchema.parse(req.body);
|
||||
const updated = await appendEncounter(
|
||||
req.organizationId!,
|
||||
fileNumber,
|
||||
encounter,
|
||||
);
|
||||
if (!updated) throw new HttpError(404, "Patient not found.");
|
||||
|
||||
void recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Added a scribe visit note for ${updated.name}`,
|
||||
entityType: "patient",
|
||||
entityId: updated.fileNumber,
|
||||
patientName: updated.name,
|
||||
patientFileNumber: updated.fileNumber,
|
||||
});
|
||||
|
||||
res.status(201).json(updated);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -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,132 @@
|
||||
import { HttpError } from "../../lib/http-error.js";
|
||||
import type { userAiSettings } from "../../db/schema/ai.js";
|
||||
import { getApiKey } from "./config.js";
|
||||
|
||||
type AiSettingsRow = typeof userAiSettings.$inferSelect;
|
||||
|
||||
export type AudioInput = {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
// Which transcription backend a user's AI settings can reach. Anthropic has no
|
||||
// speech-to-text API, so an Anthropic-only user must paste a transcript instead.
|
||||
export type TranscribeProvider = "openai" | "gemini";
|
||||
|
||||
export function transcribeProviderFor(
|
||||
settings: AiSettingsRow,
|
||||
): TranscribeProvider | null {
|
||||
if (getApiKey(settings, "openai")) return "openai";
|
||||
if (getApiKey(settings, "gemini")) return "gemini";
|
||||
return null;
|
||||
}
|
||||
|
||||
const OPENAI_MODEL = "whisper-1";
|
||||
const GEMINI_MODEL = "gemini-2.5-flash";
|
||||
|
||||
const TRANSCRIBE_PROMPT =
|
||||
"Transcribe this clinical visit recording verbatim. Return only the spoken words as plain text, with no commentary, headings, or timestamps.";
|
||||
|
||||
async function transcribeWithOpenAI(
|
||||
apiKey: string,
|
||||
audio: AudioInput,
|
||||
): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"file",
|
||||
new Blob([new Uint8Array(audio.buffer)], { type: audio.mimeType }),
|
||||
audio.filename,
|
||||
);
|
||||
form.append("model", OPENAI_MODEL);
|
||||
form.append("response_format", "json");
|
||||
|
||||
const res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
throw new HttpError(
|
||||
502,
|
||||
`Transcription failed (OpenAI ${res.status}). ${detail.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { text?: string };
|
||||
return (json.text ?? "").trim();
|
||||
}
|
||||
|
||||
async function transcribeWithGemini(
|
||||
apiKey: string,
|
||||
audio: AudioInput,
|
||||
): Promise<string> {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${encodeURIComponent(
|
||||
apiKey,
|
||||
)}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
inline_data: {
|
||||
mime_type: audio.mimeType,
|
||||
data: audio.buffer.toString("base64"),
|
||||
},
|
||||
},
|
||||
{ text: TRANSCRIBE_PROMPT },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
throw new HttpError(
|
||||
502,
|
||||
`Transcription failed (Gemini ${res.status}). ${detail.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
candidates?: { content?: { parts?: { text?: string }[] } }[];
|
||||
};
|
||||
const text =
|
||||
json.candidates?.[0]?.content?.parts
|
||||
?.map((p) => p.text ?? "")
|
||||
.join("")
|
||||
.trim() ?? "";
|
||||
return text;
|
||||
}
|
||||
|
||||
// Send an audio recording to the user's transcription provider and return the
|
||||
// raw transcript. The audio never passes through the chat loop or Veil — Veil
|
||||
// cannot redact speech, so the caller must warn the clinician that audio leaves
|
||||
// the clinic when an external provider is used (only the DRAFTING step is
|
||||
// Veil-protected). Throws a 400 when no speech-capable provider is configured.
|
||||
export async function transcribeAudio(
|
||||
settings: AiSettingsRow,
|
||||
audio: AudioInput,
|
||||
): Promise<{ transcript: string; provider: TranscribeProvider }> {
|
||||
const provider = transcribeProviderFor(settings);
|
||||
if (!provider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Transcription needs an OpenAI or Gemini API key. Add one in Settings → AI, or paste the visit transcript instead.",
|
||||
);
|
||||
}
|
||||
const apiKey = getApiKey(settings, provider)!;
|
||||
const transcript =
|
||||
provider === "openai"
|
||||
? await transcribeWithOpenAI(apiKey, audio)
|
||||
: await transcribeWithGemini(apiKey, audio);
|
||||
if (!transcript) {
|
||||
throw new HttpError(
|
||||
502,
|
||||
"The transcription came back empty — try again or paste the transcript.",
|
||||
);
|
||||
}
|
||||
return { transcript, provider };
|
||||
}
|
||||
@@ -26,6 +26,15 @@ export type Veil = {
|
||||
redactPatient: (patient: Patient) => Patient;
|
||||
/** Map a possibly-tokenized file number from a tool call back to the real one. */
|
||||
resolveFileNumber: (input: string) => string;
|
||||
/**
|
||||
* De-identify free text (e.g. a visit transcript) by swapping any KNOWN
|
||||
* identifiers — the patient name, MRN and provider names already seen via
|
||||
* redactPatient — for their tokens. Seed the token maps by calling
|
||||
* redactPatient(patient) first. Note: this only catches identifiers we know
|
||||
* about; free-text PHI spoken aloud (addresses, relatives' names) is not
|
||||
* covered — see the ambient-scribe consent notice.
|
||||
*/
|
||||
redactText: (text: string) => string;
|
||||
/** Swap any tokens in model output back to real identifiers. */
|
||||
rehydrate: (text: string) => string;
|
||||
/** Token classes actually emitted — for the audit log. */
|
||||
@@ -81,6 +90,21 @@ export function createVeil(level: VeilLevel, active: boolean): Veil {
|
||||
return mrnByToken.get(input.trim()) ?? input;
|
||||
}
|
||||
|
||||
function redactText(text: string): string {
|
||||
if (!isActive || fromToken.size === 0) return text;
|
||||
let out = text;
|
||||
// Longest real values first so a provider name containing the patient name
|
||||
// (or similar overlap) is replaced whole before its substrings.
|
||||
const byLength = [...fromToken.entries()]
|
||||
.filter(([, real]) => real.trim().length > 0)
|
||||
.sort((a, b) => b[1].length - a[1].length);
|
||||
for (const [token, real] of byLength) {
|
||||
const escaped = real.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
out = out.replace(new RegExp(escaped, "gi"), token);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rehydrate(text: string): string {
|
||||
if (!isActive || fromToken.size === 0) return text;
|
||||
let out = text;
|
||||
@@ -101,6 +125,7 @@ export function createVeil(level: VeilLevel, active: boolean): Veil {
|
||||
level,
|
||||
redactPatient,
|
||||
resolveFileNumber,
|
||||
redactText,
|
||||
rehydrate,
|
||||
usedClasses,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FhirResource } from "./resources.js";
|
||||
|
||||
// searchset Bundle assembly + offset/limit pagination for the FHIR server.
|
||||
|
||||
export const DEFAULT_COUNT = 50;
|
||||
export const MAX_COUNT = 200;
|
||||
|
||||
// Clamp a client-supplied `_count` into [1, MAX_COUNT], defaulting when absent.
|
||||
export function parseCount(raw: string | undefined): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return DEFAULT_COUNT;
|
||||
return Math.min(Math.floor(n), MAX_COUNT);
|
||||
}
|
||||
|
||||
export function parseOffset(raw: string | undefined): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
// Slice an already-materialized resource array to the requested page.
|
||||
export function paginate<T>(
|
||||
all: T[],
|
||||
count: number,
|
||||
offset: number,
|
||||
): { page: T[]; total: number } {
|
||||
return { page: all.slice(offset, offset + count), total: all.length };
|
||||
}
|
||||
|
||||
export type SearchsetBundle = {
|
||||
resourceType: "Bundle";
|
||||
type: "searchset";
|
||||
total: number;
|
||||
link: { relation: string; url: string }[];
|
||||
entry: { fullUrl: string; resource: FhirResource; search: { mode: "match" } }[];
|
||||
};
|
||||
|
||||
// Build a FHIR searchset Bundle. `page` is the current slice; `total` the full
|
||||
// match count; `params` the effective query (already carrying `_count`/`_offset`)
|
||||
// used to derive self/next/prev links.
|
||||
export function searchsetBundle(opts: {
|
||||
baseUrl: string; // e.g. https://host/fhir
|
||||
resourceType: string;
|
||||
page: FhirResource[];
|
||||
total: number;
|
||||
count: number;
|
||||
offset: number;
|
||||
params: URLSearchParams;
|
||||
}): SearchsetBundle {
|
||||
const { baseUrl, resourceType, page, total, count, offset, params } = opts;
|
||||
|
||||
const linkFor = (nextOffset: number): string => {
|
||||
const q = new URLSearchParams(params);
|
||||
q.set("_count", String(count));
|
||||
q.set("_offset", String(nextOffset));
|
||||
return `${baseUrl}/${resourceType}?${q.toString()}`;
|
||||
};
|
||||
|
||||
const link: { relation: string; url: string }[] = [
|
||||
{ relation: "self", url: linkFor(offset) },
|
||||
];
|
||||
if (offset + count < total) link.push({ relation: "next", url: linkFor(offset + count) });
|
||||
if (offset > 0)
|
||||
link.push({ relation: "previous", url: linkFor(Math.max(0, offset - count)) });
|
||||
|
||||
return {
|
||||
resourceType: "Bundle",
|
||||
type: "searchset",
|
||||
total,
|
||||
link,
|
||||
entry: page.map((resource) => ({
|
||||
fullUrl: `${baseUrl}/${resource.resourceType}/${resource.id}`,
|
||||
resource,
|
||||
search: { mode: "match" },
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// A static CapabilityStatement describing exactly what this read-only FHIR R4
|
||||
// server supports. It is intentionally honest: only the resources and search
|
||||
// params implemented below are listed, everything is `read`/`search-type` only,
|
||||
// and clinical concepts are text-only (no SNOMED/LOINC coding).
|
||||
|
||||
type ResourceCapability = {
|
||||
type: string;
|
||||
interaction: { code: "read" | "search-type" }[];
|
||||
searchParam?: { name: string; type: "token" | "string" | "reference" }[];
|
||||
};
|
||||
|
||||
const RESOURCES: ResourceCapability[] = [
|
||||
{
|
||||
type: "Patient",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "identifier", type: "token" },
|
||||
{ name: "name", type: "string" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Observation",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
{ name: "category", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "AllergyIntolerance",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Condition",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "MedicationRequest",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Encounter",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "Appointment",
|
||||
interaction: [{ code: "read" }, { code: "search-type" }],
|
||||
searchParam: [
|
||||
{ name: "patient", type: "reference" },
|
||||
{ name: "patient.identifier", type: "token" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function capabilityStatement(
|
||||
baseUrl: string,
|
||||
version: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
resourceType: "CapabilityStatement",
|
||||
status: "active",
|
||||
date: new Date().toISOString(),
|
||||
publisher: "temetro",
|
||||
kind: "instance",
|
||||
implementation: { description: "temetro FHIR server", url: baseUrl },
|
||||
software: { name: "temetro", version },
|
||||
fhirVersion: "4.0.1",
|
||||
format: ["application/fhir+json", "json"],
|
||||
rest: [
|
||||
{
|
||||
mode: "server",
|
||||
documentation:
|
||||
"Read-only FHIR R4 server. Authenticate with a per-clinic API key: " +
|
||||
"Authorization: Bearer tmf_…. Clinical values are text-only " +
|
||||
"CodeableConcepts (no SNOMED/LOINC). Patients expose age (extension), " +
|
||||
"not birthDate. Pagination via _count (default 50, max 200) and _offset.",
|
||||
security: {
|
||||
description: "Bearer token (per-organization API key, tmf_ prefix).",
|
||||
service: [
|
||||
{
|
||||
coding: [
|
||||
{
|
||||
system:
|
||||
"http://terminology.hl7.org/CodeSystem/restful-security-service",
|
||||
code: "OAuth",
|
||||
display: "OAuth",
|
||||
},
|
||||
],
|
||||
text: "API key bearer token",
|
||||
},
|
||||
],
|
||||
},
|
||||
resource: RESOURCES,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import { db } from "../../db/index.js";
|
||||
import { fhirApiKeys } from "../../db/schema/fhir-keys.js";
|
||||
|
||||
// FHIR-server API keys. The secret is `tmf_` + 32 random bytes (base64url); we
|
||||
// persist only its SHA-256 hash, so a leaked database never yields usable keys
|
||||
// and the plaintext is returned exactly once (at creation).
|
||||
|
||||
const PREFIX = "tmf_";
|
||||
|
||||
export type FhirKeyView = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revoked: boolean;
|
||||
};
|
||||
|
||||
function hashKey(secret: string): string {
|
||||
return createHash("sha256").update(secret).digest("hex");
|
||||
}
|
||||
|
||||
function toView(row: typeof fhirApiKeys.$inferSelect): FhirKeyView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
lastUsedAt: row.lastUsedAt ? row.lastUsedAt.toISOString() : null,
|
||||
revoked: row.revokedAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// List a clinic's keys (active first, then revoked), newest first. Never
|
||||
// exposes the hash.
|
||||
export async function listKeys(orgId: string): Promise<FhirKeyView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fhirApiKeys)
|
||||
.where(eq(fhirApiKeys.organizationId, orgId))
|
||||
.orderBy(desc(fhirApiKeys.createdAt));
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
// Mint a new key. Returns the one-time plaintext secret alongside the stored
|
||||
// view — the caller must surface the secret to the user immediately; it is not
|
||||
// recoverable afterwards.
|
||||
export async function createKey(
|
||||
orgId: string,
|
||||
name: string,
|
||||
createdBy: string,
|
||||
): Promise<{ secret: string; key: FhirKeyView }> {
|
||||
const secret = PREFIX + randomBytes(32).toString("base64url");
|
||||
const [row] = await db
|
||||
.insert(fhirApiKeys)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
name: name.trim() || "FHIR key",
|
||||
keyHash: hashKey(secret),
|
||||
createdBy,
|
||||
})
|
||||
.returning();
|
||||
return { secret, key: toView(row!) };
|
||||
}
|
||||
|
||||
// Revoke a key (idempotent). Scoped to the org so one clinic can't revoke
|
||||
// another's. Returns false if no such active key exists.
|
||||
export async function revokeKey(orgId: string, id: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(fhirApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(fhirApiKeys.id, id),
|
||||
eq(fhirApiKeys.organizationId, orgId),
|
||||
isNull(fhirApiKeys.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: fhirApiKeys.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
export type ResolvedKey = { orgId: string; keyId: string; keyName: string };
|
||||
|
||||
// Resolve a presented bearer secret to its owning organization (plus the key's
|
||||
// identity, for the audit log), or null when it is unknown or revoked. Bumps
|
||||
// `lastUsedAt` (throttled to once a minute) so the key list can show recent
|
||||
// activity without a write on every request.
|
||||
export async function resolveKey(secret: string): Promise<ResolvedKey | null> {
|
||||
if (!secret.startsWith(PREFIX)) return null;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(fhirApiKeys)
|
||||
.where(eq(fhirApiKeys.keyHash, hashKey(secret)))
|
||||
.limit(1);
|
||||
if (!row || row.revokedAt) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const last = row.lastUsedAt?.getTime() ?? 0;
|
||||
if (now - last > 60_000) {
|
||||
void db
|
||||
.update(fhirApiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(fhirApiKeys.id, row.id))
|
||||
.catch(() => {});
|
||||
}
|
||||
return { orgId: row.organizationId, keyId: row.id, keyName: row.name };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// FHIR OperationOutcome helpers. Errors on a FHIR endpoint are returned as an
|
||||
// OperationOutcome resource (not our usual `{ error }` JSON), with the
|
||||
// `application/fhir+json` content type, so conformant clients can parse them.
|
||||
|
||||
export const FHIR_CONTENT_TYPE = "application/fhir+json";
|
||||
|
||||
export type IssueSeverity = "fatal" | "error" | "warning" | "information";
|
||||
export type IssueCode =
|
||||
| "not-found"
|
||||
| "not-supported"
|
||||
| "security"
|
||||
| "login"
|
||||
| "forbidden"
|
||||
| "invalid"
|
||||
| "processing"
|
||||
| "exception";
|
||||
|
||||
export type OperationOutcome = {
|
||||
resourceType: "OperationOutcome";
|
||||
issue: {
|
||||
severity: IssueSeverity;
|
||||
code: IssueCode;
|
||||
diagnostics?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function operationOutcome(
|
||||
severity: IssueSeverity,
|
||||
code: IssueCode,
|
||||
diagnostics: string,
|
||||
): OperationOutcome {
|
||||
return {
|
||||
resourceType: "OperationOutcome",
|
||||
issue: [{ severity, code, diagnostics }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { and, asc, count, eq, ilike, sql } from "drizzle-orm";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
|
||||
import { db } from "../../db/index.js";
|
||||
import { appointments } from "../../db/schema/appointments.js";
|
||||
import {
|
||||
allergies,
|
||||
encounters,
|
||||
labs,
|
||||
medications,
|
||||
patients,
|
||||
problems,
|
||||
} from "../../db/schema/patients.js";
|
||||
import { prescriptions } from "../../db/schema/prescriptions.js";
|
||||
|
||||
// Narrow, org-scoped reads for the FHIR server. Deliberately separate from the
|
||||
// app's `services/patients.ts` (which returns the reshaped canonical Patient and
|
||||
// applies role redaction): the FHIR layer needs raw rows *with their UUIDs* to
|
||||
// mint stable resource ids, and offset/limit pagination the app service doesn't
|
||||
// expose. Every function is scoped to a single organization.
|
||||
|
||||
export type PatientRow = typeof patients.$inferSelect;
|
||||
export type LabRow = typeof labs.$inferSelect;
|
||||
export type AllergyRow = typeof allergies.$inferSelect;
|
||||
export type ProblemRow = typeof problems.$inferSelect;
|
||||
export type EncounterRow = typeof encounters.$inferSelect;
|
||||
export type PrescriptionRow = typeof prescriptions.$inferSelect;
|
||||
export type AppointmentRow = typeof appointments.$inferSelect;
|
||||
|
||||
// --- Patient ----------------------------------------------------------------
|
||||
|
||||
// Paginated Patient search. `identifier` matches the MRN (file number) exactly;
|
||||
// `name` is a case-insensitive substring. Returns the page plus the full total
|
||||
// for the searchset Bundle.
|
||||
export async function searchPatients(
|
||||
orgId: string,
|
||||
opts: { identifier?: string; name?: string; limit: number; offset: number },
|
||||
): Promise<{ rows: PatientRow[]; total: number }> {
|
||||
const filters: SQL[] = [eq(patients.organizationId, orgId)];
|
||||
if (opts.identifier) filters.push(eq(patients.fileNumber, opts.identifier));
|
||||
if (opts.name) filters.push(ilike(patients.name, `%${opts.name}%`));
|
||||
const where = and(...filters);
|
||||
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(patients)
|
||||
.where(where)
|
||||
.orderBy(asc(patients.fileNumber))
|
||||
.limit(opts.limit)
|
||||
.offset(opts.offset),
|
||||
db.select({ value: count() }).from(patients).where(where),
|
||||
]);
|
||||
|
||||
return { rows, total: totalRow?.value ?? 0 };
|
||||
}
|
||||
|
||||
// A single patient by FHIR logical id (the row UUID), scoped to the org.
|
||||
export async function patientById(
|
||||
orgId: string,
|
||||
id: string,
|
||||
): Promise<PatientRow | undefined> {
|
||||
// Guard against a non-UUID id: Postgres would otherwise error on the cast.
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) return undefined;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(patients)
|
||||
.where(and(eq(patients.organizationId, orgId), eq(patients.id, id)))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
// Resolve a `patient` search parameter to a patient row. Accepts either the FHIR
|
||||
// logical id (`patient=<uuid>`) or the MRN (`patient.identifier=<file#>`).
|
||||
export async function resolvePatientRef(
|
||||
orgId: string,
|
||||
ref: { patientId?: string; identifier?: string },
|
||||
): Promise<PatientRow | undefined> {
|
||||
if (ref.patientId) {
|
||||
// A reference may arrive as "Patient/<id>" or a bare id.
|
||||
const id = ref.patientId.replace(/^Patient\//, "");
|
||||
return patientById(orgId, id);
|
||||
}
|
||||
if (ref.identifier) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
eq(patients.organizationId, orgId),
|
||||
eq(patients.fileNumber, ref.identifier),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- Clinical child rows (by patient UUID) ----------------------------------
|
||||
|
||||
export function labsForPatient(patientId: string): Promise<LabRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(labs)
|
||||
.where(eq(labs.patientId, patientId))
|
||||
.orderBy(asc(labs.position));
|
||||
}
|
||||
|
||||
export function allergiesForPatient(patientId: string): Promise<AllergyRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(allergies)
|
||||
.where(eq(allergies.patientId, patientId))
|
||||
.orderBy(asc(allergies.position));
|
||||
}
|
||||
|
||||
export function problemsForPatient(patientId: string): Promise<ProblemRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(problems)
|
||||
.where(eq(problems.patientId, patientId))
|
||||
.orderBy(asc(problems.position));
|
||||
}
|
||||
|
||||
export function encountersForPatient(
|
||||
patientId: string,
|
||||
): Promise<EncounterRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(encounters)
|
||||
.where(eq(encounters.patientId, patientId))
|
||||
.orderBy(asc(encounters.position));
|
||||
}
|
||||
|
||||
// --- Denormalized resources (linked to the patient by MRN / file number) ----
|
||||
|
||||
export function prescriptionsForFile(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
): Promise<PrescriptionRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(prescriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(prescriptions.organizationId, orgId),
|
||||
eq(prescriptions.patientFileNumber, fileNumber),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${prescriptions.prescribedAt} desc`);
|
||||
}
|
||||
|
||||
export function appointmentsForFile(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
): Promise<AppointmentRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.organizationId, orgId),
|
||||
eq(appointments.patientFileNumber, fileNumber),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${appointments.date} desc, ${appointments.time} desc`);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { LabFlag } from "../../types/patient.js";
|
||||
import type {
|
||||
AllergyRow,
|
||||
AppointmentRow,
|
||||
EncounterRow,
|
||||
LabRow,
|
||||
PatientRow,
|
||||
PrescriptionRow,
|
||||
ProblemRow,
|
||||
} from "./queries.js";
|
||||
|
||||
// Pure mappers from temetro rows to FHIR R4 JSON. temetro stores clinical values
|
||||
// as **free text** (no SNOMED/LOINC coding), so every CodeableConcept here is
|
||||
// `text`-only — valid FHIR, deliberately un-coded (documented in the
|
||||
// CapabilityStatement and API docs). Resource ids are the rows' own UUIDs so
|
||||
// they are stable; synthesized vital-sign Observations derive their id from the
|
||||
// patient UUID.
|
||||
|
||||
export type FhirResource = {
|
||||
resourceType: string;
|
||||
id?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// System URIs.
|
||||
const MRN_SYSTEM = "urn:temetro:mrn";
|
||||
const INTERPRETATION_SYSTEM =
|
||||
"http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation";
|
||||
const OBS_CATEGORY_SYSTEM =
|
||||
"http://terminology.hl7.org/CodeSystem/observation-category";
|
||||
const AGE_EXTENSION =
|
||||
"https://temetro.app/fhir/StructureDefinition/patient-age-years";
|
||||
|
||||
// A FHIR dateTime from our stored strings. Passes date-only values (`YYYY-MM-DD`)
|
||||
// through unchanged (valid FHIR dateTime), otherwise parses display strings like
|
||||
// "Jun 28, 2025" to a full instant. Returns undefined when unparseable.
|
||||
function fhirDateTime(value: string | null | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
|
||||
const parsed = new Date(trimmed);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||
}
|
||||
|
||||
function humanName(full: string): Record<string, unknown>[] {
|
||||
const parts = full.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) return [{ text: full }];
|
||||
return [{ text: full, family: parts.at(-1), given: parts.slice(0, -1) }];
|
||||
}
|
||||
|
||||
function subjectRef(patient: PatientRow) {
|
||||
return { reference: `Patient/${patient.id}`, display: patient.name };
|
||||
}
|
||||
|
||||
// --- Patient ----------------------------------------------------------------
|
||||
|
||||
export function patientResource(row: PatientRow): FhirResource {
|
||||
return {
|
||||
resourceType: "Patient",
|
||||
id: row.id,
|
||||
identifier: [{ system: MRN_SYSTEM, value: row.fileNumber }],
|
||||
active: row.status !== "discharged",
|
||||
name: humanName(row.name),
|
||||
gender: row.sex === "M" ? "male" : "female",
|
||||
// temetro records age, not date of birth; expose it as an extension rather
|
||||
// than fabricate a birthDate.
|
||||
extension: [{ url: AGE_EXTENSION, valueInteger: row.age }],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Observation ------------------------------------------------------------
|
||||
|
||||
function interpretation(flag: LabFlag) {
|
||||
const map: Record<LabFlag, { code: string; display: string }> = {
|
||||
normal: { code: "N", display: "Normal" },
|
||||
high: { code: "H", display: "High" },
|
||||
low: { code: "L", display: "Low" },
|
||||
critical: { code: "HH", display: "Critical high" },
|
||||
};
|
||||
const { code, display } = map[flag];
|
||||
return [{ coding: [{ system: INTERPRETATION_SYSTEM, code, display }] }];
|
||||
}
|
||||
|
||||
export function labObservation(row: LabRow, patient: PatientRow): FhirResource {
|
||||
const effective = fhirDateTime(row.takenAt);
|
||||
return {
|
||||
resourceType: "Observation",
|
||||
id: row.id,
|
||||
status: "final",
|
||||
category: [
|
||||
{
|
||||
coding: [
|
||||
{
|
||||
system: OBS_CATEGORY_SYSTEM,
|
||||
code: "laboratory",
|
||||
display: "Laboratory",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
code: { text: row.name },
|
||||
subject: subjectRef(patient),
|
||||
...(effective ? { effectiveDateTime: effective } : {}),
|
||||
valueString: row.value,
|
||||
interpretation: interpretation(row.flag),
|
||||
};
|
||||
}
|
||||
|
||||
// Synthesize vital-sign Observations from the denormalized columns on the
|
||||
// patient row. Returns an empty array when vitals are blank (e.g. a
|
||||
// reception-registered patient with clinical fields stripped).
|
||||
export function vitalObservations(patient: PatientRow): FhirResource[] {
|
||||
const effective = fhirDateTime(patient.vitalsTakenAt);
|
||||
const base = (idSuffix: string, text: string) => ({
|
||||
resourceType: "Observation" as const,
|
||||
id: `${patient.id}-vital-${idSuffix}`,
|
||||
status: "final",
|
||||
category: [
|
||||
{
|
||||
coding: [
|
||||
{
|
||||
system: OBS_CATEGORY_SYSTEM,
|
||||
code: "vital-signs",
|
||||
display: "Vital Signs",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
code: { text },
|
||||
subject: subjectRef(patient),
|
||||
...(effective ? { effectiveDateTime: effective } : {}),
|
||||
});
|
||||
|
||||
const out: FhirResource[] = [];
|
||||
|
||||
if (patient.vitalsBp) {
|
||||
const bp = base("bp", "Blood pressure");
|
||||
const m = /^(\d+)\s*\/\s*(\d+)/.exec(patient.vitalsBp.trim());
|
||||
if (m) {
|
||||
out.push({
|
||||
...bp,
|
||||
component: [
|
||||
{
|
||||
code: { text: "Systolic blood pressure" },
|
||||
valueQuantity: { value: Number(m[1]), unit: "mmHg" },
|
||||
},
|
||||
{
|
||||
code: { text: "Diastolic blood pressure" },
|
||||
valueQuantity: { value: Number(m[2]), unit: "mmHg" },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
out.push({ ...bp, valueString: patient.vitalsBp });
|
||||
}
|
||||
}
|
||||
if (patient.vitalsHr)
|
||||
out.push({ ...base("hr", "Heart rate"), valueString: patient.vitalsHr });
|
||||
if (patient.vitalsTemp)
|
||||
out.push({
|
||||
...base("temp", "Body temperature"),
|
||||
valueString: patient.vitalsTemp,
|
||||
});
|
||||
if (patient.vitalsSpo2)
|
||||
out.push({
|
||||
...base("spo2", "Oxygen saturation"),
|
||||
valueString: patient.vitalsSpo2,
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- AllergyIntolerance -----------------------------------------------------
|
||||
|
||||
export function allergyResource(
|
||||
row: AllergyRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
return {
|
||||
resourceType: "AllergyIntolerance",
|
||||
id: row.id,
|
||||
clinicalStatus: {
|
||||
coding: [
|
||||
{
|
||||
system:
|
||||
"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
|
||||
code: "active",
|
||||
},
|
||||
],
|
||||
},
|
||||
code: { text: row.substance },
|
||||
patient: subjectRef(patient),
|
||||
criticality: row.severity === "severe" ? "high" : "low",
|
||||
reaction: [
|
||||
{ manifestation: [{ text: row.reaction }], severity: row.severity },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Condition --------------------------------------------------------------
|
||||
|
||||
export function conditionResource(
|
||||
row: ProblemRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
return {
|
||||
resourceType: "Condition",
|
||||
id: row.id,
|
||||
clinicalStatus: {
|
||||
coding: [
|
||||
{
|
||||
system: "http://terminology.hl7.org/CodeSystem/condition-clinical",
|
||||
code: "active",
|
||||
},
|
||||
],
|
||||
},
|
||||
code: { text: row.label },
|
||||
subject: subjectRef(patient),
|
||||
...(row.since ? { onsetString: row.since } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// --- MedicationRequest ------------------------------------------------------
|
||||
|
||||
export function medicationRequestResource(
|
||||
row: PrescriptionRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
const status =
|
||||
row.status === "completed"
|
||||
? "completed"
|
||||
: row.status === "expired"
|
||||
? "stopped"
|
||||
: "active";
|
||||
const dosageText = [row.dose, row.frequency].filter(Boolean).join(" ").trim();
|
||||
return {
|
||||
resourceType: "MedicationRequest",
|
||||
id: row.id,
|
||||
status,
|
||||
intent: "order",
|
||||
medicationCodeableConcept: { text: row.medication },
|
||||
subject: subjectRef(patient),
|
||||
...(row.prescribedAt ? { authoredOn: row.prescribedAt } : {}),
|
||||
requester: { display: row.prescriber },
|
||||
...(dosageText ? { dosageInstruction: [{ text: dosageText }] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Encounter --------------------------------------------------------------
|
||||
|
||||
function narrative(text: string): Record<string, unknown> {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return {
|
||||
status: "generated",
|
||||
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escaped}</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function encounterResource(
|
||||
row: EncounterRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
const start = fhirDateTime(row.date);
|
||||
return {
|
||||
resourceType: "Encounter",
|
||||
id: row.id,
|
||||
...(row.summary ? { text: narrative(row.summary) } : {}),
|
||||
status: "finished",
|
||||
class: {
|
||||
system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
|
||||
code: "AMB",
|
||||
display: "ambulatory",
|
||||
},
|
||||
type: [{ text: row.type }],
|
||||
subject: subjectRef(patient),
|
||||
...(start ? { period: { start } } : {}),
|
||||
participant: [{ individual: { display: row.provider } }],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Appointment ------------------------------------------------------------
|
||||
|
||||
export function appointmentResource(
|
||||
row: AppointmentRow,
|
||||
patient: PatientRow,
|
||||
): FhirResource {
|
||||
const statusMap: Record<string, string> = {
|
||||
confirmed: "booked",
|
||||
"checked-in": "arrived",
|
||||
completed: "fulfilled",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
// Combine local date + time into an instant; omit when unparseable.
|
||||
const startDate =
|
||||
row.date && row.time ? new Date(`${row.date}T${row.time}:00`) : null;
|
||||
const start =
|
||||
startDate && !Number.isNaN(startDate.getTime())
|
||||
? startDate.toISOString()
|
||||
: undefined;
|
||||
return {
|
||||
resourceType: "Appointment",
|
||||
id: row.id,
|
||||
status: statusMap[row.status] ?? "booked",
|
||||
description: row.type,
|
||||
...(start ? { start } : {}),
|
||||
participant: [
|
||||
{ actor: subjectRef(patient), status: "accepted" },
|
||||
...(row.provider
|
||||
? [{ actor: { display: row.provider }, status: "accepted" }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -570,6 +570,46 @@ export async function appendLabs(
|
||||
return getPatient(orgId, fileNumber);
|
||||
}
|
||||
|
||||
// Append a single encounter (visit note) without touching the rest of the
|
||||
// record — used by the ambient AI scribe, which drafts one note at a time and
|
||||
// must not go through updatePatient's wholesale child replacement. Position
|
||||
// continues after the current max so the new note sorts last.
|
||||
export async function appendEncounter(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
entry: Encounter,
|
||||
): Promise<Patient | null> {
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: patients.id })
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
eq(patients.organizationId, orgId),
|
||||
eq(patients.fileNumber, fileNumber),
|
||||
),
|
||||
);
|
||||
if (!existing) return false;
|
||||
|
||||
const [pos] = await tx
|
||||
.select({ max: sql<number>`coalesce(max(${encounters.position}), -1)` })
|
||||
.from(encounters)
|
||||
.where(eq(encounters.patientId, existing.id));
|
||||
await tx.insert(encounters).values({
|
||||
patientId: existing.id,
|
||||
position: (pos?.max ?? -1) + 1,
|
||||
...entry,
|
||||
});
|
||||
await tx
|
||||
.update(patients)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(patients.id, existing.id));
|
||||
return true;
|
||||
});
|
||||
if (!inserted) return null;
|
||||
return getPatient(orgId, fileNumber);
|
||||
}
|
||||
|
||||
// Remove a single lab result from a patient, identified by its
|
||||
// name/value/takenAt (the frontend has no row id). Scoped to the org via the
|
||||
// owning patient. Returns the reloaded patient, or null when the chart is gone.
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Tell the relay to expect a device response for `requestId` and route it back
|
||||
// to this clinic — used by **QR pairing**, where there's no wallet number to
|
||||
// `wallet:send` to yet, so nothing would otherwise register the request.
|
||||
export function expectResponse(orgId: string, requestId: string): void {
|
||||
hubs.get(orgId)?.emit("hub:expect", { requestId });
|
||||
}
|
||||
|
||||
// Open (and authenticate) a hub connection for a clinic, if not already open.
|
||||
// Idempotent — safe to call on startup, when an org joins the network, and
|
||||
// before generating a pairing QR. The socket auto-reconnects on its own, so an
|
||||
// existing entry is left as-is.
|
||||
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 },
|
||||
async (ack: { ok?: boolean } | undefined) => {
|
||||
if (!ack?.ok) {
|
||||
console.warn(`Temetro Network: relay rejected clinic ${orgId}`);
|
||||
return;
|
||||
}
|
||||
console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`);
|
||||
// The relay keeps routing state in memory, so re-register this clinic's
|
||||
// still-pending requests — restores QR-pairing / share routing after a
|
||||
// relay restart or a reconnect.
|
||||
try {
|
||||
for (const requestId of await walletShare.pendingRequestIds(orgId)) {
|
||||
expectResponse(orgId, requestId);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
);
|
||||
} 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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -137,6 +137,22 @@ export async function listShareRequests(
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
// Ids of this clinic's still-pending share/pairing requests. Used to re-register
|
||||
// them with the relay when the clinic's hub (re)connects (the relay keeps
|
||||
// routing state in memory, so it's lost on a relay restart / redeploy).
|
||||
export async function pendingRequestIds(orgId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ id: walletShareRequests.id })
|
||||
.from(walletShareRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
eq(walletShareRequests.status, "pending"),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// Apply a response relayed back from the patient's device. On approval we
|
||||
// decrypt the sealed bundle with the request's ephemeral private key and verify
|
||||
// the wallet's Ed25519 signature over it (provenance: it really came from that
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+3
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
/* Append the Arabic face so Arabic codepoints fall through per-character even
|
||||
in LTR locales; Latin text still renders in Inter. See app/layout.tsx. */
|
||||
--font-sans: var(--font-sans), var(--font-arabic);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-heading: var(--font-heading);
|
||||
--font-heading: var(--font-heading), var(--font-arabic);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
+30
-4
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist_Mono, Inter } from "next/font/google";
|
||||
import { Geist_Mono, IBM_Plex_Sans_Arabic, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
@@ -10,6 +10,27 @@ import { ToastProvider } from "@/components/ui/toast";
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const interHeading = Inter({ subsets: ["latin"], variable: "--font-heading" });
|
||||
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||
// Arabic-capable fallback: Inter has no Arabic glyphs, so we append this to the
|
||||
// sans/heading stacks (see globals.css) for per-character fallback in every
|
||||
// locale, and rely on it fully when dir="rtl". Not a variable font — pin weights.
|
||||
const plexArabic = IBM_Plex_Sans_Arabic({
|
||||
subsets: ["arabic"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-arabic",
|
||||
});
|
||||
|
||||
// Runs before first paint: mirror lib/i18n/config.ts `dirFor` so an Arabic user
|
||||
// gets dir="rtl" immediately instead of a flash of LTR. Detection order matches
|
||||
// i18next-browser-languagedetector (localStorage key "i18nextLng", then the
|
||||
// browser language). suppressHydrationWarning on <html> ignores the attr diff.
|
||||
const setInitialDir = `
|
||||
(function(){try{
|
||||
var l=localStorage.getItem("i18nextLng")||navigator.language||"en";
|
||||
var e=document.documentElement;
|
||||
e.lang=l;
|
||||
e.dir=l.indexOf("ar")===0?"rtl":"ltr";
|
||||
}catch(_){}})();
|
||||
`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "temetro — AI assistant for clinicians",
|
||||
@@ -33,17 +54,22 @@ export default function RootLayout({
|
||||
inter.variable,
|
||||
interHeading.variable,
|
||||
geistMono.variable,
|
||||
plexArabic.variable,
|
||||
"font-sans"
|
||||
)}
|
||||
>
|
||||
{/* suppressHydrationWarning: next-themes sets the theme class on <html>
|
||||
before hydration, and browser extensions (e.g. ColorZilla's
|
||||
cz-shortcut-listen) mutate <body>. Only ignores attribute diffs on
|
||||
those elements, not their children. */}
|
||||
before hydration, the dir script below sets lang/dir, and browser
|
||||
extensions (e.g. ColorZilla's cz-shortcut-listen) mutate <body>. Only
|
||||
ignores attribute diffs on those elements, not their children. */}
|
||||
<body
|
||||
className="h-dvh overflow-hidden flex flex-col"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<script
|
||||
// Sets <html dir/lang> before paint; see setInitialDir above.
|
||||
dangerouslySetInnerHTML={{ __html: setInitialDir }}
|
||||
/>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
|
||||
@@ -104,7 +104,7 @@ function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="shrink-0 text-muted-foreground text-xs">{label}</span>
|
||||
<span className="text-right text-foreground text-sm">{value}</span>
|
||||
<span className="text-end text-foreground text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,7 +212,7 @@ export function ActivityView() {
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"-mx-2 flex-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-accent/40",
|
||||
"-mx-2 flex-1 rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
|
||||
isLast ? "pb-1" : "mb-5",
|
||||
)}
|
||||
onClick={() => setSelected(entry)}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function TrendCard({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left"
|
||||
className="w-full text-start"
|
||||
disabled={!hasData}
|
||||
onClick={() => setOpen(true)}
|
||||
type="button"
|
||||
|
||||
@@ -314,9 +314,9 @@ export function AppointmentsView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("appointments.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function CalendarDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
<ChevronLeft className="rtl:rotate-180" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Next month"
|
||||
@@ -154,7 +154,7 @@ export function CalendarDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight />
|
||||
<ChevronRight className="rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ export function CalendarDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-left align-top transition-colors hover:bg-accent/50",
|
||||
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-start align-top transition-colors hover:bg-accent/50",
|
||||
inMonth
|
||||
? "bg-card/30"
|
||||
: "bg-transparent text-muted-foreground/40",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
import { useAiAccess } from "@/lib/ai-policy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { applyStoredLanguage } from "@/lib/language";
|
||||
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
|
||||
|
||||
// Authoritative client-side gate for the app shell. Requires a session and an
|
||||
@@ -24,6 +25,16 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
|
||||
const hasUser = Boolean(session?.user);
|
||||
const activeOrgId = session?.session?.activeOrganizationId ?? null;
|
||||
|
||||
// Adopt the language saved on the backend once signed in, so the UI language
|
||||
// roams across devices. Best-effort and one-shot; localStorage stays the
|
||||
// offline source of truth.
|
||||
const languageSynced = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasUser || languageSynced.current) return;
|
||||
languageSynced.current = true;
|
||||
void applyStoredLanguage();
|
||||
}, [hasUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
if (!hasUser) {
|
||||
|
||||
@@ -269,7 +269,7 @@ export function ActionPreviewCard({
|
||||
</span>
|
||||
{editable ? (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
onClick={() => setEditOpen(true)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AiSetupNotice() {
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("chat.setupNotice.dismiss")}
|
||||
className="-mr-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setDismissed(true)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function BatchActionPreviewCard({
|
||||
<span className="font-medium text-sm">
|
||||
{t("chat.actionCard.batch.title", { count: items.length })}
|
||||
</span>
|
||||
<Badge className="ml-auto gap-1" variant="secondary">
|
||||
<Badge className="ms-auto gap-1" variant="secondary">
|
||||
<Sparkles className="size-3" />
|
||||
AI
|
||||
</Badge>
|
||||
|
||||
@@ -104,9 +104,9 @@ export function ChatHistoryPanel() {
|
||||
{t("chat.history.startNew")}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-2.5 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
className="ps-8"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("chat.history.search")}
|
||||
value={query}
|
||||
@@ -123,7 +123,7 @@ export function ChatHistoryPanel() {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||
"group flex items-center gap-2 rounded-md px-2 py-2 text-start text-sm transition-colors hover:bg-accent",
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground",
|
||||
|
||||
@@ -242,7 +242,7 @@ export function ChatInput({
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className={cn(contextPill, "ml-0.5")}
|
||||
className={cn(contextPill, "ms-0.5")}
|
||||
onClick={() => {
|
||||
setAddKey((k) => k + 1);
|
||||
setAddOpen(true);
|
||||
@@ -258,7 +258,7 @@ export function ChatInput({
|
||||
<ModePicker
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
triggerClassName={cn(pillButton, "mr-1")}
|
||||
triggerClassName={cn(pillButton, "me-1")}
|
||||
/>
|
||||
<button
|
||||
aria-label={
|
||||
|
||||
@@ -421,7 +421,7 @@ export function ChatPanel() {
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("chat.error.dismiss")}
|
||||
className="-mr-1 shrink-0 rounded-md p-1 text-destructive-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive-foreground"
|
||||
className="-me-1 shrink-0 rounded-md p-1 text-destructive-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive-foreground"
|
||||
onClick={() => setErrorDismissed(true)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -196,7 +196,7 @@ export function ImportPreviewCard({
|
||||
<span className="text-sm font-medium">{t("chat.importCard.title")}</span>
|
||||
{status === "pending" && records.length > 0 ? (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
onClick={() => setReviewOpen(true)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -289,7 +289,7 @@ export function ImportPreviewCard({
|
||||
t("chat.importCard.unnamed");
|
||||
return (
|
||||
<button
|
||||
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3 text-left transition-colors hover:bg-accent"
|
||||
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3 text-start transition-colors hover:bg-accent"
|
||||
key={index}
|
||||
onClick={() => setEditingIndex(index)}
|
||||
type="button"
|
||||
|
||||
@@ -16,7 +16,7 @@ export function InventoryListCard({ items }: { items: InventoryItem[] }) {
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Boxes className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{t("chat.lists.inventory")}</span>
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
<Badge className="ms-auto" variant="secondary">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -71,11 +71,11 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
// plus a subtle clickable affordance (they open a detail dialog). Compact cards
|
||||
// size to their own (short) content — see `items-start` in PatientResult.
|
||||
const rowCard =
|
||||
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
"w-72 shrink-0 cursor-pointer gap-0 text-start outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
// Same footprint as `rowCard` but with no clickable affordance — used when a
|
||||
// card has nothing extra to reveal, so it shouldn't promise "Click for more".
|
||||
const rowCardStatic = "w-72 shrink-0 gap-0 text-left";
|
||||
const rowCardStatic = "w-72 shrink-0 gap-0 text-start";
|
||||
|
||||
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
|
||||
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
|
||||
@@ -196,7 +196,7 @@ function ExpandableCard({
|
||||
{children}
|
||||
<div className="flex items-center gap-1 px-4 pt-2 pb-3 text-muted-foreground text-xs">
|
||||
{t("patientCard.clickForMore")}
|
||||
<ArrowRight className="size-3" />
|
||||
<ArrowRight className="size-3 rtl:rotate-180" />
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
|
||||
|
||||
@@ -724,7 +724,7 @@ export function PatientFormDialog({
|
||||
|
||||
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
{error && (
|
||||
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
|
||||
<p className="text-sm text-destructive sm:me-auto">{error}</p>
|
||||
)}
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("patientForm.cancel")}
|
||||
|
||||
@@ -31,7 +31,7 @@ function Shell({
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
<Badge className="ms-auto" variant="secondary">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@ export function AppointmentListCard({
|
||||
{range ? (
|
||||
<span className="text-muted-foreground text-xs">{range}</span>
|
||||
) : null}
|
||||
<Badge className="ml-auto" variant="secondary">
|
||||
<Badge className="ms-auto" variant="secondary">
|
||||
{appointments.length}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ export function AppointmentListCard({
|
||||
</span>
|
||||
<Button render={<Link href={href} />} size="sm" variant="ghost">
|
||||
{t("chat.lists.viewInCalendar")}
|
||||
<ChevronRight className="size-4" />
|
||||
<ChevronRight className="size-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type * as React from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import i18n from "@/lib/i18n/config";
|
||||
import i18n, { dirFor } from "@/lib/i18n/config";
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
// Keep <html lang/dir> in sync with the active language. The inline script in
|
||||
// app/layout.tsx sets these before first paint (avoiding an RTL flash); this
|
||||
// effect keeps them correct after hydration and on every language switch.
|
||||
useEffect(() => {
|
||||
const apply = (lng: string) => {
|
||||
const root = document.documentElement;
|
||||
root.lang = lng;
|
||||
root.dir = dirFor(lng);
|
||||
};
|
||||
apply(i18n.resolvedLanguage ?? i18n.language);
|
||||
i18n.on("languageChanged", apply);
|
||||
return () => {
|
||||
i18n.off("languageChanged", apply);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function InvoiceDetailSheet({
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{invoice.number}
|
||||
<AiBadge source={invoice.source} />
|
||||
<Badge className="ml-auto" variant={statusVariant[invoice.status]}>
|
||||
<Badge className="ms-auto" variant={statusVariant[invoice.status]}>
|
||||
{t(`invoices.status.${invoice.status}`)}
|
||||
</Badge>
|
||||
</SheetTitle>
|
||||
@@ -222,7 +222,7 @@ export function InvoiceDetailSheet({
|
||||
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||
{li.quantity} × {formatMoney(li.unitPrice)}
|
||||
</span>
|
||||
<span className="w-20 shrink-0 text-right font-medium text-foreground tabular-nums">
|
||||
<span className="w-20 shrink-0 text-end font-medium text-foreground tabular-nums">
|
||||
{formatMoney(li.quantity * li.unitPrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -135,9 +135,9 @@ export function InvoicesView() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPage(1);
|
||||
@@ -180,7 +180,7 @@ export function InvoicesView() {
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{pageRows.map((inv) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-start transition-colors hover:bg-accent/50"
|
||||
key={inv.id}
|
||||
onClick={() => openInvoice(inv)}
|
||||
type="button"
|
||||
|
||||
@@ -92,7 +92,7 @@ export function LabIntegrationCard({
|
||||
{t("integrations.fhir.cardTitle")}
|
||||
</h2>
|
||||
<Badge
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
variant={
|
||||
config.status === "connected"
|
||||
? "secondary"
|
||||
@@ -141,9 +141,9 @@ export function LabIntegrationCard({
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("integrations.fhir.searchPlaceholder")}
|
||||
value={query}
|
||||
@@ -153,7 +153,7 @@ export function LabIntegrationCard({
|
||||
<div className="flex flex-col gap-1">
|
||||
{matches.map((p) => (
|
||||
<button
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={p.fileNumber}
|
||||
onClick={() => setSelected(p)}
|
||||
type="button"
|
||||
|
||||
@@ -336,7 +336,7 @@ function AddResultDialog({
|
||||
{t("lab.addResult.patient")}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-activedescendant={
|
||||
matches[activeIndex]
|
||||
@@ -344,7 +344,7 @@ function AddResultDialog({
|
||||
: undefined
|
||||
}
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(event) => {
|
||||
setPatientQuery(event.target.value);
|
||||
setActiveIndex(0);
|
||||
@@ -359,7 +359,7 @@ function AddResultDialog({
|
||||
{matches.map((p, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors",
|
||||
"flex items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors",
|
||||
index === activeIndex
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent",
|
||||
@@ -650,7 +650,7 @@ export function LabView() {
|
||||
}
|
||||
onClick={() => toggle(task.id)}
|
||||
/>
|
||||
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -679,7 +679,7 @@ export function LabView() {
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 px-4 pb-4 pl-12 text-sm">
|
||||
<div className="space-y-3 px-4 pb-4 ps-12 text-sm">
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap",
|
||||
|
||||
@@ -124,7 +124,7 @@ export function LoginForm({
|
||||
{t("auth.login.passwordLabel")}
|
||||
</FieldLabel>
|
||||
<Link
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
className="ms-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
href="/forgot-password"
|
||||
>
|
||||
{t("auth.login.forgotPassword")}
|
||||
|
||||
@@ -285,10 +285,10 @@ export function MeetingRoom({
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("meetings.invite.search")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setMemberQuery(e.target.value)}
|
||||
placeholder={t("meetings.invite.search")}
|
||||
size="sm"
|
||||
|
||||
@@ -246,13 +246,13 @@ export function MeetingsView() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1 rounded-lg pr-1 transition-colors hover:bg-accent/50",
|
||||
"group flex w-full items-center gap-1 rounded-lg pe-1 transition-colors hover:bg-accent/50",
|
||||
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={room.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-start"
|
||||
onClick={() => setActiveRoom(room)}
|
||||
type="button"
|
||||
>
|
||||
@@ -352,7 +352,7 @@ export function MeetingsView() {
|
||||
<div className="flex flex-col gap-1">
|
||||
{upcoming.map((e) => (
|
||||
<button
|
||||
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-start transition-colors hover:bg-accent/50"
|
||||
key={e.id}
|
||||
onClick={() => setSelectedDay(new Date(`${e.date}T00:00:00`))}
|
||||
type="button"
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ScheduleMeetingDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent/50",
|
||||
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50",
|
||||
on && "bg-accent",
|
||||
)}
|
||||
key={m.id}
|
||||
|
||||
@@ -28,7 +28,7 @@ function Row({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-muted-foreground text-sm">{label}</dt>
|
||||
<dd className="text-right text-foreground text-sm">{value}</dd>
|
||||
<dd className="text-end text-foreground text-sm">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ function SentAttachment({ att }: { att: MessageAttachment }) {
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{att.fileName}</AttachmentTitle>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions className="pr-1.5">
|
||||
<AttachmentActions className="pe-1.5">
|
||||
<AttachmentAction
|
||||
aria-label={t("messages.attach.download")}
|
||||
onClick={() => {
|
||||
@@ -491,10 +491,10 @@ export function MessagesView() {
|
||||
</div>
|
||||
<div className="border-border border-b px-3 py-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setInboxQuery(e.target.value)}
|
||||
placeholder={t("messages.searchPlaceholder")}
|
||||
size="sm"
|
||||
@@ -520,7 +520,7 @@ export function MessagesView() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 rounded-lg pr-2 transition-colors hover:bg-accent/50",
|
||||
"flex w-full items-center gap-1 rounded-lg pe-2 transition-colors hover:bg-accent/50",
|
||||
selected?.id === c.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
key={c.id}
|
||||
@@ -544,7 +544,7 @@ export function MessagesView() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pr-1 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pe-1 text-start"
|
||||
onClick={() => open(c.id)}
|
||||
type="button"
|
||||
>
|
||||
@@ -880,10 +880,10 @@ export function MessagesView() {
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.compose.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setMemberQuery(e.target.value)}
|
||||
placeholder={t("messages.compose.searchPlaceholder")}
|
||||
size="sm"
|
||||
@@ -902,7 +902,7 @@ export function MessagesView() {
|
||||
) : (
|
||||
visibleMembers.map((m) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={m.id}
|
||||
onClick={() => startConversation(m.id)}
|
||||
type="button"
|
||||
@@ -932,10 +932,10 @@ export function MessagesView() {
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.attach.apptSearchPlaceholder")}
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(e) => setApptQuery(e.target.value)}
|
||||
placeholder={t("messages.attach.apptSearchPlaceholder")}
|
||||
size="sm"
|
||||
@@ -954,7 +954,7 @@ export function MessagesView() {
|
||||
) : (
|
||||
visibleAppts.map((a) => (
|
||||
<button
|
||||
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={a.id}
|
||||
onClick={() => attachAppointment(a)}
|
||||
type="button"
|
||||
|
||||
@@ -153,7 +153,7 @@ export function NotesView() {
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-start transition-colors hover:bg-accent/50"
|
||||
key={n.id}
|
||||
onClick={() => openNote(n)}
|
||||
type="button"
|
||||
|
||||
@@ -141,6 +141,8 @@ export function ImportFromWalletDialog({
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setError(t("patients.importApp.invalidWallet"));
|
||||
} else if (err instanceof ApiError && err.status === 409) {
|
||||
setError(t("patients.importApp.networkOff"));
|
||||
} else {
|
||||
setError(t("patients.importApp.error"));
|
||||
}
|
||||
@@ -171,8 +173,12 @@ export function ImportFromWalletDialog({
|
||||
setPairUri(`temetro-pair:?${params.toString()}`);
|
||||
setRequest(pairing);
|
||||
setPhase("waiting");
|
||||
} catch {
|
||||
setError(t("patients.importApp.error"));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
setError(t("patients.importApp.networkOff"));
|
||||
} else {
|
||||
setError(t("patients.importApp.error"));
|
||||
}
|
||||
setPhase("error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,9 @@ import { AiBadge } from "@/components/ai-badge";
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
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,
|
||||
@@ -28,8 +30,10 @@ import { type Appointment, listAppointments } from "@/lib/appointments";
|
||||
import { type Invoice, listInvoices } from "@/lib/invoices";
|
||||
import { deletePatient, getPatient, type Patient } from "@/lib/patients";
|
||||
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";
|
||||
|
||||
@@ -76,9 +80,17 @@ export function PatientDetailSheet({
|
||||
// Deleting a chart is destructive — only offer it once we know the role is
|
||||
// a full clinician (patient:delete), never optimistically.
|
||||
const canDelete = role != null && hasClinicalAccess(role);
|
||||
// The ambient scribe writes a clinical note, so it needs full clinical write
|
||||
// access AND the clinic's AI must be enabled for this member.
|
||||
const { allowed: aiAllowed } = useAiAccess();
|
||||
const canScribe = role != null && hasClinicalAccess(role) && aiAllowed;
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
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).
|
||||
@@ -124,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 {
|
||||
@@ -171,6 +198,10 @@ export function PatientDetailSheet({
|
||||
setEditKey((k) => k + 1);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
|
||||
onWalletPush={
|
||||
walletLinked ? () => setWalletPushOpen(true) : undefined
|
||||
}
|
||||
onOpenGraph={() => {
|
||||
onOpenChange(false);
|
||||
setGraphOpen(true);
|
||||
@@ -197,6 +228,23 @@ export function PatientDetailSheet({
|
||||
/>
|
||||
)}
|
||||
|
||||
{patient && (
|
||||
<ScribeDialog
|
||||
onOpenChange={setScribeOpen}
|
||||
onSaved={(updated) => setPatient(updated)}
|
||||
open={scribeOpen}
|
||||
patient={patient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{patient && (
|
||||
<WalletPushDialog
|
||||
onOpenChange={setWalletPushOpen}
|
||||
open={walletPushOpen}
|
||||
patient={patient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{patient && (
|
||||
<TransferPatientDialog
|
||||
onOpenChange={setTransferOpen}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRight, FileDown, Network, Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
FileDown,
|
||||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -173,6 +181,8 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
|
||||
export function PatientDetail({
|
||||
patient,
|
||||
onEdit,
|
||||
onScribe,
|
||||
onWalletPush,
|
||||
onTransfer,
|
||||
onDelete,
|
||||
onOpenGraph,
|
||||
@@ -182,6 +192,10 @@ export function PatientDetail({
|
||||
}: {
|
||||
patient: Patient;
|
||||
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).
|
||||
@@ -287,16 +301,33 @@ export function PatientDetail({
|
||||
{t("patients.transfer.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onScribe && (
|
||||
<Button onClick={onScribe} size="sm" type="button" variant="outline">
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
||||
<Pencil className="size-4" />
|
||||
{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")}
|
||||
className="ml-auto"
|
||||
className="ms-auto"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -358,7 +389,7 @@ export function PatientDetail({
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-card/30">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-start transition-colors hover:bg-accent"
|
||||
key={file.id}
|
||||
onClick={() => setOpenFile(file)}
|
||||
type="button"
|
||||
|
||||
@@ -210,7 +210,7 @@ export function AttachmentsSection({
|
||||
key={attachment.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-start"
|
||||
onClick={() => setPreview(attachment)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -115,9 +115,9 @@ export function PatientsView() {
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPage(1);
|
||||
@@ -159,7 +159,7 @@ export function PatientsView() {
|
||||
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-border border-b text-left text-xs text-muted-foreground uppercase">
|
||||
<tr className="border-border border-b text-start text-xs text-muted-foreground uppercase">
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import { Mic, Square, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, 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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
} from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { uploadAttachment } from "@/lib/attachments";
|
||||
import type { Encounter, Patient } from "@/lib/patients";
|
||||
import { draftNote, saveNote, transcribeRecording } from "@/lib/scribe";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
|
||||
type Phase = "input" | "processing" | "review";
|
||||
type InputTab = "record" | "paste";
|
||||
type RecState = "idle" | "recording" | "recorded";
|
||||
|
||||
// Pick a supported audio mime for MediaRecorder (Opus in WebM/OGG is tiny for
|
||||
// speech; Safari falls back to mp4). Returns "" to let the browser choose.
|
||||
function pickAudioMime(): string {
|
||||
if (typeof MediaRecorder === "undefined") return "";
|
||||
const candidates = [
|
||||
"audio/webm;codecs=opus",
|
||||
"audio/webm",
|
||||
"audio/ogg;codecs=opus",
|
||||
"audio/mp4",
|
||||
];
|
||||
return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
|
||||
}
|
||||
|
||||
function fmtElapsed(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// The ambient AI scribe: record or paste a visit conversation, draft a SOAP
|
||||
// encounter note, review it, and append it to the patient record.
|
||||
export function ScribeDialog({
|
||||
patient,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
patient: Patient;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: (updated: Patient) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("input");
|
||||
const [tab, setTab] = useState<InputTab>("record");
|
||||
const [recState, setRecState] = useState<RecState>("idle");
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const [visitType, setVisitType] = useState("");
|
||||
const [draft, setDraft] = useState<Encounter | null>(null);
|
||||
const [veilNote, setVeilNote] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mediaRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const blobRef = useRef<Blob | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Tear down any live recording + object state when the dialog closes.
|
||||
const stopTracks = () => {
|
||||
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
|
||||
mediaRef.current = null;
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
|
||||
// Stop tracks on unmount.
|
||||
useEffect(() => () => stopTracks(), []);
|
||||
|
||||
// Reset all state for the next open. Called on every close (the parent only
|
||||
// ever closes this dialog through our onOpenChange), so no reset-in-effect.
|
||||
const reset = () => {
|
||||
setPhase("input");
|
||||
setTab("record");
|
||||
setRecState("idle");
|
||||
setElapsed(0);
|
||||
setTranscript("");
|
||||
setVisitType("");
|
||||
setDraft(null);
|
||||
setVeilNote(null);
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
blobRef.current = null;
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
stopTracks();
|
||||
reset();
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mime = pickAudioMime();
|
||||
const recorder = new MediaRecorder(
|
||||
stream,
|
||||
mime ? { mimeType: mime } : undefined,
|
||||
);
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
blobRef.current = new Blob(chunksRef.current, {
|
||||
type: recorder.mimeType || "audio/webm",
|
||||
});
|
||||
setRecState("recorded");
|
||||
};
|
||||
recorder.start();
|
||||
mediaRef.current = recorder;
|
||||
setRecState("recording");
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||
} catch {
|
||||
setError(t("scribe.errors.mic"));
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
mediaRef.current?.stop();
|
||||
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
|
||||
const discardRecording = () => {
|
||||
blobRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setRecState("idle");
|
||||
setElapsed(0);
|
||||
};
|
||||
|
||||
const filenameFor = (blob: Blob): string => {
|
||||
const ext = blob.type.includes("mp4")
|
||||
? "m4a"
|
||||
: blob.type.includes("ogg")
|
||||
? "ogg"
|
||||
: "webm";
|
||||
return `visit-${patient.fileNumber}-${Date.now()}.${ext}`;
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
setError(null);
|
||||
setPhase("processing");
|
||||
try {
|
||||
let text = transcript.trim();
|
||||
if (tab === "record") {
|
||||
const blob = blobRef.current;
|
||||
if (!blob) {
|
||||
setError(t("scribe.errors.noRecording"));
|
||||
setPhase("input");
|
||||
return;
|
||||
}
|
||||
// Store the recording as a patient attachment (auditable), then
|
||||
// transcribe it server-side.
|
||||
const file = new File([blob], filenameFor(blob), { type: blob.type });
|
||||
const attachment = await uploadAttachment({
|
||||
file,
|
||||
fileNumber: patient.fileNumber,
|
||||
labKey: "scribe",
|
||||
});
|
||||
const res = await transcribeRecording(attachment.id);
|
||||
text = res.transcript.trim();
|
||||
setTranscript(text);
|
||||
}
|
||||
if (!text) {
|
||||
setError(t("scribe.errors.empty"));
|
||||
setPhase("input");
|
||||
return;
|
||||
}
|
||||
const { draft: note, veil } = await draftNote({
|
||||
fileNumber: patient.fileNumber,
|
||||
transcript: text,
|
||||
visitType: visitType.trim() || undefined,
|
||||
});
|
||||
setDraft(note);
|
||||
setVeilNote(
|
||||
veil.active ? t("scribe.review.veil", { provider: veil.provider }) : null,
|
||||
);
|
||||
setPhase("review");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
|
||||
);
|
||||
setPhase("input");
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
setPhase("processing");
|
||||
try {
|
||||
const updated = await saveNote(patient.fileNumber, draft);
|
||||
notify.success(t("scribe.saved.title"), patient.name);
|
||||
onSaved(updated);
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
|
||||
);
|
||||
setPhase("review");
|
||||
}
|
||||
};
|
||||
|
||||
const busy = phase === "processing";
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
{t("scribe.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("scribe.subtitle", { name: patient.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
|
||||
{phase === "review" && draft ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="scribe-type">{t("scribe.review.type")}</Label>
|
||||
<Input
|
||||
id="scribe-type"
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, type: e.target.value })
|
||||
}
|
||||
value={draft.type}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="scribe-date">{t("scribe.review.date")}</Label>
|
||||
<Input
|
||||
id="scribe-date"
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, date: e.target.value })
|
||||
}
|
||||
type="date"
|
||||
value={draft.date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="scribe-summary">
|
||||
{t("scribe.review.summary")}
|
||||
</Label>
|
||||
<Textarea
|
||||
className="min-h-56"
|
||||
id="scribe-summary"
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, summary: e.target.value })
|
||||
}
|
||||
value={draft.summary}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("scribe.review.provider", { provider: draft.provider })}
|
||||
</p>
|
||||
{veilNote && (
|
||||
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
|
||||
{veilNote}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
onValueChange={(v) => setTab(v as InputTab)}
|
||||
value={tab}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTab value="record">
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.tabs.record")}
|
||||
</TabsTab>
|
||||
<TabsTab value="paste">{t("scribe.tabs.paste")}</TabsTab>
|
||||
</TabsList>
|
||||
|
||||
<TabsPanel className="pt-3" value="record">
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
{recState === "recording" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<span className="size-2.5 animate-pulse rounded-full bg-destructive" />
|
||||
<span className="font-mono text-lg tabular-nums">
|
||||
{fmtElapsed(elapsed)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={stopRecording}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
{t("scribe.record.stop")}
|
||||
</Button>
|
||||
</>
|
||||
) : recState === "recorded" ? (
|
||||
<>
|
||||
<p className="text-foreground text-sm">
|
||||
{t("scribe.record.ready", {
|
||||
duration: fmtElapsed(elapsed),
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
onClick={discardRecording}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{t("scribe.record.discard")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={startRecording} type="button">
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.record.start")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TabsPanel>
|
||||
|
||||
<TabsPanel className="pt-3" value="paste">
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder={t("scribe.paste.placeholder")}
|
||||
value={transcript}
|
||||
/>
|
||||
</TabsPanel>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="scribe-visit-type">
|
||||
{t("scribe.visitType.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="scribe-visit-type"
|
||||
onChange={(e) => setVisitType(e.target.value)}
|
||||
placeholder={t("scribe.visitType.placeholder")}
|
||||
value={visitType}
|
||||
/>
|
||||
</div>
|
||||
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
|
||||
{t("scribe.consent")}
|
||||
</p>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
{phase === "review" ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => setPhase("input")}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("scribe.review.back")}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={save} type="button">
|
||||
{busy && <Spinner className="size-4" />}
|
||||
{t("scribe.review.save")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("scribe.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
busy ||
|
||||
(tab === "record"
|
||||
? recState !== "recorded"
|
||||
: transcript.trim().length === 0)
|
||||
}
|
||||
onClick={generate}
|
||||
type="button"
|
||||
>
|
||||
{busy && <Spinner className="size-4" />}
|
||||
{busy ? t("scribe.processing") : t("scribe.generate")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ function Row({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-right text-foreground text-sm">{value}</span>
|
||||
<span className="text-end text-foreground text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function InventoryDetailDialog({
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{item.name}</span>
|
||||
<Badge
|
||||
className="ml-auto shrink-0"
|
||||
className="ms-auto shrink-0"
|
||||
variant={availabilityVariant[availability]}
|
||||
>
|
||||
{t(`inventory.availability.${availability}`)}
|
||||
|
||||
@@ -67,7 +67,7 @@ function ItemRow({
|
||||
const descriptor = [item.strength, item.form].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-start transition-colors hover:bg-accent/50"
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -205,9 +205,9 @@ export function InventoryView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("inventory.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -330,9 +330,9 @@ export function PharmacyView() {
|
||||
<p className="text-muted-foreground text-sm">{t("pharmacy.subtitle")}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("pharmacy.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -43,7 +43,7 @@ function Field({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5 text-left">
|
||||
<label className="flex flex-col gap-1.5 text-start">
|
||||
<span className="font-medium text-foreground text-sm">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
@@ -122,7 +122,7 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2">
|
||||
{cards.map((c) => (
|
||||
<button
|
||||
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-left transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-start transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
key={c.step}
|
||||
onClick={() => onPick(c.step)}
|
||||
type="button"
|
||||
@@ -133,7 +133,7 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1 font-semibold text-lg text-foreground">
|
||||
{c.title}
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 rtl:rotate-180" />
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">{c.desc}</span>
|
||||
</span>
|
||||
@@ -151,7 +151,7 @@ function BackButton({ onBack }: { onBack: () => void }) {
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<ArrowLeft className="size-4 rtl:rotate-180" />
|
||||
{t("portal.back")}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -378,11 +378,11 @@ export function AddPrescriptionDialog({
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-autocomplete="list"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
className="ps-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={onPatientKeyDown}
|
||||
placeholder={t("prescriptions.dialog.searchPlaceholder")}
|
||||
@@ -399,7 +399,7 @@ export function AddPrescriptionDialog({
|
||||
matches.map((p, i) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-start transition-colors",
|
||||
i === activeIndex
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent",
|
||||
@@ -442,7 +442,7 @@ export function AddPrescriptionDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
|
||||
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-start transition-colors",
|
||||
i === medIndex ? "bg-accent" : "hover:bg-accent",
|
||||
)}
|
||||
// Use onMouseDown so the pick fires before the input's
|
||||
|
||||
@@ -147,7 +147,7 @@ export function PrescriptionDetailSheet({
|
||||
<SheetFooter>
|
||||
{onDelete && (
|
||||
<Button
|
||||
className="sm:mr-auto"
|
||||
className="sm:me-auto"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
|
||||
@@ -244,9 +244,9 @@ export function PrescriptionsView() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
className="w-full ps-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("prescriptions.searchPlaceholder")}
|
||||
value={query}
|
||||
|
||||
@@ -422,7 +422,7 @@ export function EmployeeDetailDialog({
|
||||
<DialogFooter>
|
||||
{editable && member && (
|
||||
<Button
|
||||
className="sm:mr-auto"
|
||||
className="sm:me-auto"
|
||||
onClick={() => onRemove(member)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
|
||||
@@ -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${
|
||||
@@ -109,7 +149,7 @@ export function SigningPanel() {
|
||||
: t("settings.signing.rotateKey")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<div className="sm:text-end">
|
||||
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{key
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ export function CareTeamPanel({
|
||||
{initials(m.name, m.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="min-w-0 flex-1 text-start">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{m.name || m.email || m.userId}
|
||||
{isSelf && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
<span className="ms-1 text-xs text-muted-foreground">
|
||||
{t("settings.careTeam.you")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
@@ -179,7 +184,7 @@ function IntegrationCard({
|
||||
: t("settings.integrations.test")}
|
||||
</Button>
|
||||
{config.lastSyncAt ? (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
<span className="ms-auto text-xs text-muted-foreground">
|
||||
{t("settings.integrations.lastSync", {
|
||||
when: new Date(config.lastSyncAt).toLocaleString(),
|
||||
})}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function CopyField({
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pr-1 pl-3 sm:w-80">
|
||||
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pe-1 ps-3 sm:w-80">
|
||||
<span className="flex-1 truncate text-sm text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,15 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectPopup,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DeleteAccountDialog } from "@/components/settings/delete-account-dialog";
|
||||
import {
|
||||
CopyField,
|
||||
@@ -17,6 +25,7 @@ import {
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { supportedLanguages } from "@/lib/i18n/config";
|
||||
import { persistLanguage } from "@/lib/language";
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
@@ -68,6 +77,10 @@ export function ProfilePanel() {
|
||||
const [baselineName, setBaselineName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
// A language picked in the Select but not yet applied — its presence opens the
|
||||
// confirmation dialog. The Select stays bound to `activeLang`, so cancelling
|
||||
// (clearing this) automatically reverts the shown selection.
|
||||
const [pendingLang, setPendingLang] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -222,21 +235,23 @@ export function ProfilePanel() {
|
||||
>
|
||||
<SettingsCard className="space-y-1.5 p-5">
|
||||
<FieldLabel>{t("settings.profile.language.label")}</FieldLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{supportedLanguages.map((lng) => (
|
||||
<Button
|
||||
aria-pressed={activeLang === lng}
|
||||
className="rounded-3xl"
|
||||
key={lng}
|
||||
onClick={() => void i18n.changeLanguage(lng)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={activeLang === lng ? "default" : "outline"}
|
||||
>
|
||||
{t(`settings.profile.language.${lng}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value !== activeLang) setPendingLang(value);
|
||||
}}
|
||||
value={activeLang}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{supportedLanguages.map((lng) => (
|
||||
<SelectItem key={lng} value={lng}>
|
||||
{t(`settings.profile.language.${lng}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -316,6 +331,30 @@ export function ProfilePanel() {
|
||||
|
||||
<DeleteAccountDialog onOpenChange={setDeleteOpen} open={deleteOpen} />
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t("settings.profile.language.cancel")}
|
||||
confirmLabel={t("settings.profile.language.confirmCta")}
|
||||
description={
|
||||
pendingLang
|
||||
? t("settings.profile.language.confirmBody", {
|
||||
language: t(`settings.profile.language.${pendingLang}`),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (pendingLang) {
|
||||
void i18n.changeLanguage(pendingLang);
|
||||
void persistLanguage(pendingLang);
|
||||
}
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingLang(null);
|
||||
}}
|
||||
open={pendingLang !== null}
|
||||
title={t("settings.profile.language.confirmTitle")}
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
{dirty ? (
|
||||
<div className="sticky bottom-4 z-10">
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function MobileSidebarTrigger() {
|
||||
return (
|
||||
<Button
|
||||
aria-label={t("nav.openSidebar")}
|
||||
className="fixed top-3 right-3 z-50 size-9 rounded-full bg-background/80 shadow-sm backdrop-blur md:hidden"
|
||||
className="fixed top-3 end-3 z-50 size-9 rounded-full bg-background/80 shadow-sm backdrop-blur md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function DashboardNavigation({ routes }: { routes: Route[] }) {
|
||||
>
|
||||
{route.icon}
|
||||
{!isCollapsed && (
|
||||
<span className="ml-2 flex-1 truncate font-medium text-sm">
|
||||
<span className="ms-2 flex-1 truncate font-medium text-sm">
|
||||
{route.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function NotificationsPopover() {
|
||||
>
|
||||
<BellIcon className="size-5" />
|
||||
{unread > 0 && (
|
||||
<span className="-top-0.5 -right-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
|
||||
<span className="-top-0.5 -end-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -146,13 +146,13 @@ export function NavUser() {
|
||||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="grid flex-1 text-start text-sm leading-tight">
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{secondary}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
<ChevronsUpDown className="ms-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
@@ -167,7 +167,7 @@ export function NavUser() {
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="grid flex-1 text-start text-sm leading-tight">
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{secondary}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function AccordionTrigger({
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180",
|
||||
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-start font-medium text-sm outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
data-slot="accordion-trigger"
|
||||
|
||||
@@ -94,8 +94,8 @@ const bubbleReactionsVariants = cva(
|
||||
bottom: "bottom-0 translate-y-3/4",
|
||||
},
|
||||
align: {
|
||||
start: "left-3",
|
||||
end: "right-3",
|
||||
start: "start-3",
|
||||
end: "end-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user