Compare commits

...

15 Commits

Author SHA1 Message Date
Khalid Abdi b33561d7e1 chore: release v0.11.0
Bump root/backend/frontend to 0.11.0 and record the 0.11.0 changelog:
relay-routed Patient Portal + wallet linking, phone-ready portal QR,
appointments/invoices synced to the wallet, clinic-location reverse geocoding,
and the Arabic RTL sidebar fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:15:28 +03:00
Khalid Abdi 947623a691 frontend: relay-based Patient Portal QR + kiosk "Link wallet" option
- lib/portal.ts: getPortalLink + portalPairingUri build a temetro-portal: URI
  (relay URL + clinic signing key) instead of a localhost API URL.
- Signing settings QR now encodes that pairing URI, so a real phone can reach
  the clinic over the Temetro Network relay (fixes "server cannot be accessed").
- Portal kiosk gains a third "Link my wallet" option that shows the same QR.
- New portal.choose.wallet* / portal.wallet.* keys in all 5 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:06:04 +03:00
Khalid Abdi dd64d689c8 backend: Patient Portal over the relay + wallet linking
- services/portal.ts: clinic-scoped portal actions (clinic info, doctors,
  availability, linkWallet, conflict-checked booking, results, downloadable lab
  files) plus handlePortalRequest to dispatch a relayed portal:request.
- relay-client.ts: handle portal:request on the hub and ack the result back down
  the relay (device path identifies the patient by verified wallet number).
- patients: new nullable wallet_number column (+ migration); linking stores it,
  and walletNumberForPatient now resolves via it so pushes work after a portal
  link, not only after a permanent share.
- routes/portal.ts: GET /:clinic/link returns the relay-based pairing descriptor
  (clinic signing key + relay URL) for the QR — no more localhost-baked API URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:08:36 +03:00
Khalid Abdi 1c5e71eb39 backend: include appointments & invoices in the wallet push bundle
Appointments and invoices live in their own tables, not on the Patient
snapshot, so a clinic->wallet push previously sealed only the patient record
and the patient's appointments/invoices never reached the wallet app. Load and
attach them to the sealed bundle ({ patient, appointments, invoices, changes }).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:34:27 +03:00
Khalid Abdi fb9fa299c9 frontend: RTL sidebar arrow placement + reverse-geocode clinic location
- Arabic RTL: stack the collapse arrow/bell above the nav icons instead of
  pinning them to the opposite edge; mirror the panel-toggle glyph; flip the
  notifications popover to open toward the content side.
- "Use my current location" now reverse-geocodes (OpenStreetMap Nominatim) to
  fill address/city/country, not just latitude/longitude, with a graceful
  coordinates-only fallback. New settings.location.geoPartial key in all 5 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:32:23 +03:00
Khalid Abdi ab17978b5c feat: portal doctor booking, Arabic RTL fix, wallet portal QR (v0.10.0)
backend: public GET /api/portal/:clinic/doctors and /availability, and
thread a chosen provider into portal bookings (conflict check unchanged).

frontend: fix site-wide Arabic RTL — anchor the sidebar right for RTL and
mirror the Switch thumb. Add a Patient Portal section (open/copy/QR) to
Settings → Signing and a "Use my current location" GPS button to the clinic
location editor. New i18n keys across all five locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:31:20 +03:00
Khalid Abdi 36461a5498 feat: patient blood type & phone + clinic location setting (v0.9.0)
Patient record:
- Add `bloodType` and `phone` to the patient model (schema, canonical types on
  both backend + frontend, zod validation). `phone` is a demographic field
  (reception can read/write); `bloodType` is clinical PHI, redacted for the
  reception role. Surface both in the record sheet, chat summary card, and the
  add/edit patient form. Migration 0033.

Clinic location:
- New org-scoped `clinic_settings` table (address/city/country + optional
  lat/long), service, and routes: GET /api/clinic/settings (any clinician) and
  PUT /api/clinic/location (owner/admin). Edited in Settings → Signing → Clinic
  location. Consumed later by the wallet app. Migration 0034.

i18n:
- Translate all new keys into every shipped locale (en/de/fr/ar/so) and document
  the "translate into every locale" rule in frontend/CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:07:27 +03:00
Khalid Abdi 01dbc07e92 fix: default RELAY_URL to the hosted relay (v0.8.2)
The default http://localhost:8080 silently failed for clinics that joined
the network without setting RELAY_URL — inside Docker localhost is the
container itself, so the hub connection never reached the relay (endless
"relay unreachable" retries) and pairing QRs encoded an unreachable
localhost. Default to https://network.temetro.com so "Join Temetro Network"
works out of the box; self-hosters running their own relay still override
it. Also (re)ensure the hub is connected before pre-registering a pairing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:50:46 +03:00
Khalid Abdi 233ce9f854 fix: QR wallet pairing broken by multi-clinic routing (v0.8.1)
QR "scan to connect" pairing has no wallet number, so nothing registered
the request with the relay and the scanning device's response was rejected
("unknown or expired request"). The backend now pre-registers the pairing
request over the relay's new hub:expect event on POST /pair, and
re-registers still-pending requests on hub (re)connect so routing survives
a relay restart. /pair now requires the clinic to have joined the network
(clear 409, surfaced in the import dialog, localized in all five langs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:16:33 +03:00
Khalid Abdi 99aa534e88 feat: multi-clinic Temetro Network with per-clinic identity (v0.8.0)
The relay is now multi-clinic. Each clinic authenticates to the /hub
namespace by signing a challenge with its own Ed25519 clinic signing key
(a per-clinic identity, not a shared RELAY_TOKEN), and the relay routes
every device response back to only the clinic that originated the request
(keyed by requestId) — so clinics never see each other's traffic.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:34:24 +03:00
Khalid Abdi 0d2494d67a feat: read-only FHIR R4 server (share records over /fhir)
Expose temetro's own records as a read-only FHIR R4 server at /fhir,
authenticated with per-clinic API keys (tmf_… bearer tokens, SHA-256
hashed, shown once). Serves Patient, Observation (labs + vitals),
AllergyIntolerance, Condition, MedicationRequest, Encounter and
Appointment as text-only CodeableConcepts (temetro stores free-text
clinical values); CapabilityStatement at /fhir/metadata (unauth).
Searchset Bundles with _count/_offset pagination and self/next/prev
links; every request is org-scoped and written to the activity log.
Keys are created/revoked under Settings → Integrations (owner/admin).

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:57:38 +03:00
Khalid Abdi b29fdff1cb feat: ambient AI visit scribe (record/paste → reviewed SOAP note)
Record a clinician↔patient visit (or paste a transcript) on the patient
sheet; the backend transcribes it (OpenAI Whisper / Gemini), de-identifies
the transcript + context through Veil, and drafts a structured SOAP note
the clinician reviews and edits before saving — the same write-approval
gate as the chat agent.

Backend: POST /api/scribe/{transcribe,draft,save} (routes/scribe.ts,
services/ai/transcribe.ts), veil.redactText() free-text redactor,
appendEncounter service, audio MIME types on attachments. Gated by
patient:write + the clinic AI policy (reception/disabled-AI excluded).
Frontend: ScribeDialog + lib/scribe.ts, "Record visit" on the patient
detail, gated by clinical access + AI availability. New `scribe` locale
namespace across all five languages. Bumps to v0.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:33:30 +03:00
89 changed files with 33879 additions and 155 deletions
+195
View File
@@ -7,6 +7,201 @@ for how releases are cut and published.
## [Unreleased]
## [0.11.0] — 2026-07-09
### Added
- **Patient Portal over the Temetro Network relay + wallet linking.** The wallet app now reaches a
clinic's Patient Portal through the relay instead of a direct HTTP API, so it works from a real
phone. New `services/portal.ts` (clinic info, doctors, availability, wallet linking, conflict-aware
booking, results, downloadable lab files) runs behind a `portal:request` hub handler
(`backend/src/services/relay-client.ts`). A new nullable `patients.wallet_number` column stores the
link, and `walletNumberForPatient` resolves through it so clinic→wallet pushes work after a portal
link (not only after a permanent share). `GET /api/portal/:clinic/link` returns the relay-based
pairing descriptor (clinic signing key + relay URL).
- **Portal "Link my wallet" option.** The Patient Portal kiosk adds a third card that shows a QR the
wallet app scans to link over the relay (`components/portal/portal-kiosk.tsx`).
- **Appointments & invoices reach the wallet.** Clinic→wallet pushes now include the patient's
appointments and invoices in the sealed bundle, so they show up in the wallet app.
- **Clinic location reverse-geocoding.** "Use my current location" now fills address / city /
country (OpenStreetMap Nominatim), not just latitude / longitude (`lib/geocode.ts`).
### Fixed
- **Patient Portal QR was unreachable from a phone.** Settings → Signing now encodes a
`temetro-portal:` pairing URI (relay URL + clinic signing key) instead of a `localhost` API URL, so
the wallet app can actually connect (`components/settings/settings-portal.tsx`).
- **Arabic (RTL) sidebar.** The collapse arrow and notification bell now stack **above** the nav
icons instead of being pinned to the opposite edge; the toggle glyph mirrors and the notifications
popover opens toward the content side (`components/sidebar-02/app-sidebar.tsx`,
`components/ui/sidebar.tsx`, `components/sidebar-02/nav-notifications.tsx`).
## [0.10.0] — 2026-07-07
### Added
- **Patient Portal doctor picker & availability.** The portal now lists the clinic's doctors and
books against a chosen provider, showing only free slots. New public endpoints
`GET /api/portal/:clinic/doctors` and `GET /api/portal/:clinic/availability?provider=&date=`
(display-safe fields only), and `POST /api/portal/:clinic/appointments` accepts an optional
`provider` (`backend/src/routes/portal.ts`). The existing 409 conflict check stays authoritative.
- **Patient Portal links in Settings.** Settings → Signing → Patient Portal adds **open**, **copy
link**, and **QR code** actions (`components/settings/settings-portal.tsx`); the QR carries the
backend base (`?api=`) so the patient wallet app can book natively when it scans it.
- **Clinic location "Use my current location".** The location editor fills map coordinates from the
browser's geolocation (`components/settings/settings-location.tsx`).
- **Wallet app native Patient Portal.** Scanning a clinic's portal QR opens a native booking screen
(doctor list → free-slot picker → confirm) in the patient wallet app.
### Fixed
- **Arabic (RTL) layout.** The sidebar now anchors to the **right** for RTL locales and the toggle
switch mirrors correctly, instead of leaving the shell misaligned
(`components/sidebar-02/app-sidebar.tsx`, `components/ui/switch.tsx`).
- **Wallet app:** record-card **bottom sheet no longer freezes** the app (dropped the per-frame
animated blur overlay for HeroUI's built-in overlay); **Reset wallet** now confirms in a native
HeroUI dialog with Liquid Glass actions; fixed the **white edge flash** on screen transitions in
dark mode; the home/onboarding/register **logo** is now the Temetro mark.
### Changed
- New i18n keys (`settings.portal.*`, geolocation strings) are translated into **all** shipped
locales (en, de, fr, ar, so).
## [0.9.0] — 2026-07-06
### Added
- **Patient blood type & phone number.** The patient record now carries a `bloodType` (e.g. `O+`)
and a `phone` number. Both are shown in the record sheet and chat summary card and are editable in
the add/edit patient form. `phone` is a demographic/contact field (visible to and editable by the
**reception** role); `bloodType` is treated as clinical PHI and is **redacted for reception** (like
allergies/vitals). New columns `patients.phone` / `patients.blood_type` (migration `0033`).
- **Clinic location setting.** A new org-scoped `clinic_settings` table (migration `0034`) stores the
clinic's address (address / city / country) plus optional map coordinates (latitude / longitude),
set in **Settings → Signing → Clinic location** (owner/admin only). New endpoints
`GET /api/clinic/settings` (any clinician) and `PUT /api/clinic/location` (owner/admin). This will
be surfaced in the patient wallet app to show a clinic's location.
### Changed
- New i18n keys for the above are translated into **all** shipped locales (en, de, fr, ar, so), per
the coverage rule now documented in `frontend/CLAUDE.md`.
## [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
+28 -5
View File
@@ -27,12 +27,13 @@ repository (published as `temetro`).
> "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow:
> clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`),
> and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over
> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is
> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`.
> the **Temetro Network** relay (see below), the patient approves on their phone, and the sealed record
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
>
> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing,
> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**.
> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
> shares. The AI chat is still **mock replies**. Email verification is wired but currently **not
> enforced** at sign-in (see `backend/CLAUDE.md`).
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
@@ -47,6 +48,28 @@ here means keys + data live on the patient's device and the relay only ever forw
is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be
deleted). Commit/push that app inside its own repo, separately from this one.
## Temetro Network (sibling repo/folder `~/Desktop/Temetro-network`)
The **relay** that connects this backend to patient wallet apps. It is its **own git repo** on the
Desktop (folder `~/Desktop/Temetro-network`, pushed to `github.com/temetro/temetro-network`), **not**
in this monorepo — a standalone **Rust + Axum + socketioxide** service meant to run always-on (e.g.
on **Railway**). It replaces the old flaky Cloudflare quick-tunnel that used to expose the backend's
embedded `/wallet` Socket.io namespace to phones.
It is a **dumb, stateless pipe**: two Socket.io namespaces — `/wallet` for devices
(challenge/Ed25519-signature auth, room keyed by wallet number) and `/hub` for this backend
(`RELAY_TOKEN`-authenticated). Devices and the backend both connect to it; it **forwards sealed
ciphertext verbatim** and never opens bundles or touches a database. Its only crypto is verifying a
device's auth signature (mirrors `backend/src/lib/wallet-crypto.ts`). The backend connects to it as a
`/hub` client via `backend/src/services/relay-client.ts` (its `sendToWallet` is what `emitToWallet`
now calls); configure with `RELAY_URL` + `RELAY_TOKEN`. Commit/push that service inside its own repo,
separately from this one.
> **Note:** in this sandbox the `~/Desktop/Temetro-network` folder blocks directory enumeration
> (`ls`/`getcwd`/git inside it return EPERM) though plain file writes work. Develop/build/commit it
> in an accessible copy and mirror the tree in with `tar`; drive git there via
> `GIT_DIR`/`GIT_WORK_TREE` from an accessible cwd.
## Layout
`frontend/` and `backend/` were previously separate per-folder git repos; they have been **merged
+17 -7
View File
@@ -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) -----------------------------------------------------
+12
View File
@@ -60,6 +60,18 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
- **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`), which is **multi-clinic**.
This backend connects to it as a `/hub` client in **`src/services/relay-client.ts`**, keeping **one
authenticated connection per network-enabled org** (`hubs` map keyed by `orgId`). Each org
authenticates by signing the relay's `hub:challenge` with its clinic signing key
(`signWithClinicKey`) — no shared `RELAY_TOKEN` needed (it's now optional/legacy, only for a
private relay). `emitToWallet(orgId, …)` (realtime.ts) delegates to `sendToWallet(orgId, …)`, and
device responses (`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) +
`wallet:online` replay are handled per-org there, calling the same `wallet-share` /
`wallet-updates` services the old `/wallet` namespace did. A clinic opts in via **"Join Temetro
Network"** (Settings → Signing → `PUT /api/signing/network`, `clinic_signing_keys.network_enabled`);
`connectOrg`/`disconnectOrg` open/close its connection, and wallet routes 409 when it's off.
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
## Gotchas / conventions
+5
View File
@@ -68,6 +68,11 @@ services:
BETTER_AUTH_URL: http://localhost:4000
FRONTEND_URL: http://localhost:3000
PORT: "4000"
# Temetro Network relay (github.com/temetro/temetro-network). Set RELAY_URL
# to your deployed relay's public URL and RELAY_TOKEN to the shared secret
# you configured on it — both are required for patient-wallet import.
RELAY_URL: ${RELAY_URL:-}
RELAY_TOKEN: ${RELAY_TOKEN:-}
NODE_ENV: production
# Uploaded patient/lab files live here, on the temetro_uploads volume.
UPLOAD_DIR: /var/lib/temetro/uploads
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "wallet_record_updates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"created_by" text NOT NULL,
"file_number" text NOT NULL,
"wallet_number" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"payload_sealed" text NOT NULL,
"clinic_signature" text NOT NULL,
"clinic_public_key" text NOT NULL,
"clinic_fingerprint" text NOT NULL,
"changes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"delivered_at" timestamp,
"resolved_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "wallet_updates_org_idx" ON "wallet_record_updates" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "wallet_updates_wallet_idx" ON "wallet_record_updates" USING btree ("wallet_number");
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "fhir_api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_used_at" timestamp,
"revoked_at" timestamp,
CONSTRAINT "fhir_api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
ALTER TABLE "fhir_api_keys" ADD CONSTRAINT "fhir_api_keys_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "fhir_api_keys" ADD CONSTRAINT "fhir_api_keys_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "fhir_api_keys_org_idx" ON "fhir_api_keys" USING btree ("organization_id");
@@ -0,0 +1 @@
ALTER TABLE "clinic_signing_keys" ADD COLUMN "network_enabled" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE "patients" ADD COLUMN "phone" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "patients" ADD COLUMN "blood_type" text DEFAULT '' NOT NULL;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "clinic_settings" (
"organization_id" text PRIMARY KEY NOT NULL,
"address" text DEFAULT '' NOT NULL,
"city" text DEFAULT '' NOT NULL,
"country" text DEFAULT '' NOT NULL,
"latitude" double precision,
"longitude" double precision,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "clinic_settings" ADD CONSTRAINT "clinic_settings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1 @@
ALTER TABLE "patients" ADD COLUMN "wallet_number" text;
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
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
+42
View File
@@ -211,6 +211,48 @@
"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
},
{
"idx": 33,
"version": "7",
"when": 1783362745730,
"tag": "0033_ambitious_reavers",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1783363217049,
"tag": "0034_chunky_blacklash",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1783530491321,
"tag": "0035_slippery_retro_girl",
"breakpoints": true
}
]
}
+60 -2
View File
@@ -1,12 +1,12 @@
{
"name": "temetro-backend",
"version": "0.1.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "temetro-backend",
"version": "0.1.0",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.84",
@@ -28,6 +28,7 @@
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -3339,6 +3340,40 @@
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@@ -4947,6 +4982,21 @@
"ws": "~8.20.1"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
@@ -5779,6 +5829,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.3.0",
"version": "0.11.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
@@ -41,6 +41,7 @@
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
+25
View File
@@ -0,0 +1,25 @@
import { doublePrecision, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// Per-clinic (organization) settings. Currently holds the clinic's physical
// location — a free-text address plus optional map coordinates — set in
// Settings → Location by an owner/admin and surfaced to patients in the wallet
// app later (e.g. a map pin for a clinic that shared a record). One row per org
// (PK = organizationId), mirroring `clinic_signing_keys`.
export const clinicSettings = pgTable("clinic_settings", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
address: text("address").notNull().default(""),
city: text("city").notNull().default(""),
country: text("country").notNull().default(""),
// Optional map coordinates (WGS84). Null until the clinic sets them.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+30
View File
@@ -0,0 +1,30 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// Per-organization API keys for the read-only FHIR R4 server (`/fhir`). These
// are machine-to-machine credentials (no Better Auth session): a caller sends
// `Authorization: Bearer tmf_<secret>` and every query is scoped to the owning
// clinic. Only the SHA-256 *hash* of the secret is stored — the plaintext key is
// shown once at creation and never again. Revoking sets `revokedAt` (kept for
// audit rather than hard-deleted).
export const fhirApiKeys = pgTable(
"fhir_api_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// Hex SHA-256 of the full `tmf_…` secret. Unique so a lookup is a single
// indexed probe and two keys can never collide.
keyHash: text("key_hash").notNull().unique(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
lastUsedAt: timestamp("last_used_at"),
revokedAt: timestamp("revoked_at"),
},
(t) => [index("fhir_api_keys_org_idx").on(t.organizationId)],
);
+3
View File
@@ -20,4 +20,7 @@ export * from "./integrations.js";
export * from "./staff-profile.js";
export * from "./meetings.js";
export * from "./signing.js";
export * from "./clinic-settings.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
export * from "./fhir-keys.js";
+9
View File
@@ -36,6 +36,11 @@ export const patients = pgTable(
pcp: text("pcp").notNull(),
status: text("status").$type<PatientStatus>().notNull(),
initials: text("initials").notNull(),
// Contact + clinical demographics. `phone` is a contact/registration field
// (reception may read/write it); `bloodType` is clinical (redacted for the
// reception role, like allergies/vitals).
phone: text("phone").notNull().default(""),
bloodType: text("blood_type").notNull().default(""),
alerts: jsonb("alerts").$type<string[]>().notNull(),
vitalsBp: text("vitals_bp").notNull(),
vitalsHr: text("vitals_hr").notNull(),
@@ -59,6 +64,10 @@ export const patients = pgTable(
// and passes, a scheduled sweep hard-deletes the row (services/wallet-share).
shareOrigin: text("share_origin").$type<"wallet">(),
shareExpiresAt: timestamp("share_expires_at"),
// The patient's wallet number (tmw_…) once they link their wallet from the
// Patient Portal. Lets clinic→wallet pushes and portal actions resolve to
// this file directly (services/portal.ts, wallet-updates.ts). Nullable.
walletNumber: text("wallet_number"),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
+6 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
@@ -16,6 +16,11 @@ export const clinicSigningKeys = pgTable("clinic_signing_keys", {
fingerprint: text("fingerprint").notNull(),
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
privateKeyEnc: text("private_key_enc").notNull(),
// Whether this clinic has joined the Temetro Network relay ("Join Temetro
// Network" in Settings → Signing). Off by default: only when enabled does the
// backend open this clinic's relay hub connection and expose wallet features.
// The relay identity *is* this signing key, so the flag lives on the same row.
networkEnabled: boolean("network_enabled").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
rotatedAt: timestamp("rotated_at"),
});
+53
View File
@@ -0,0 +1,53 @@
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
export type WalletUpdateStatus =
| "pending"
| "delivered"
| "approved"
| "denied";
// One row per clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient they can push the updated record to the patient's app;
// it lands here as `pending`, is sealed to the wallet's (X25519-from-Ed25519)
// key and signed with the clinic's Ed25519 key. The relay delivers it live if
// the device is connected, and again on the wallet's next authenticated connect
// (so an offline phone still receives it). The patient reviews the change,
// verifies the clinic signature, and approves/denies in-app — only then is the
// on-device record replaced. The clinic polls `status` for delivery/approval.
export const walletRecordUpdates = pgTable(
"wallet_record_updates",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdBy: text("created_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileNumber: text("file_number").notNull(),
walletNumber: text("wallet_number").notNull(),
status: text("status")
.$type<WalletUpdateStatus>()
.notNull()
.default("pending"),
// base64 sealed box of the full updated patient snapshot (sealed to the
// wallet's derived X25519 key).
payloadSealed: text("payload_sealed").notNull(),
// The clinic's Ed25519 signature over the plaintext bundle bytes + its
// public key + fingerprint, so the wallet can verify provenance (TOFU pin).
clinicSignature: text("clinic_signature").notNull(),
clinicPublicKey: text("clinic_public_key").notNull(),
clinicFingerprint: text("clinic_fingerprint").notNull(),
// Human-readable summary of what changed (shown in the wallet inbox).
changes: jsonb("changes").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at").defaultNow().notNull(),
deliveredAt: timestamp("delivered_at"),
resolvedAt: timestamp("resolved_at"),
},
(t) => [
index("wallet_updates_org_idx").on(t.organizationId),
index("wallet_updates_wallet_idx").on(t.walletNumber),
],
);
+16
View File
@@ -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";
+18 -1
View File
@@ -16,8 +16,10 @@ import { analyticsRouter } from "./routes/analytics.js";
import { attachmentsRouter } from "./routes/attachments.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { clinicRouter } from "./routes/clinic.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 +30,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";
@@ -87,6 +91,7 @@ app.use("/api/network", networkRouter);
app.use("/api/patients/wallet", patientsWalletRouter);
app.use("/api/patients", patientsRouter);
app.use("/api/signing", signingRouter);
app.use("/api/clinic", clinicRouter);
app.use("/api/attachments", attachmentsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
@@ -104,10 +109,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 +126,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 +160,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
+4
View File
@@ -105,6 +105,10 @@ export const patientInputSchema = z
),
status: z.enum(["active", "inpatient", "discharged"]).default("active"),
initials: z.string().trim().max(4).default(""),
phone: z.string().trim().max(30).default(""),
bloodType: z
.enum(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-", ""])
.default(""),
allergies: z.array(allergySchema).default([]),
alerts: z.array(z.string()).default([]),
medications: z.array(medicationSchema).default([]),
+49
View File
@@ -0,0 +1,49 @@
import { bytesToHex } from "@noble/hashes/utils.js";
// Convert an Ed25519 public key to the matching X25519 (Montgomery) public key,
// so the clinic can `seal()` a record update to a wallet that only publishes an
// Ed25519 identity (its wallet number). The patient wallet derives the matching
// X25519 *private* key from its Ed25519 seed (SHA-512 clamp) to `open()` it —
// this file MUST stay byte-for-byte compatible with the wallet app's
// src/lib/crypto.ts. @noble/curves does not export edwardsToMontgomery in the
// pinned version, so the birational map u = (1 + y) / (1 - y) mod p is done here
// with BigInt. Verified: edPubToMontU(A) === x25519.getPublicKey(edClamp(seed)).
const P = 2n ** 255n - 19n;
function modpow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
let b = base % mod;
let e = exp;
while (e > 0n) {
if (e & 1n) result = (result * b) % mod;
b = (b * b) % mod;
e >>= 1n;
}
return result;
}
// Modular inverse via Fermat's little theorem (p is prime).
function inv(a: bigint): bigint {
return modpow(((a % P) + P) % P, P - 2n, P);
}
// Ed25519 public key (compressed, little-endian y with the x-sign in the high
// bit) → X25519 u-coordinate, returned as 32-byte little-endian hex.
export function ed25519PubToX25519Hex(edPub: Uint8Array): string {
if (edPub.length !== 32) throw new Error("Ed25519 public key must be 32 bytes.");
const bytes = edPub.slice();
bytes[31] = (bytes[31] as number) & 0x7f; // clear the x sign bit
let y = 0n;
for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i] as number);
y %= P;
// u = (1 + y) / (1 - y) (mod p)
const u = ((1n + y) * inv((1n - y + P) % P)) % P;
const out = new Uint8Array(32);
let v = u;
for (let i = 0; i < 32; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return bytesToHex(out);
}
+49
View File
@@ -0,0 +1,49 @@
import type { NextFunction, Request, Response } from "express";
import { resolveKey } from "../services/fhir-server/keys.js";
import {
FHIR_CONTENT_TYPE,
operationOutcome,
} from "../services/fhir-server/outcome.js";
// Bearer-token auth for the read-only FHIR server. Unlike the rest of the API
// (Better Auth session cookies), the `/fhir` endpoints authenticate with a
// per-clinic API key: `Authorization: Bearer tmf_<secret>`. On success the
// caller's organization is attached to `req.organizationId` and every downstream
// query is scoped to it. Failures return a FHIR OperationOutcome, not our
// standard error JSON.
export async function requireFhirKey(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const header = req.headers.authorization ?? "";
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
const secret = match?.[1]?.trim();
const unauthorized = (diagnostics: string) => {
res
.status(401)
.type(FHIR_CONTENT_TYPE)
.set("WWW-Authenticate", "Bearer")
.json(operationOutcome("error", "login", diagnostics));
};
if (!secret) {
unauthorized("Missing bearer token. Send Authorization: Bearer tmf_…");
return;
}
try {
const resolved = await resolveKey(secret);
if (!resolved) {
unauthorized("Invalid or revoked API key.");
return;
}
req.organizationId = resolved.orgId;
req.fhirKey = { id: resolved.keyId, name: resolved.keyName };
next();
} catch (err) {
next(err);
}
}
+8 -109
View File
@@ -1,16 +1,14 @@
import type { Server as HttpServer } from "node:http";
import { fromNodeHeaders } from "better-auth/node";
import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { Server, type Socket } from "socket.io";
import { auth } from "./auth.js";
import { env } from "./env.js";
import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js";
import * as meetings from "./services/meetings.js";
import * as messaging from "./services/messaging.js";
import { createNotification } from "./services/notifications.js";
import * as walletShare from "./services/wallet-share.js";
import { sendToWallet } from "./services/relay-client.js";
import type { MessageAttachment } from "./types/messaging.js";
let io: Server | null = null;
@@ -19,7 +17,6 @@ const userRoom = (userId: string) => `user:${userId}`;
const convRoom = (conversationId: string) => `conv:${conversationId}`;
const callRoom = (roomId: string) => `call:${roomId}`;
const orgRoom = (orgId: string) => `org:${orgId}`;
const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`;
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
// past that the room is closed to new joiners.
@@ -43,15 +40,18 @@ export function emitToConversation(
io?.to(convRoom(conversationId)).emit(event, data);
}
// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet
// namespace, room keyed by wallet number). The relay only ever forwards
// ciphertext — it cannot read the record bundle.
// Relay an end-to-end-encrypted message to a patient wallet device. Devices no
// longer connect to this server directly — they connect to the standalone
// Temetro Network relay, which forwards to the room keyed by wallet number. We
// push over the relay's /hub namespace (see services/relay-client.ts). The
// relay only ever forwards ciphertext — it cannot read the record bundle.
export function emitToWallet(
orgId: string,
walletNumber: string,
event: string,
data: unknown,
): void {
io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data);
sendToWallet(orgId, walletNumber, event, data);
}
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
@@ -285,106 +285,5 @@ export function initRealtime(httpServer: HttpServer): Server {
});
});
// --- Patient wallet relay (/wallet namespace) ----------------------------
// Devices have no clinic session, so this namespace is NOT cookie-gated.
// Instead a device proves control of its wallet keypair: the server issues a
// random challenge, the device signs it with its Ed25519 key, and only then
// may it join its own wallet room. The relay forwards encrypted share
// requests/responses without ever reading the record bundle.
const walletNs = io.of("/wallet");
walletNs.on("connection", (socket: Socket) => {
const challenge = bytesToHex(randomBytes(32));
socket.data.challenge = challenge;
socket.data.walletNumber = null as string | null;
socket.emit("wallet:challenge", { challenge });
socket.on(
"wallet:auth",
(payload: { walletNumber?: string; signature?: string }, ack?: Ack) => {
try {
const walletNumber = String(payload?.walletNumber ?? "");
const signature = String(payload?.signature ?? "");
const publicKey = decodeWalletNumber(walletNumber);
const ok = verifySignature(
publicKey,
signature,
utf8ToBytes(socket.data.challenge as string),
);
if (!ok) {
ack?.({ ok: false });
return;
}
socket.data.walletNumber = walletNumber;
socket.join(walletRoom(walletNumber));
ack?.({ ok: true });
} catch {
ack?.({ ok: false });
}
},
);
// The patient approved/denied a share on their device; the sealed bundle (if
// approved) rides along and is decrypted + verified server-side.
socket.on(
"wallet:share-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
sealed?: string;
signature?: string;
},
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const view = await walletShare.applyShareResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.sealed,
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// The patient revoked a previously shared record; delete it from the clinic.
socket.on(
"wallet:revoke",
async (
payload: { requestId?: string; walletNumber?: string },
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const result = await walletShare.revokeShare(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
);
ack?.({ ok: !!result });
} catch {
ack?.({ ok: false });
}
},
);
});
return io;
}
+8
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
import { Router } from "express";
import { z } from "zod";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as clinicSettings from "../services/clinic-settings.js";
export const clinicRouter = Router();
clinicRouter.use(requireAuth, requireOrg);
// The clinic's settings (currently just its location). Readable by any
// clinician so the app/UI can display the clinic address.
clinicRouter.get(
"/settings",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await clinicSettings.getClinicSettings(req.organizationId!));
} catch (err) {
next(err);
}
},
);
// Set the clinic's location — owner/admin only (gated on the org-update
// statement, same as signing-key rotation / network toggle).
const locationSchema = z.object({
address: z.string().trim().max(200).default(""),
city: z.string().trim().max(120).default(""),
country: z.string().trim().max(120).default(""),
latitude: z.number().min(-90).max(90).nullable().default(null),
longitude: z.number().min(-180).max(180).nullable().default(null),
});
clinicRouter.put(
"/location",
requirePermission({ organization: ["update"] }),
async (req, res, next) => {
try {
const location = locationSchema.parse(req.body);
const view = await clinicSettings.setClinicLocation(
req.organizationId!,
location,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Updated the clinic location",
entityType: "settings",
});
res.json(view);
} catch (err) {
next(err);
}
},
);
+275
View File
@@ -0,0 +1,275 @@
import { createRequire } from "node:module";
import { Router } from "express";
import type { Request, Response } from "express";
import type { ParsedQs } from "qs";
import { env } from "../env.js";
import { requireFhirKey } from "../middleware/fhir-auth.js";
import { recordActivity } from "../services/activity.js";
import {
paginate,
parseCount,
parseOffset,
searchsetBundle,
} from "../services/fhir-server/bundle.js";
import { capabilityStatement } from "../services/fhir-server/capability.js";
import {
FHIR_CONTENT_TYPE,
operationOutcome,
type IssueCode,
type IssueSeverity,
} from "../services/fhir-server/outcome.js";
import * as q from "../services/fhir-server/queries.js";
import {
allergyResource,
appointmentResource,
conditionResource,
encounterResource,
labObservation,
medicationRequestResource,
patientResource,
vitalObservations,
type FhirResource,
} from "../services/fhir-server/resources.js";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json") as { version?: string };
const VERSION = env.APP_VERSION ?? pkg.version ?? "0.0.0";
export const fhirRouter = Router();
// --- helpers ----------------------------------------------------------------
function baseUrl(req: Request): string {
return `${req.protocol}://${req.get("host")}/fhir`;
}
function sendResource(res: Response, resource: unknown): void {
res.type(FHIR_CONTENT_TYPE).json(resource);
}
function sendOutcome(
res: Response,
status: number,
severity: IssueSeverity,
code: IssueCode,
diagnostics: string,
): void {
res
.status(status)
.type(FHIR_CONTENT_TYPE)
.json(operationOutcome(severity, code, diagnostics));
}
function qstr(v: string | ParsedQs | (string | ParsedQs)[] | undefined): string | undefined {
if (typeof v === "string") return v.trim() || undefined;
if (Array.isArray(v) && typeof v[0] === "string") return v[0].trim() || undefined;
return undefined;
}
// Best-effort audit: every FHIR request is logged with the key name + result
// count, scoped to the org. Access to PHI over the API must leave a trail.
function audit(req: Request, resourceType: string, count: number): void {
void recordActivity({
orgId: req.organizationId!,
actor: { name: `FHIR API · ${req.fhirKey?.name ?? "key"}` },
action: `Read ${resourceType} via the FHIR API (${count} result${count === 1 ? "" : "s"})`,
entityType: "patient",
});
}
// Materialize a page from a full resource array + emit a searchset Bundle.
function respondSearch(
req: Request,
res: Response,
resourceType: string,
all: FhirResource[],
): void {
const count = parseCount(qstr(req.query._count as never));
const offset = parseOffset(qstr(req.query._offset as never));
const { page, total } = paginate(all, count, offset);
const params = new URLSearchParams();
for (const [k, v] of Object.entries(req.query)) {
if (k === "_count" || k === "_offset") continue;
const s = qstr(v as never);
if (s !== undefined) params.set(k, s);
}
audit(req, resourceType, total);
sendResource(
res,
searchsetBundle({ baseUrl: baseUrl(req), resourceType, page, total, count, offset, params }),
);
}
// Resolve the `patient` / `patient.identifier` search parameter to a patient row
// (org-scoped). Returns undefined when the param is absent or matches nobody.
async function patientFromQuery(req: Request) {
const patientId = qstr(req.query.patient as never);
const identifier = qstr(req.query["patient.identifier"] as never);
if (!patientId && !identifier) return undefined;
return q.resolvePatientRef(req.organizationId!, { patientId, identifier });
}
// --- CapabilityStatement (unauthenticated, per FHIR convention) -------------
fhirRouter.get("/metadata", (req, res) => {
sendResource(res, capabilityStatement(baseUrl(req), VERSION));
});
// Everything below requires a valid per-clinic API key.
fhirRouter.use(requireFhirKey);
// --- Patient ----------------------------------------------------------------
fhirRouter.get("/Patient", async (req, res, next) => {
try {
const count = parseCount(qstr(req.query._count as never));
const offset = parseOffset(qstr(req.query._offset as never));
const { rows, total } = await q.searchPatients(req.organizationId!, {
identifier: qstr(req.query.identifier as never),
name: qstr(req.query.name as never),
limit: count,
offset,
});
const params = new URLSearchParams();
if (qstr(req.query.identifier as never))
params.set("identifier", qstr(req.query.identifier as never)!);
if (qstr(req.query.name as never)) params.set("name", qstr(req.query.name as never)!);
audit(req, "Patient", total);
sendResource(
res,
searchsetBundle({
baseUrl: baseUrl(req),
resourceType: "Patient",
page: rows.map(patientResource),
total,
count,
offset,
params,
}),
);
} catch (err) {
next(err);
}
});
fhirRouter.get("/Patient/:id", async (req, res, next) => {
try {
const row = await q.patientById(req.organizationId!, String(req.params.id));
if (!row) {
sendOutcome(res, 404, "error", "not-found", "Patient not found.");
return;
}
audit(req, "Patient", 1);
sendResource(res, patientResource(row));
} catch (err) {
next(err);
}
});
// --- Observation (labs + vitals) --------------------------------------------
fhirRouter.get("/Observation", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) {
respondSearch(req, res, "Observation", []);
return;
}
const category = qstr(req.query.category as never);
const all: FhirResource[] = [];
if (category !== "vital-signs") {
const rows = await q.labsForPatient(patient.id);
all.push(...rows.map((r) => labObservation(r, patient)));
}
if (category !== "laboratory") {
all.push(...vitalObservations(patient));
}
respondSearch(req, res, "Observation", all);
} catch (err) {
next(err);
}
});
// --- AllergyIntolerance -----------------------------------------------------
fhirRouter.get("/AllergyIntolerance", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "AllergyIntolerance", []);
const rows = await q.allergiesForPatient(patient.id);
respondSearch(req, res, "AllergyIntolerance", rows.map((r) => allergyResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- Condition --------------------------------------------------------------
fhirRouter.get("/Condition", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "Condition", []);
const rows = await q.problemsForPatient(patient.id);
respondSearch(req, res, "Condition", rows.map((r) => conditionResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- MedicationRequest ------------------------------------------------------
fhirRouter.get("/MedicationRequest", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "MedicationRequest", []);
const rows = await q.prescriptionsForFile(req.organizationId!, patient.fileNumber);
respondSearch(
req,
res,
"MedicationRequest",
rows.map((r) => medicationRequestResource(r, patient)),
);
} catch (err) {
next(err);
}
});
// --- Encounter --------------------------------------------------------------
fhirRouter.get("/Encounter", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "Encounter", []);
const rows = await q.encountersForPatient(patient.id);
respondSearch(req, res, "Encounter", rows.map((r) => encounterResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- Appointment ------------------------------------------------------------
fhirRouter.get("/Appointment", async (req, res, next) => {
try {
const patient = await patientFromQuery(req);
if (!patient) return respondSearch(req, res, "Appointment", []);
const rows = await q.appointmentsForFile(req.organizationId!, patient.fileNumber);
respondSearch(req, res, "Appointment", rows.map((r) => appointmentResource(r, patient)));
} catch (err) {
next(err);
}
});
// --- Unknown resource / path -> OperationOutcome ----------------------------
fhirRouter.use((req, res) => {
sendOutcome(
res,
404,
"error",
"not-supported",
`Unsupported FHIR path or resource: ${req.method} ${req.path}.`,
);
});
+70
View File
@@ -18,6 +18,7 @@ import {
listConfigs,
saveConfig,
} from "../services/integrations/config.js";
import { createKey, listKeys, revokeKey } from "../services/fhir-server/keys.js";
import * as eprescribe from "../services/integrations/eprescribe.js";
import * as fhir from "../services/integrations/fhir.js";
@@ -119,6 +120,75 @@ integrationsRouter.post(
},
);
// --- FHIR server API keys (owner/admin only) --------------------------------
// These credential the read-only /fhir server. The plaintext secret is returned
// exactly once (on creation) and only its hash is stored.
integrationsRouter.get(
"/fhir-server/keys",
requireAuth,
requireOrg,
async (req, res, next) => {
try {
assertAdmin(req.memberRole);
res.json(await listKeys(req.organizationId!));
} catch (err) {
next(err);
}
},
);
const createKeySchema = z.object({ name: z.string().trim().min(1).max(120) });
integrationsRouter.post(
"/fhir-server/keys",
requireAuth,
requireOrg,
async (req, res, next) => {
try {
assertAdmin(req.memberRole);
const { name } = createKeySchema.parse(req.body);
const { secret, key } = await createKey(
req.organizationId!,
name,
req.user!.id,
);
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Created a FHIR API key ("${key.name}")`,
entityType: "settings",
});
// `secret` is present only in this response — the client must show it now.
res.status(201).json({ ...key, secret });
} catch (err) {
next(err);
}
},
);
integrationsRouter.delete(
"/fhir-server/keys/:id",
requireAuth,
requireOrg,
async (req, res, next) => {
try {
assertAdmin(req.memberRole);
const revoked = await revokeKey(req.organizationId!, String(req.params.id));
if (!revoked) throw new HttpError(404, "API key not found.");
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Revoked a FHIR API key",
entityType: "settings",
});
res.json({ revoked: true });
} catch (err) {
next(err);
}
},
);
// --- Actions ----------------------------------------------------------------
const syncSchema = z.object({ fileNumber: z.string().trim().min(1) });
+118 -3
View File
@@ -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.
+95 -3
View File
@@ -1,9 +1,11 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { Router, type Request } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { organization } from "../db/schema/auth.js";
import { member, organization, user } from "../db/schema/auth.js";
import { staffProfile } from "../db/schema/staff-profile.js";
import { env } from "../env.js";
import { appointmentInputSchema } from "../lib/appointment-validation.js";
import { HttpError } from "../lib/http-error.js";
import { initialsFromName } from "../lib/initials.js";
@@ -11,6 +13,11 @@ import { patientInputSchema } from "../lib/patient-validation.js";
import { recordActivity } from "../services/activity.js";
import { createAppointment, listAppointments } from "../services/appointments.js";
import { createPatient, getPatient } from "../services/patients.js";
import { getOrCreateKey } from "../services/signing.js";
// Clinical-capable roles that can be a patient's provider (mirrors
// staff.ts PROVIDER_ROLES). Department roles (reception, pharmacy, lab) excluded.
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the
// waiting room). Scoped by the clinic slug in the URL — there is no session.
@@ -45,12 +52,97 @@ portalRouter.get("/:clinic", async (req, res, next) => {
}
});
// GET /api/portal/:clinic/link — the relay-based pairing descriptor the wallet
// app scans to talk to this clinic over the Temetro Network: the clinic's
// signing public key (the relay's routing id) + the relay URL. Both values are
// non-secret (the signing key is the clinic's public identity). This is what
// makes the Patient Portal QR reachable from a real phone — it no longer bakes
// in a localhost API URL.
portalRouter.get("/:clinic/link", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const key = await getOrCreateKey(clinic.id);
res.json({
clinicId: key.publicKey,
relay: env.RELAY_URL,
slug: String(req.params.clinic ?? "").trim(),
name: clinic.name,
});
} catch (err) {
next(err);
}
});
// GET /api/portal/:clinic/doctors — public list of the clinic's providers so a
// patient can pick who to see. Returns only display-safe fields (name +
// specialty); no ids, emails, or usernames leave this unauthenticated surface.
portalRouter.get("/:clinic/doctors", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const rows = await db
.select({ name: user.name, specialty: staffProfile.specialty })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.leftJoin(
staffProfile,
and(
eq(staffProfile.userId, member.userId),
eq(staffProfile.organizationId, member.organizationId),
),
)
.where(
and(
eq(member.organizationId, clinic.id),
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
),
)
.orderBy(asc(user.name));
res.json(rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null })));
} catch (err) {
next(err);
}
});
const availabilitySchema = z.object({
provider: z.string().trim().max(200).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
});
// GET /api/portal/:clinic/availability?provider=&date= — the taken time slots
// for a provider on a given day, so the kiosk can render only free slots. The
// filter mirrors the booking conflict check (an empty-provider appointment
// blocks the slot clinic-wide). Booking still re-checks server-side (409).
portalRouter.get("/:clinic/availability", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const q = availabilitySchema.parse({
provider: req.query.provider,
date: req.query.date,
});
const provider = q.provider ?? "";
const taken = (await listAppointments(clinic.id))
.filter(
(a) =>
a.status !== "cancelled" &&
a.date === q.date &&
(!provider || !a.provider || a.provider === provider),
)
.map((a) => a.time);
res.json({ date: q.date, provider, taken: [...new Set(taken)].sort() });
} catch (err) {
next(err);
}
});
const bookingSchema = z.object({
fileNumber: z.string().trim().min(1, "A file number is required.").max(64),
name: z.string().trim().min(1, "Your name is required.").max(200),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
type: z.string().trim().max(120).optional(),
// Chosen provider (doctor name) from the portal's doctor picker; falls back
// to the patient's PCP when omitted.
provider: z.string().trim().max(200).optional(),
});
const newPatientSchema = z.object({
@@ -114,7 +206,7 @@ portalRouter.post("/:clinic/appointments", async (req, res, next) => {
date: body.date,
time: body.time,
type: body.type || "Self-service booking",
provider: patient.pcp || "",
provider: body.provider || patient.pcp || "",
status: "confirmed",
source: "manual",
});
+240
View File
@@ -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);
}
});
+48
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { z } from "zod";
import {
requireAuth,
@@ -6,6 +7,7 @@ import {
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import { connectOrg, disconnectOrg } from "../services/relay-client.js";
import * as signing from "../services/signing.js";
import * as walletShare from "../services/wallet-share.js";
@@ -48,6 +50,52 @@ signingRouter.post(
},
);
// Whether this clinic has joined the Temetro Network relay. Readable by any
// clinician (the panel shows the toggle state + connection status).
signingRouter.get(
"/network",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json({ enabled: await signing.getNetworkEnabled(req.organizationId!) });
} catch (err) {
next(err);
}
},
);
// Join / leave the Temetro Network — owner/admin only (same gate as key
// rotation). Enabling opens this clinic's relay hub connection; disabling tears
// it down.
const networkSchema = z.object({ enabled: z.boolean() });
signingRouter.put(
"/network",
requirePermission({ organization: ["update"] }),
async (req, res, next) => {
try {
const { enabled } = networkSchema.parse(req.body);
await signing.setNetworkEnabled(req.organizationId!, enabled);
if (enabled) {
await connectOrg(req.organizationId!);
} else {
disconnectOrg(req.organizationId!);
}
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: enabled
? "Joined the Temetro Network"
: "Left the Temetro Network",
entityType: "settings",
});
res.json({ enabled });
} catch (err) {
next(err);
}
},
);
// Recent records shared from patient wallets — feeds the panel's shared-records
// list.
signingRouter.get(
+132
View File
@@ -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 };
}
+25
View File
@@ -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,
};
+82
View File
@@ -0,0 +1,82 @@
import { eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { clinicSettings } from "../db/schema/clinic-settings.js";
export type ClinicLocation = {
address: string;
city: string;
country: string;
latitude: number | null;
longitude: number | null;
};
export type ClinicSettingsView = {
location: ClinicLocation;
};
const EMPTY_LOCATION: ClinicLocation = {
address: "",
city: "",
country: "",
latitude: null,
longitude: null,
};
type ClinicSettingsRow = typeof clinicSettings.$inferSelect;
function toView(row: ClinicSettingsRow | undefined): ClinicSettingsView {
if (!row) return { location: { ...EMPTY_LOCATION } };
return {
location: {
address: row.address,
city: row.city,
country: row.country,
latitude: row.latitude,
longitude: row.longitude,
},
};
}
// Read a clinic's settings. Returns empty defaults when no row exists yet, so
// the panel always renders.
export async function getClinicSettings(
orgId: string,
): Promise<ClinicSettingsView> {
const [row] = await db
.select()
.from(clinicSettings)
.where(eq(clinicSettings.organizationId, orgId))
.limit(1);
return toView(row);
}
// Upsert the clinic's location (address + optional coordinates).
export async function setClinicLocation(
orgId: string,
location: ClinicLocation,
): Promise<ClinicSettingsView> {
const values = {
organizationId: orgId,
address: location.address,
city: location.city,
country: location.country,
latitude: location.latitude,
longitude: location.longitude,
};
const [row] = await db
.insert(clinicSettings)
.values(values)
.onConflictDoUpdate({
target: clinicSettings.organizationId,
set: {
address: values.address,
city: values.city,
country: values.country,
latitude: values.latitude,
longitude: values.longitude,
},
})
.returning();
return toView(row);
}
@@ -0,0 +1,77 @@
import type { FhirResource } from "./resources.js";
// searchset Bundle assembly + offset/limit pagination for the FHIR server.
export const DEFAULT_COUNT = 50;
export const MAX_COUNT = 200;
// Clamp a client-supplied `_count` into [1, MAX_COUNT], defaulting when absent.
export function parseCount(raw: string | undefined): number {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return DEFAULT_COUNT;
return Math.min(Math.floor(n), MAX_COUNT);
}
export function parseOffset(raw: string | undefined): number {
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return 0;
return Math.floor(n);
}
// Slice an already-materialized resource array to the requested page.
export function paginate<T>(
all: T[],
count: number,
offset: number,
): { page: T[]; total: number } {
return { page: all.slice(offset, offset + count), total: all.length };
}
export type SearchsetBundle = {
resourceType: "Bundle";
type: "searchset";
total: number;
link: { relation: string; url: string }[];
entry: { fullUrl: string; resource: FhirResource; search: { mode: "match" } }[];
};
// Build a FHIR searchset Bundle. `page` is the current slice; `total` the full
// match count; `params` the effective query (already carrying `_count`/`_offset`)
// used to derive self/next/prev links.
export function searchsetBundle(opts: {
baseUrl: string; // e.g. https://host/fhir
resourceType: string;
page: FhirResource[];
total: number;
count: number;
offset: number;
params: URLSearchParams;
}): SearchsetBundle {
const { baseUrl, resourceType, page, total, count, offset, params } = opts;
const linkFor = (nextOffset: number): string => {
const q = new URLSearchParams(params);
q.set("_count", String(count));
q.set("_offset", String(nextOffset));
return `${baseUrl}/${resourceType}?${q.toString()}`;
};
const link: { relation: string; url: string }[] = [
{ relation: "self", url: linkFor(offset) },
];
if (offset + count < total) link.push({ relation: "next", url: linkFor(offset + count) });
if (offset > 0)
link.push({ relation: "previous", url: linkFor(Math.max(0, offset - count)) });
return {
resourceType: "Bundle",
type: "searchset",
total,
link,
entry: page.map((resource) => ({
fullUrl: `${baseUrl}/${resource.resourceType}/${resource.id}`,
resource,
search: { mode: "match" },
})),
};
}
@@ -0,0 +1,114 @@
// A static CapabilityStatement describing exactly what this read-only FHIR R4
// server supports. It is intentionally honest: only the resources and search
// params implemented below are listed, everything is `read`/`search-type` only,
// and clinical concepts are text-only (no SNOMED/LOINC coding).
type ResourceCapability = {
type: string;
interaction: { code: "read" | "search-type" }[];
searchParam?: { name: string; type: "token" | "string" | "reference" }[];
};
const RESOURCES: ResourceCapability[] = [
{
type: "Patient",
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "identifier", type: "token" },
{ name: "name", type: "string" },
],
},
{
type: "Observation",
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "patient", type: "reference" },
{ name: "patient.identifier", type: "token" },
{ name: "category", type: "token" },
],
},
{
type: "AllergyIntolerance",
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "patient", type: "reference" },
{ name: "patient.identifier", type: "token" },
],
},
{
type: "Condition",
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "patient", type: "reference" },
{ name: "patient.identifier", type: "token" },
],
},
{
type: "MedicationRequest",
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "patient", type: "reference" },
{ name: "patient.identifier", type: "token" },
],
},
{
type: "Encounter",
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "patient", type: "reference" },
{ name: "patient.identifier", type: "token" },
],
},
{
type: "Appointment",
interaction: [{ code: "read" }, { code: "search-type" }],
searchParam: [
{ name: "patient", type: "reference" },
{ name: "patient.identifier", type: "token" },
],
},
];
export function capabilityStatement(
baseUrl: string,
version: string,
): Record<string, unknown> {
return {
resourceType: "CapabilityStatement",
status: "active",
date: new Date().toISOString(),
publisher: "temetro",
kind: "instance",
implementation: { description: "temetro FHIR server", url: baseUrl },
software: { name: "temetro", version },
fhirVersion: "4.0.1",
format: ["application/fhir+json", "json"],
rest: [
{
mode: "server",
documentation:
"Read-only FHIR R4 server. Authenticate with a per-clinic API key: " +
"Authorization: Bearer tmf_…. Clinical values are text-only " +
"CodeableConcepts (no SNOMED/LOINC). Patients expose age (extension), " +
"not birthDate. Pagination via _count (default 50, max 200) and _offset.",
security: {
description: "Bearer token (per-organization API key, tmf_ prefix).",
service: [
{
coding: [
{
system:
"http://terminology.hl7.org/CodeSystem/restful-security-service",
code: "OAuth",
display: "OAuth",
},
],
text: "API key bearer token",
},
],
},
resource: RESOURCES,
},
],
};
}
+110
View File
@@ -0,0 +1,110 @@
import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, isNull } from "drizzle-orm";
import { db } from "../../db/index.js";
import { fhirApiKeys } from "../../db/schema/fhir-keys.js";
// FHIR-server API keys. The secret is `tmf_` + 32 random bytes (base64url); we
// persist only its SHA-256 hash, so a leaked database never yields usable keys
// and the plaintext is returned exactly once (at creation).
const PREFIX = "tmf_";
export type FhirKeyView = {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
revoked: boolean;
};
function hashKey(secret: string): string {
return createHash("sha256").update(secret).digest("hex");
}
function toView(row: typeof fhirApiKeys.$inferSelect): FhirKeyView {
return {
id: row.id,
name: row.name,
createdAt: row.createdAt.toISOString(),
lastUsedAt: row.lastUsedAt ? row.lastUsedAt.toISOString() : null,
revoked: row.revokedAt !== null,
};
}
// List a clinic's keys (active first, then revoked), newest first. Never
// exposes the hash.
export async function listKeys(orgId: string): Promise<FhirKeyView[]> {
const rows = await db
.select()
.from(fhirApiKeys)
.where(eq(fhirApiKeys.organizationId, orgId))
.orderBy(desc(fhirApiKeys.createdAt));
return rows.map(toView);
}
// Mint a new key. Returns the one-time plaintext secret alongside the stored
// view — the caller must surface the secret to the user immediately; it is not
// recoverable afterwards.
export async function createKey(
orgId: string,
name: string,
createdBy: string,
): Promise<{ secret: string; key: FhirKeyView }> {
const secret = PREFIX + randomBytes(32).toString("base64url");
const [row] = await db
.insert(fhirApiKeys)
.values({
organizationId: orgId,
name: name.trim() || "FHIR key",
keyHash: hashKey(secret),
createdBy,
})
.returning();
return { secret, key: toView(row!) };
}
// Revoke a key (idempotent). Scoped to the org so one clinic can't revoke
// another's. Returns false if no such active key exists.
export async function revokeKey(orgId: string, id: string): Promise<boolean> {
const result = await db
.update(fhirApiKeys)
.set({ revokedAt: new Date() })
.where(
and(
eq(fhirApiKeys.id, id),
eq(fhirApiKeys.organizationId, orgId),
isNull(fhirApiKeys.revokedAt),
),
)
.returning({ id: fhirApiKeys.id });
return result.length > 0;
}
export type ResolvedKey = { orgId: string; keyId: string; keyName: string };
// Resolve a presented bearer secret to its owning organization (plus the key's
// identity, for the audit log), or null when it is unknown or revoked. Bumps
// `lastUsedAt` (throttled to once a minute) so the key list can show recent
// activity without a write on every request.
export async function resolveKey(secret: string): Promise<ResolvedKey | null> {
if (!secret.startsWith(PREFIX)) return null;
const [row] = await db
.select()
.from(fhirApiKeys)
.where(eq(fhirApiKeys.keyHash, hashKey(secret)))
.limit(1);
if (!row || row.revokedAt) return null;
const now = Date.now();
const last = row.lastUsedAt?.getTime() ?? 0;
if (now - last > 60_000) {
void db
.update(fhirApiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(fhirApiKeys.id, row.id))
.catch(() => {});
}
return { orgId: row.organizationId, keyId: row.id, keyName: row.name };
}
@@ -0,0 +1,36 @@
// FHIR OperationOutcome helpers. Errors on a FHIR endpoint are returned as an
// OperationOutcome resource (not our usual `{ error }` JSON), with the
// `application/fhir+json` content type, so conformant clients can parse them.
export const FHIR_CONTENT_TYPE = "application/fhir+json";
export type IssueSeverity = "fatal" | "error" | "warning" | "information";
export type IssueCode =
| "not-found"
| "not-supported"
| "security"
| "login"
| "forbidden"
| "invalid"
| "processing"
| "exception";
export type OperationOutcome = {
resourceType: "OperationOutcome";
issue: {
severity: IssueSeverity;
code: IssueCode;
diagnostics?: string;
}[];
};
export function operationOutcome(
severity: IssueSeverity,
code: IssueCode,
diagnostics: string,
): OperationOutcome {
return {
resourceType: "OperationOutcome",
issue: [{ severity, code, diagnostics }],
};
}
+168
View File
@@ -0,0 +1,168 @@
import { and, asc, count, eq, ilike, sql } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
import { db } from "../../db/index.js";
import { appointments } from "../../db/schema/appointments.js";
import {
allergies,
encounters,
labs,
medications,
patients,
problems,
} from "../../db/schema/patients.js";
import { prescriptions } from "../../db/schema/prescriptions.js";
// Narrow, org-scoped reads for the FHIR server. Deliberately separate from the
// app's `services/patients.ts` (which returns the reshaped canonical Patient and
// applies role redaction): the FHIR layer needs raw rows *with their UUIDs* to
// mint stable resource ids, and offset/limit pagination the app service doesn't
// expose. Every function is scoped to a single organization.
export type PatientRow = typeof patients.$inferSelect;
export type LabRow = typeof labs.$inferSelect;
export type AllergyRow = typeof allergies.$inferSelect;
export type ProblemRow = typeof problems.$inferSelect;
export type EncounterRow = typeof encounters.$inferSelect;
export type PrescriptionRow = typeof prescriptions.$inferSelect;
export type AppointmentRow = typeof appointments.$inferSelect;
// --- Patient ----------------------------------------------------------------
// Paginated Patient search. `identifier` matches the MRN (file number) exactly;
// `name` is a case-insensitive substring. Returns the page plus the full total
// for the searchset Bundle.
export async function searchPatients(
orgId: string,
opts: { identifier?: string; name?: string; limit: number; offset: number },
): Promise<{ rows: PatientRow[]; total: number }> {
const filters: SQL[] = [eq(patients.organizationId, orgId)];
if (opts.identifier) filters.push(eq(patients.fileNumber, opts.identifier));
if (opts.name) filters.push(ilike(patients.name, `%${opts.name}%`));
const where = and(...filters);
const [rows, [totalRow]] = await Promise.all([
db
.select()
.from(patients)
.where(where)
.orderBy(asc(patients.fileNumber))
.limit(opts.limit)
.offset(opts.offset),
db.select({ value: count() }).from(patients).where(where),
]);
return { rows, total: totalRow?.value ?? 0 };
}
// A single patient by FHIR logical id (the row UUID), scoped to the org.
export async function patientById(
orgId: string,
id: string,
): Promise<PatientRow | undefined> {
// Guard against a non-UUID id: Postgres would otherwise error on the cast.
if (!/^[0-9a-f-]{36}$/i.test(id)) return undefined;
const [row] = await db
.select()
.from(patients)
.where(and(eq(patients.organizationId, orgId), eq(patients.id, id)))
.limit(1);
return row;
}
// Resolve a `patient` search parameter to a patient row. Accepts either the FHIR
// logical id (`patient=<uuid>`) or the MRN (`patient.identifier=<file#>`).
export async function resolvePatientRef(
orgId: string,
ref: { patientId?: string; identifier?: string },
): Promise<PatientRow | undefined> {
if (ref.patientId) {
// A reference may arrive as "Patient/<id>" or a bare id.
const id = ref.patientId.replace(/^Patient\//, "");
return patientById(orgId, id);
}
if (ref.identifier) {
const [row] = await db
.select()
.from(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, ref.identifier),
),
)
.limit(1);
return row;
}
return undefined;
}
// --- Clinical child rows (by patient UUID) ----------------------------------
export function labsForPatient(patientId: string): Promise<LabRow[]> {
return db
.select()
.from(labs)
.where(eq(labs.patientId, patientId))
.orderBy(asc(labs.position));
}
export function allergiesForPatient(patientId: string): Promise<AllergyRow[]> {
return db
.select()
.from(allergies)
.where(eq(allergies.patientId, patientId))
.orderBy(asc(allergies.position));
}
export function problemsForPatient(patientId: string): Promise<ProblemRow[]> {
return db
.select()
.from(problems)
.where(eq(problems.patientId, patientId))
.orderBy(asc(problems.position));
}
export function encountersForPatient(
patientId: string,
): Promise<EncounterRow[]> {
return db
.select()
.from(encounters)
.where(eq(encounters.patientId, patientId))
.orderBy(asc(encounters.position));
}
// --- Denormalized resources (linked to the patient by MRN / file number) ----
export function prescriptionsForFile(
orgId: string,
fileNumber: string,
): Promise<PrescriptionRow[]> {
return db
.select()
.from(prescriptions)
.where(
and(
eq(prescriptions.organizationId, orgId),
eq(prescriptions.patientFileNumber, fileNumber),
),
)
.orderBy(sql`${prescriptions.prescribedAt} desc`);
}
export function appointmentsForFile(
orgId: string,
fileNumber: string,
): Promise<AppointmentRow[]> {
return db
.select()
.from(appointments)
.where(
and(
eq(appointments.organizationId, orgId),
eq(appointments.patientFileNumber, fileNumber),
),
)
.orderBy(sql`${appointments.date} desc, ${appointments.time} desc`);
}
@@ -0,0 +1,317 @@
import type { LabFlag } from "../../types/patient.js";
import type {
AllergyRow,
AppointmentRow,
EncounterRow,
LabRow,
PatientRow,
PrescriptionRow,
ProblemRow,
} from "./queries.js";
// Pure mappers from temetro rows to FHIR R4 JSON. temetro stores clinical values
// as **free text** (no SNOMED/LOINC coding), so every CodeableConcept here is
// `text`-only — valid FHIR, deliberately un-coded (documented in the
// CapabilityStatement and API docs). Resource ids are the rows' own UUIDs so
// they are stable; synthesized vital-sign Observations derive their id from the
// patient UUID.
export type FhirResource = {
resourceType: string;
id?: string;
[key: string]: unknown;
};
// System URIs.
const MRN_SYSTEM = "urn:temetro:mrn";
const INTERPRETATION_SYSTEM =
"http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation";
const OBS_CATEGORY_SYSTEM =
"http://terminology.hl7.org/CodeSystem/observation-category";
const AGE_EXTENSION =
"https://temetro.app/fhir/StructureDefinition/patient-age-years";
// A FHIR dateTime from our stored strings. Passes date-only values (`YYYY-MM-DD`)
// through unchanged (valid FHIR dateTime), otherwise parses display strings like
// "Jun 28, 2025" to a full instant. Returns undefined when unparseable.
function fhirDateTime(value: string | null | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
const parsed = new Date(trimmed);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}
function humanName(full: string): Record<string, unknown>[] {
const parts = full.trim().split(/\s+/).filter(Boolean);
if (parts.length < 2) return [{ text: full }];
return [{ text: full, family: parts.at(-1), given: parts.slice(0, -1) }];
}
function subjectRef(patient: PatientRow) {
return { reference: `Patient/${patient.id}`, display: patient.name };
}
// --- Patient ----------------------------------------------------------------
export function patientResource(row: PatientRow): FhirResource {
return {
resourceType: "Patient",
id: row.id,
identifier: [{ system: MRN_SYSTEM, value: row.fileNumber }],
active: row.status !== "discharged",
name: humanName(row.name),
gender: row.sex === "M" ? "male" : "female",
// temetro records age, not date of birth; expose it as an extension rather
// than fabricate a birthDate.
extension: [{ url: AGE_EXTENSION, valueInteger: row.age }],
};
}
// --- Observation ------------------------------------------------------------
function interpretation(flag: LabFlag) {
const map: Record<LabFlag, { code: string; display: string }> = {
normal: { code: "N", display: "Normal" },
high: { code: "H", display: "High" },
low: { code: "L", display: "Low" },
critical: { code: "HH", display: "Critical high" },
};
const { code, display } = map[flag];
return [{ coding: [{ system: INTERPRETATION_SYSTEM, code, display }] }];
}
export function labObservation(row: LabRow, patient: PatientRow): FhirResource {
const effective = fhirDateTime(row.takenAt);
return {
resourceType: "Observation",
id: row.id,
status: "final",
category: [
{
coding: [
{
system: OBS_CATEGORY_SYSTEM,
code: "laboratory",
display: "Laboratory",
},
],
},
],
code: { text: row.name },
subject: subjectRef(patient),
...(effective ? { effectiveDateTime: effective } : {}),
valueString: row.value,
interpretation: interpretation(row.flag),
};
}
// Synthesize vital-sign Observations from the denormalized columns on the
// patient row. Returns an empty array when vitals are blank (e.g. a
// reception-registered patient with clinical fields stripped).
export function vitalObservations(patient: PatientRow): FhirResource[] {
const effective = fhirDateTime(patient.vitalsTakenAt);
const base = (idSuffix: string, text: string) => ({
resourceType: "Observation" as const,
id: `${patient.id}-vital-${idSuffix}`,
status: "final",
category: [
{
coding: [
{
system: OBS_CATEGORY_SYSTEM,
code: "vital-signs",
display: "Vital Signs",
},
],
},
],
code: { text },
subject: subjectRef(patient),
...(effective ? { effectiveDateTime: effective } : {}),
});
const out: FhirResource[] = [];
if (patient.vitalsBp) {
const bp = base("bp", "Blood pressure");
const m = /^(\d+)\s*\/\s*(\d+)/.exec(patient.vitalsBp.trim());
if (m) {
out.push({
...bp,
component: [
{
code: { text: "Systolic blood pressure" },
valueQuantity: { value: Number(m[1]), unit: "mmHg" },
},
{
code: { text: "Diastolic blood pressure" },
valueQuantity: { value: Number(m[2]), unit: "mmHg" },
},
],
});
} else {
out.push({ ...bp, valueString: patient.vitalsBp });
}
}
if (patient.vitalsHr)
out.push({ ...base("hr", "Heart rate"), valueString: patient.vitalsHr });
if (patient.vitalsTemp)
out.push({
...base("temp", "Body temperature"),
valueString: patient.vitalsTemp,
});
if (patient.vitalsSpo2)
out.push({
...base("spo2", "Oxygen saturation"),
valueString: patient.vitalsSpo2,
});
return out;
}
// --- AllergyIntolerance -----------------------------------------------------
export function allergyResource(
row: AllergyRow,
patient: PatientRow,
): FhirResource {
return {
resourceType: "AllergyIntolerance",
id: row.id,
clinicalStatus: {
coding: [
{
system:
"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
code: "active",
},
],
},
code: { text: row.substance },
patient: subjectRef(patient),
criticality: row.severity === "severe" ? "high" : "low",
reaction: [
{ manifestation: [{ text: row.reaction }], severity: row.severity },
],
};
}
// --- Condition --------------------------------------------------------------
export function conditionResource(
row: ProblemRow,
patient: PatientRow,
): FhirResource {
return {
resourceType: "Condition",
id: row.id,
clinicalStatus: {
coding: [
{
system: "http://terminology.hl7.org/CodeSystem/condition-clinical",
code: "active",
},
],
},
code: { text: row.label },
subject: subjectRef(patient),
...(row.since ? { onsetString: row.since } : {}),
};
}
// --- MedicationRequest ------------------------------------------------------
export function medicationRequestResource(
row: PrescriptionRow,
patient: PatientRow,
): FhirResource {
const status =
row.status === "completed"
? "completed"
: row.status === "expired"
? "stopped"
: "active";
const dosageText = [row.dose, row.frequency].filter(Boolean).join(" ").trim();
return {
resourceType: "MedicationRequest",
id: row.id,
status,
intent: "order",
medicationCodeableConcept: { text: row.medication },
subject: subjectRef(patient),
...(row.prescribedAt ? { authoredOn: row.prescribedAt } : {}),
requester: { display: row.prescriber },
...(dosageText ? { dosageInstruction: [{ text: dosageText }] } : {}),
};
}
// --- Encounter --------------------------------------------------------------
function narrative(text: string): Record<string, unknown> {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return {
status: "generated",
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escaped}</div>`,
};
}
export function encounterResource(
row: EncounterRow,
patient: PatientRow,
): FhirResource {
const start = fhirDateTime(row.date);
return {
resourceType: "Encounter",
id: row.id,
...(row.summary ? { text: narrative(row.summary) } : {}),
status: "finished",
class: {
system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
code: "AMB",
display: "ambulatory",
},
type: [{ text: row.type }],
subject: subjectRef(patient),
...(start ? { period: { start } } : {}),
participant: [{ individual: { display: row.provider } }],
};
}
// --- Appointment ------------------------------------------------------------
export function appointmentResource(
row: AppointmentRow,
patient: PatientRow,
): FhirResource {
const statusMap: Record<string, string> = {
confirmed: "booked",
"checked-in": "arrived",
completed: "fulfilled",
cancelled: "cancelled",
};
// Combine local date + time into an instant; omit when unparseable.
const startDate =
row.date && row.time ? new Date(`${row.date}T${row.time}:00`) : null;
const start =
startDate && !Number.isNaN(startDate.getTime())
? startDate.toISOString()
: undefined;
return {
resourceType: "Appointment",
id: row.id,
status: statusMap[row.status] ?? "booked",
description: row.type,
...(start ? { start } : {}),
participant: [
{ actor: subjectRef(patient), status: "accepted" },
...(row.provider
? [{ actor: { display: row.provider }, status: "accepted" }]
: []),
],
};
}
+49
View File
@@ -53,6 +53,8 @@ function toPatient(row: PatientRow, children: Children): Patient {
primaryProviderId: row.primaryProviderId,
status: row.status,
initials: row.initials,
phone: row.phone,
bloodType: row.bloodType,
allergies: children.allergies,
alerts: row.alerts,
medications: children.medications,
@@ -82,6 +84,8 @@ const EMPTY_TREND: Trend = { label: "", unit: "", points: [] };
function redactClinical(patient: Patient): Patient {
return {
...patient,
// bloodType is clinical PHI; phone is a demographic/contact field and stays.
bloodType: "",
allergies: [],
alerts: [],
medications: [],
@@ -116,6 +120,8 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string)
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
bloodType: input.bloodType,
alerts: input.alerts,
vitalsBp: input.vitals.bp,
vitalsHr: input.vitals.hr,
@@ -147,6 +153,8 @@ function demographicColumns(
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
bloodType: "",
source: input.source,
alerts: [] as string[],
vitalsBp: "",
@@ -172,6 +180,7 @@ function demographicUpdateColumns(input: PatientInput) {
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
};
}
@@ -570,6 +579,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.
+295
View File
@@ -0,0 +1,295 @@
// Patient Portal actions, shared by the public REST kiosk (routes/portal.ts)
// and the relay path used by the wallet app (relay-client.ts handles
// `portal:request` and dispatches here). Both go through the same clinic-scoped
// logic so a booking made in the phone shows up on the clinic's Appointments
// page exactly like a kiosk booking.
//
// The relay path identifies the patient by their *verified* wallet number (the
// relay only forwards a request after the device signed the relay challenge),
// which is more trustworthy than the kiosk's name + file-number check.
import { readFile } from "node:fs/promises";
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "../db/index.js";
import { member, organization, user } from "../db/schema/auth.js";
import { patients } from "../db/schema/patients.js";
import { staffProfile } from "../db/schema/staff-profile.js";
import { appointmentInputSchema } from "../lib/appointment-validation.js";
import { HttpError } from "../lib/http-error.js";
import { initialsFromName } from "../lib/initials.js";
import { recordActivity } from "./activity.js";
import { createAppointment, listAppointments } from "./appointments.js";
import {
absolutePath,
getAttachmentRow,
listAttachments,
} from "./attachments.js";
import { getPatient } from "./patients.js";
// Clinical-capable roles that can be a patient's provider (mirrors portal.ts).
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
const norm = (s: string) => s.trim().toLowerCase();
export type PortalDoctor = { name: string; specialty: string | null };
export async function getClinicInfo(orgId: string): Promise<{ name: string }> {
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, orgId))
.limit(1);
if (!org) throw new HttpError(404, "Clinic not found.");
return { name: org.name };
}
export async function listDoctors(orgId: string): Promise<PortalDoctor[]> {
const rows = await db
.select({ name: user.name, specialty: staffProfile.specialty })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.leftJoin(
staffProfile,
and(
eq(staffProfile.userId, member.userId),
eq(staffProfile.organizationId, member.organizationId),
),
)
.where(
and(
eq(member.organizationId, orgId),
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
),
)
.orderBy(asc(user.name));
return rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null }));
}
// Taken time slots for a provider on a day, so the client renders only free
// ones. Mirrors routes/portal.ts (an empty-provider appointment blocks the slot
// clinic-wide). Booking re-checks server-side.
export async function getAvailability(
orgId: string,
provider: string,
date: string,
): Promise<{ date: string; provider: string; taken: string[] }> {
const taken = (await listAppointments(orgId))
.filter(
(a) =>
a.status !== "cancelled" &&
a.date === date &&
(!provider || !a.provider || a.provider === provider),
)
.map((a) => a.time);
return { date, provider, taken: [...new Set(taken)].sort() };
}
// --- wallet linkage ---------------------------------------------------------
// Save a wallet number onto a patient file after verifying the patient's
// identity (name + file number, like the kiosk). Once linked, clinic→wallet
// pushes and portal actions resolve to this file directly.
export async function linkWallet(
orgId: string,
walletNumber: string,
fileNumber: string,
name: string,
): Promise<{ fileNumber: string; name: string }> {
const patient = await getPatient(orgId, fileNumber);
if (!patient || norm(patient.name) !== norm(name)) {
throw new HttpError(
404,
"We couldn't find a record matching that name and file number.",
);
}
await db
.update(patients)
.set({ walletNumber })
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, patient.fileNumber),
),
);
await recordActivity({
orgId,
actor: { id: "", name: patient.name },
action: `Patient portal — ${patient.name} linked a wallet`,
entityType: "patient",
entityId: patient.fileNumber,
});
return { fileNumber: patient.fileNumber, name: patient.name };
}
// The file number a linked wallet maps to, or null.
export async function fileNumberForWallet(
orgId: string,
walletNumber: string,
): Promise<string | null> {
const [row] = await db
.select({ fileNumber: patients.fileNumber })
.from(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.walletNumber, walletNumber),
),
)
.limit(1);
return row?.fileNumber ?? null;
}
async function requireLinkedPatient(orgId: string, walletNumber: string) {
const fileNumber = await fileNumberForWallet(orgId, walletNumber);
if (!fileNumber) {
throw new HttpError(403, "This wallet isn't linked to a record at this clinic.");
}
const patient = await getPatient(orgId, fileNumber);
if (!patient) throw new HttpError(404, "Linked record not found.");
return patient;
}
// Book an appointment for the linked wallet (conflict-checked), attributed to
// the patient's file so it appears on the clinic's Appointments page.
export async function bookForWallet(
orgId: string,
walletNumber: string,
body: { date: string; time: string; type?: string; provider?: string },
): Promise<{ date: string; time: string; type: string; provider: string }> {
const patient = await requireLinkedPatient(orgId, walletNumber);
const today = new Date().toISOString().slice(0, 10);
if (body.date < today) throw new HttpError(400, "Please pick a future date.");
const input = appointmentInputSchema.parse({
fileNumber: patient.fileNumber,
name: patient.name,
initials: patient.initials || initialsFromName(patient.name),
date: body.date,
time: body.time,
type: body.type || "Self-service booking",
provider: body.provider || patient.pcp || "",
status: "confirmed",
source: "manual",
});
const taken = (await listAppointments(orgId)).some(
(a) =>
a.status !== "cancelled" &&
a.date === input.date &&
a.time === input.time &&
(!input.provider || !a.provider || a.provider === input.provider),
);
if (taken) {
throw new HttpError(409, "That time slot is already taken. Please choose another time.");
}
const created = await createAppointment(orgId, "", input);
await recordActivity({
orgId,
actor: { id: "", name: patient.name },
action: `Patient portal booking — ${patient.name} on ${created.date} ${created.time}`,
entityType: "appointment",
entityId: created.id,
});
return {
date: created.date,
time: created.time,
type: created.type,
provider: created.provider,
};
}
// Results view for the linked wallet: upcoming appointments + downloadable lab
// files (metadata only; bytes come from `getResultFile`).
export async function resultsForWallet(
orgId: string,
walletNumber: string,
): Promise<{
name: string;
upcoming: { date: string; time: string; type: string; provider: string; status: string }[];
files: { id: string; filename: string; mimeType: string; sizeBytes: number; labKey: string | null }[];
}> {
const patient = await requireLinkedPatient(orgId, walletNumber);
const now = new Date();
const upcoming = (await listAppointments(orgId))
.filter(
(a) =>
a.fileNumber === patient.fileNumber &&
a.status !== "cancelled" &&
new Date(`${a.date}T${a.time}`) >= now,
)
.map((a) => ({
date: a.date,
time: a.time,
type: a.type,
provider: a.provider,
status: a.status,
}));
const files = (await listAttachments(orgId, patient.fileNumber)).map((f) => ({
id: f.id,
filename: f.filename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
labKey: f.labKey,
}));
return { name: patient.name, upcoming, files };
}
// A single lab/result file for the linked wallet, base64-encoded so it can ride
// back over the relay. Verifies the file belongs to the patient's own record.
export async function resultFileForWallet(
orgId: string,
walletNumber: string,
attachmentId: string,
): Promise<{ filename: string; mimeType: string; base64: string }> {
const patient = await requireLinkedPatient(orgId, walletNumber);
const row = await getAttachmentRow(orgId, attachmentId);
if (!row || row.fileNumber !== patient.fileNumber) {
throw new HttpError(404, "File not found.");
}
const bytes = await readFile(absolutePath(row.storagePath));
return {
filename: row.filename,
mimeType: row.mimeType,
base64: bytes.toString("base64"),
};
}
// --- relay dispatch ---------------------------------------------------------
type PortalPayload = Record<string, unknown>;
// Dispatch a `portal:request` relayed from a wallet device. `walletNumber` is
// the device's relay-verified wallet number (empty for the public reads).
export async function handlePortalRequest(
orgId: string,
req: { action: string; payload: PortalPayload; walletNumber: string },
): Promise<unknown> {
const { action, payload, walletNumber } = req;
const s = (k: string): string => String(payload[k] ?? "");
switch (action) {
case "clinic":
return getClinicInfo(orgId);
case "doctors":
return listDoctors(orgId);
case "availability":
return getAvailability(orgId, s("provider"), s("date"));
case "link":
return linkWallet(orgId, walletNumber, s("fileNumber"), s("name"));
case "book":
return bookForWallet(orgId, walletNumber, {
date: s("date"),
time: s("time"),
type: s("type") || undefined,
provider: s("provider") || undefined,
});
case "results":
return resultsForWallet(orgId, walletNumber);
case "result-file":
return resultFileForWallet(orgId, walletNumber, s("id"));
default:
throw new HttpError(400, `Unknown portal action: ${action}`);
}
}
+249
View File
@@ -0,0 +1,249 @@
// 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 { HttpError } from "../lib/http-error.js";
import { handlePortalRequest } from "./portal.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 });
}
},
);
// A wallet app made a Patient Portal request over the relay (book, view
// results, link, …). The relay forwards it here with the device's verified
// wallet number; we run the same portal logic the web kiosk uses and ack the
// result back down the relay to the device.
hub.on(
"portal:request",
async (
payload: {
action?: string;
payload?: Record<string, unknown>;
walletNumber?: string;
},
ack?: Ack,
) => {
try {
const data = await handlePortalRequest(orgId, {
action: String(payload?.action ?? ""),
payload: payload?.payload ?? {},
walletNumber: String(payload?.walletNumber ?? ""),
});
ack?.({ ok: true, data });
} catch (err) {
const status = err instanceof HttpError ? err.status : 500;
ack?.({ ok: false, error: (err as Error).message, status });
}
},
);
// The patient revoked a previously shared record; delete it from the clinic.
hub.on(
"wallet:revoke",
async (payload: { requestId?: string; walletNumber?: string }, ack?: Ack) => {
try {
const result = await walletShare.revokeShare(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
);
ack?.({ ok: !!result });
} catch {
ack?.({ ok: false });
}
},
);
}
+34
View File
@@ -74,6 +74,40 @@ export async function rotateKey(orgId: string): Promise<SigningKeyView> {
return mintKey(orgId, true);
}
// Whether this clinic has joined the Temetro Network relay. Defaults to `false`
// when the clinic has no signing key yet (it hasn't opted in).
export async function getNetworkEnabled(orgId: string): Promise<boolean> {
const [row] = await db
.select({ networkEnabled: clinicSigningKeys.networkEnabled })
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.organizationId, orgId));
return row?.networkEnabled ?? false;
}
// Org ids of every clinic currently on the network — used at startup to open a
// relay hub connection for each.
export async function networkEnabledOrgs(): Promise<string[]> {
const rows = await db
.select({ organizationId: clinicSigningKeys.organizationId })
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.networkEnabled, true));
return rows.map((r) => r.organizationId);
}
// Join or leave the Temetro Network. Ensures the clinic has a signing key first
// (the relay authenticates with it), then flips the flag. Returns the new state.
export async function setNetworkEnabled(
orgId: string,
enabled: boolean,
): Promise<boolean> {
await getOrCreateKey(orgId);
await db
.update(clinicSigningKeys)
.set({ networkEnabled: enabled })
.where(eq(clinicSigningKeys.organizationId, orgId));
return enabled;
}
// Sign a message with the clinic's signing key (creating one if needed). Returns
// the signature + public key so a verifier can check provenance.
export async function signWithClinicKey(
+16
View File
@@ -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
+272
View File
@@ -0,0 +1,272 @@
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 { patients } from "../db/schema/patients.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 { listAppointments } from "./appointments.js";
import { listInvoices } from "./invoices.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> {
// Preferred: the wallet number the patient linked from the Patient Portal
// (stored directly on the file). Falls back to a permanent, approved,
// committed share for records imported the older way.
const [linked] = await db
.select({ walletNumber: patients.walletNumber })
.from(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, fileNumber),
isNotNull(patients.walletNumber),
),
)
.limit(1);
if (linked?.walletNumber) return linked.walletNumber;
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.");
// Appointments and invoices live in their own tables (not on the Patient
// snapshot), so pull the ones for this patient and ship them alongside — the
// wallet has no other way to see them and they'd otherwise silently vanish.
const [orgAppointments, orgInvoices] = await Promise.all([
listAppointments(orgId),
listInvoices(orgId),
]);
const appointments = orgAppointments.filter(
(a) => a.fileNumber === fileNumber,
);
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient` (+ appointments/invoices).
const bundle = utf8ToBytes(
JSON.stringify({ patient, appointments, invoices, changes }),
);
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
const sealed = seal(x25519Hex, bundle);
const [row] = await db
.insert(walletRecordUpdates)
.values({
organizationId: orgId,
createdBy: userId,
fileNumber,
walletNumber,
payloadSealed: sealed,
clinicSignature: signature,
clinicPublicKey: publicKey,
clinicFingerprint: fingerprint(hexToBytes(publicKey)),
changes,
})
.returning();
return row!;
}
// Build the wire event for a stored update row (joins the clinic name).
export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, row.organizationId));
return {
requestId: row.id,
clinicName: org?.name ?? "A clinic",
sealed: row.payloadSealed,
signature: row.clinicSignature,
clinicPublicKey: row.clinicPublicKey,
fingerprint: row.clinicFingerprint,
changes: row.changes,
createdAt: row.createdAt.toISOString(),
};
}
// Every unresolved update for a wallet — re-sent on each authenticated connect
// so an offline device eventually receives what it missed.
// Pending updates a wallet missed, scoped to one clinic — the relay delivers a
// `wallet:online` over that clinic's own hub connection, so a clinic only ever
// re-sends its *own* updates (never another clinic's).
export async function pendingUpdatesForWallet(
orgId: string,
walletNumber: string,
): Promise<UpdateRow[]> {
return db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.organizationId, orgId),
eq(walletRecordUpdates.walletNumber, walletNumber),
isNull(walletRecordUpdates.resolvedAt),
),
)
.orderBy(walletRecordUpdates.createdAt);
}
// Mark a pending update delivered (best-effort; only advances from pending).
export async function markDelivered(id: string): Promise<void> {
await db
.update(walletRecordUpdates)
.set({ status: "delivered", deliveredAt: new Date() })
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.status, "pending"),
),
);
}
// Apply the patient's decision relayed back from the wallet. Verifies the
// wallet's Ed25519 signature over `${decision}:${requestId}` (provenance) before
// resolving. Returns the resolved view, or null when unknown/already resolved.
export async function applyUpdateResponse(
requestId: string,
walletNumber: string,
decision: "approved" | "denied",
signatureHex?: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.id, requestId));
if (!row || row.resolvedAt) return null;
if (row.walletNumber !== walletNumber.trim()) return null;
if (!signatureHex) return null;
const publicKey = decodeWalletNumber(walletNumber);
const message = utf8ToBytes(`${decision}:${requestId}`);
if (!verifySignature(publicKey, signatureHex, message)) {
throw new HttpError(400, "Response signature did not match the wallet.");
}
const [updated] = await db
.update(walletRecordUpdates)
.set({ status: decision, resolvedAt: new Date() })
.where(eq(walletRecordUpdates.id, requestId))
.returning();
return updated ? toView(updated) : null;
}
// Recent update pushes for the clinic (Signing panel "Sent updates" list).
export async function listUpdates(
orgId: string,
limit = 30,
): Promise<WalletUpdateView[]> {
const rows = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.organizationId, orgId))
.orderBy(desc(walletRecordUpdates.createdAt))
.limit(limit);
return rows.map(toView);
}
export async function getUpdate(
orgId: string,
id: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.organizationId, orgId),
),
);
return row ? toView(row) : null;
}
+3
View File
@@ -15,6 +15,9 @@ declare global {
};
organizationId?: string;
memberRole?: string;
// Set by the FHIR bearer-auth middleware (machine-to-machine API key)
// instead of a Better Auth session; used for org scoping + audit.
fhirKey?: { id: string; name: string };
}
}
}
+2
View File
@@ -61,6 +61,8 @@ export type Patient = {
primaryProviderId?: string | null; // user id of the responsible clinician
status: PatientStatus;
initials: string; // for AvatarFallback
phone?: string; // contact number (demographic; visible to reception)
bloodType?: string; // e.g. "O+"; clinical — redacted for reception
allergies: Allergy[];
alerts: string[];
medications: Medication[];
+8 -2
View File
@@ -104,12 +104,18 @@ white/6%), so layered surfaces stay close in lightness.
## i18n
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, English resources in
`lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, resources in
`lib/i18n/locales/<lng>/translation.json`). `components/i18n-provider.tsx` wraps the app in
`app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`)
in **client** components. To add a language, drop a `locales/<lng>/translation.json` and register it
in `resources`/`supportedLngs` in `config.ts`.
> **Translate into EVERY locale, not just English.** The app ships multiple languages
> (`lib/i18n/locales/`: currently `en`, `de`, `fr`, `ar`, `so`). Whenever you add or rename a
> translation key, add it to **all** `locales/*/translation.json` files with a real translation for
> each language (not the English string copied over) — leaving a key in only `en/` ships a broken UI
> in the others. Keep the nested structure identical across every locale file.
**Coverage:** essentially all user-facing strings are now keyed (every app page + its dialogs/sheets,
auth pages, settings panels, the sidebar/user menu, chat input, patient cards/detail/form, messages,
notifications, notes). Keys are grouped by feature (`appointments.*`, `patientCard.*`, `messages.*`,
@@ -243,6 +243,11 @@ function SummaryCard({
label={t("patientCard.summary.allergies")}
value={patient.allergies.length || t("patientCard.summary.none")}
/>
<Stat
label={t("patientCard.summary.bloodType")}
value={patient.bloodType || "—"}
/>
<Stat label={t("patientCard.summary.phone")} value={patient.phone || "—"} />
</div>
<AlertBadges alerts={patient.alerts} />
{onEdit ? (
@@ -243,6 +243,8 @@ export function PatientFormDialog({
// per-doctor visibility), not free text. `providerId` is the selected user id.
const [providers, setProviders] = useState<Provider[]>([]);
const [providerId, setProviderId] = useState(patient?.primaryProviderId ?? "");
const [phone, setPhone] = useState(patient?.phone ?? "");
const [bloodType, setBloodType] = useState(patient?.bloodType ?? "");
const [bp, setBp] = useState(patient?.vitals.bp ?? "");
const [hr, setHr] = useState(patient?.vitals.hr ?? "");
const [temp, setTemp] = useState(patient?.vitals.temp ?? "");
@@ -310,6 +312,8 @@ export function PatientFormDialog({
primaryProviderId: providerId || null,
status,
initials: initialsFromName(name),
phone: phone.trim(),
bloodType,
allergies: allergies.filter((a) => a.substance.trim()),
alerts: patient?.alerts ?? [],
medications: medications.filter((m) => m.name.trim()),
@@ -515,6 +519,31 @@ export function PatientFormDialog({
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t("patientForm.phone")}>
<Input
inputMode="tel"
onChange={(event) => setPhone(event.target.value)}
placeholder={t("patientForm.phonePlaceholder")}
value={phone}
/>
</Field>
<Field label={t("patientForm.bloodType")}>
<select
className={controlClass}
onChange={(event) => setBloodType(event.target.value)}
value={bloodType}
>
<option value="">{t("patientForm.bloodTypeUnknown")}</option>
{["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"].map((bt) => (
<option key={bt} value={bt}>
{bt}
</option>
))}
</select>
</Field>
</div>
{showClinical && (
<>
<div className="flex flex-col gap-1.5">
@@ -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,12 +301,29 @@ 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")}
@@ -328,6 +359,14 @@ export function PatientDetail({
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
<Stat
label={t("patientCard.summary.bloodType")}
value={patient.bloodType || "—"}
/>
<Stat
label={t("patientCard.summary.phone")}
value={patient.phone || "—"}
/>
</div>
</Section>
@@ -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>
);
}
+54 -1
View File
@@ -8,9 +8,11 @@ import {
ChevronRight,
FlaskConical,
Loader2,
Smartphone,
} from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import { Button } from "@/components/ui/button";
import {
@@ -25,13 +27,15 @@ import {
bookPortalAppointment,
createPortalPatient,
getPortalClinic,
getPortalLink,
lookupPortalResults,
portalPairingUri,
type PortalBookingResult,
type PortalResults,
} from "@/lib/portal";
import { cn } from "@/lib/utils";
type Step = "choose" | "book" | "results";
type Step = "choose" | "book" | "results" | "wallet";
const todayKey = () => new Date().toISOString().slice(0, 10);
@@ -93,6 +97,8 @@ export function PortalKiosk({ clinic }: { clinic: string }) {
<ChooseStep onPick={setStep} />
) : step === "book" ? (
<BookStep clinic={clinic} onBack={() => setStep("choose")} />
) : step === "wallet" ? (
<WalletStep clinic={clinic} onBack={() => setStep("choose")} />
) : (
<ResultsStep clinic={clinic} onBack={() => setStep("choose")} />
)}
@@ -117,6 +123,12 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
title: t("portal.choose.resultsTitle"),
desc: t("portal.choose.resultsDesc"),
},
{
step: "wallet",
icon: <Smartphone className="size-7" />,
title: t("portal.choose.walletTitle"),
desc: t("portal.choose.walletDesc"),
},
];
return (
<div className="grid w-full gap-4 sm:grid-cols-2">
@@ -157,6 +169,47 @@ function BackButton({ onBack }: { onBack: () => void }) {
);
}
// Show a QR the patient scans with the temetro wallet app to link it to this
// clinic over the Temetro Network relay. After linking, the app can book
// appointments and view/download results itself, syncing back to the clinic.
function WalletStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
const { t } = useTranslation();
const [uri, setUri] = useState<string | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
getPortalLink(clinic)
.then((link) => active && setUri(portalPairingUri(link)))
.catch(() => active && setError(true));
return () => {
active = false;
};
}, [clinic]);
return (
<div className="flex w-full flex-col items-center gap-5">
<BackButton onBack={onBack} />
<h2 className="font-semibold text-xl">{t("portal.wallet.title")}</h2>
<p className="max-w-md text-center text-muted-foreground text-sm">
{t("portal.wallet.subtitle")}
</p>
{uri ? (
<div className="rounded-2xl bg-white p-4">
<QRCodeSvg value={uri} size={232} />
</div>
) : error ? (
<p className="text-destructive text-sm">{t("portal.wallet.error")}</p>
) : (
<Loader2 className="size-6 animate-spin text-muted-foreground" />
)}
<p className="max-w-md text-center text-muted-foreground text-xs">
{t("portal.wallet.hint")}
</p>
</div>
);
}
function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
const { t } = useTranslation();
// "returning" = has a file number; "new" = register first, then book.
@@ -6,19 +6,25 @@ 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,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { ClinicLocationSection } from "@/components/settings/settings-location";
import { PatientPortalSection } from "@/components/settings/settings-portal";
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 +39,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 +92,32 @@ export function SigningPanel() {
}
};
const toggleNetwork = async (next: boolean) => {
setNetworkSaving(true);
// Optimistic — revert on failure.
setNetworkOn(next);
try {
const saved = await setNetworkEnabled(next);
setNetworkOn(saved);
notify.success(
next
? t("settings.network.joinedTitle")
: t("settings.network.leftTitle"),
next
? t("settings.network.joinedBody")
: t("settings.network.leftBody"),
);
} catch {
setNetworkOn(!next);
notify.error(
t("settings.network.errorTitle"),
t("settings.network.error"),
);
} finally {
setNetworkSaving(false);
}
};
const recordStatusLabel = (status: SharedRecord["status"]): string =>
t(
`settings.signing.records.status${
@@ -130,6 +172,45 @@ 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>
<PatientPortalSection />
<ClinicLocationSection />
<SettingsSection
description={t("settings.signing.identityDescription")}
title={t("settings.signing.identityTitle")}
@@ -223,6 +304,40 @@ export function SigningPanel() {
</SettingsCard>
)}
</SettingsSection>
<SettingsSection
description={t("walletUpdatesList.description")}
title={t("walletUpdatesList.title")}
>
{updates.length === 0 ? (
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("walletUpdatesList.none")}
</p>
</SettingsCard>
) : (
<SettingsCard className="divide-y divide-border">
{updates.map((update) => (
<div
className="flex items-center justify-between gap-3 px-4 py-3.5"
key={update.id}
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm">
{update.changes.join(" · ") || `#${update.fileNumber}`}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
#{update.fileNumber} · {formatDate(update.createdAt)}
</p>
</div>
<Badge variant="secondary">
{t(`walletPush.status.${update.status}.title`)}
</Badge>
</div>
))}
</SettingsCard>
)}
</SettingsSection>
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { CheckCircle2, CircleDashed, XCircle } from "lucide-react";
import { CheckCircle2, CircleDashed, Copy, KeyRound, Trash2, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -13,10 +13,15 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { API_BASE_URL } from "@/lib/api-client";
import {
createFhirKey,
type FhirApiKey,
type IntegrationConfig,
type IntegrationType,
listFhirKeys,
listIntegrations,
revokeFhirKey,
saveIntegration,
testIntegration,
} from "@/lib/integrations";
@@ -191,6 +196,216 @@ function IntegrationCard({
);
}
// The read-only FHIR R4 server. Unlike the integration cards above (which make
// temetro a FHIR *client*), this exposes temetro's own records over `/fhir` to
// external systems, authenticated with per-clinic API keys. Owner/admin only:
// the component self-gates by hiding when the keys fetch is forbidden.
function FhirServerCard() {
const { t } = useTranslation();
const [keys, setKeys] = useState<FhirApiKey[] | null>(null);
const [allowed, setAllowed] = useState(true);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [freshSecret, setFreshSecret] = useState<string | null>(null);
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
const baseUrl = `${API_BASE_URL}/fhir`;
useEffect(() => {
let active = true;
listFhirKeys()
.then((rows) => active && setKeys(rows))
.catch(() => {
if (active) {
setAllowed(false);
setKeys([]);
}
});
return () => {
active = false;
};
}, []);
const create = async () => {
if (!name.trim() || creating) return;
setCreating(true);
try {
const created = await createFhirKey(name.trim());
setFreshSecret(created.secret);
setKeys((prev) => [created, ...(prev ?? [])]);
setName("");
} catch {
notify.error(
t("settings.integrations.fhirServer.createFailed"),
t("settings.integrations.fhirServer.createFailedBody"),
);
} finally {
setCreating(false);
}
};
const revoke = async (id: string) => {
try {
await revokeFhirKey(id);
setKeys((prev) =>
(prev ?? []).map((k) => (k.id === id ? { ...k, revoked: true } : k)),
);
} catch {
notify.error(
t("settings.integrations.fhirServer.revokeFailed"),
t("settings.integrations.fhirServer.revokeFailedBody"),
);
} finally {
setConfirmRevoke(null);
}
};
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
notify.success(label, "");
} catch {
// Clipboard blocked — no-op; the value is visible for manual copy.
}
};
if (!allowed) return null;
return (
<SettingsSection
description={t("settings.integrations.fhirServer.description")}
title={t("settings.integrations.fhirServer.title")}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.fhirServer.baseUrl")}</FieldLabel>
<div className="flex items-center gap-2">
<Input readOnly value={baseUrl} />
<Button
onClick={() =>
copy(baseUrl, t("settings.integrations.fhirServer.copiedUrl"))
}
size="icon"
variant="outline"
>
<Copy className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.baseUrlHint")}
</p>
</div>
{freshSecret ? (
<div className="space-y-2 rounded-2xl border border-primary/40 bg-primary/5 p-4">
<p className="text-sm font-medium">
{t("settings.integrations.fhirServer.secretTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.secretHint")}
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg bg-muted px-3 py-2 font-mono text-xs">
{freshSecret}
</code>
<Button
onClick={() =>
copy(
freshSecret,
t("settings.integrations.fhirServer.copiedSecret"),
)
}
size="icon"
variant="outline"
>
<Copy className="size-4" />
</Button>
</div>
<Button onClick={() => setFreshSecret(null)} size="sm" variant="ghost">
{t("settings.integrations.fhirServer.dismissSecret")}
</Button>
</div>
) : null}
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.fhirServer.newKey")}</FieldLabel>
<div className="flex items-center gap-2">
<Input
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
placeholder={t("settings.integrations.fhirServer.newKeyPlaceholder")}
value={name}
/>
<Button disabled={creating || !name.trim()} onClick={create} size="sm">
<KeyRound className="size-4" />
{creating
? t("settings.integrations.fhirServer.creating")
: t("settings.integrations.fhirServer.create")}
</Button>
</div>
</div>
{keys && keys.length > 0 ? (
<ul className="divide-y rounded-2xl border">
{keys.map((k) => (
<li
key={k.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{k.name}</p>
<p className="text-xs text-muted-foreground">
{k.lastUsedAt
? t("settings.integrations.fhirServer.lastUsed", {
when: new Date(k.lastUsedAt).toLocaleString(),
})
: t("settings.integrations.fhirServer.neverUsed")}
</p>
</div>
{k.revoked ? (
<Badge variant="outline">
{t("settings.integrations.fhirServer.revoked")}
</Badge>
) : confirmRevoke === k.id ? (
<div className="flex items-center gap-2">
<Button
onClick={() => revoke(k.id)}
size="sm"
variant="destructive"
>
{t("settings.integrations.fhirServer.confirmRevoke")}
</Button>
<Button
onClick={() => setConfirmRevoke(null)}
size="sm"
variant="ghost"
>
{t("settings.integrations.fhirServer.cancel")}
</Button>
</div>
) : (
<Button
onClick={() => setConfirmRevoke(k.id)}
size="sm"
variant="outline"
>
<Trash2 className="size-4" />
{t("settings.integrations.fhirServer.revoke")}
</Button>
)}
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.noKeys")}
</p>
)}
</SettingsCard>
</SettingsSection>
);
}
export function IntegrationsPanel() {
const { t } = useTranslation();
const [configs, setConfigs] = useState<IntegrationConfig[] | null>(null);
@@ -231,6 +446,7 @@ export function IntegrationsPanel() {
} satisfies IntegrationConfig);
return <IntegrationCard initial={initial} key={type} type={type} />;
})}
<FhirServerCard />
</div>
);
}
@@ -0,0 +1,227 @@
"use client";
import { LocateFixed } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
FieldLabel,
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { cn } from "@/lib/utils";
import { getClinicSettings, saveClinicLocation } from "@/lib/clinic";
import { reverseGeocode } from "@/lib/geocode";
import { notify } from "@/lib/toast";
// Parse a coordinate input into a number or null (empty ⇒ null). Returns
// `false` when the string is present but not a finite number, so we can flag it.
function parseCoord(value: string): number | null | false {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : false;
}
// Clinic location editor (owner/admin only — mounted inside the Signing panel).
// Persists the clinic's address + optional map coordinates so the wallet app can
// display the clinic location later.
export function ClinicLocationSection() {
const { t, i18n } = useTranslation();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [country, setCountry] = useState("");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
useEffect(() => {
let active = true;
getClinicSettings()
.then((settings) => {
if (!active) return;
const loc = settings.location;
setAddress(loc.address);
setCity(loc.city);
setCountry(loc.country);
setLatitude(loc.latitude === null ? "" : String(loc.latitude));
setLongitude(loc.longitude === null ? "" : String(loc.longitude));
})
.catch(() => {
/* keep empty defaults */
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
// Fill the location from the browser's geolocation (the clinician runs this on
// a device at the clinic): sets the coordinates, then reverse-geocodes them to
// also fill address / city / country. The address lookup is best-effort — on
// failure we keep the coordinates and just tell the user to fill the rest.
const useMyLocation = () => {
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.geoUnsupported"),
);
return;
}
setLocating(true);
navigator.geolocation.getCurrentPosition(
async (pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setLatitude(lat.toFixed(6));
setLongitude(lng.toFixed(6));
const place = await reverseGeocode(lat, lng, i18n.language);
setLocating(false);
if (place) {
if (place.address) setAddress(place.address);
if (place.city) setCity(place.city);
if (place.country) setCountry(place.country);
} else {
notify.info(
t("settings.location.savedTitle"),
t("settings.location.geoPartial"),
);
}
},
() => {
setLocating(false);
notify.error(
t("settings.location.errorTitle"),
t("settings.location.geoError"),
);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
};
const save = async () => {
const lat = parseCoord(latitude);
const lng = parseCoord(longitude);
if (lat === false || lng === false) {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.invalidCoords"),
);
return;
}
setSaving(true);
try {
await saveClinicLocation({
address: address.trim(),
city: city.trim(),
country: country.trim(),
latitude: lat,
longitude: lng,
});
notify.success(
t("settings.location.savedTitle"),
t("settings.location.savedBody"),
);
} catch {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.error"),
);
} finally {
setSaving(false);
}
};
return (
<SettingsSection
description={t("settings.location.description")}
title={t("settings.location.title")}
>
<SettingsCard className="flex flex-col gap-4 p-5">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.address")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setAddress(e.target.value)}
placeholder={t("settings.location.addressPlaceholder")}
value={address}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.city")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setCity(e.target.value)}
value={city}
/>
</label>
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.country")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setCountry(e.target.value)}
value={country}
/>
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.latitude")}</FieldLabel>
<Input
disabled={loading}
inputMode="decimal"
onChange={(e) => setLatitude(e.target.value)}
placeholder="e.g. 2.0469"
value={latitude}
/>
</label>
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.longitude")}</FieldLabel>
<Input
disabled={loading}
inputMode="decimal"
onChange={(e) => setLongitude(e.target.value)}
placeholder="e.g. 45.3182"
value={longitude}
/>
</label>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.location.coordinatesHint")}
</p>
<div className="flex flex-wrap gap-2">
<Button
className={cn("rounded-lg", whiteButton)}
disabled={loading || saving}
onClick={save}
type="button"
>
{saving
? t("settings.location.saving")
: t("settings.location.save")}
</Button>
<Button
className="rounded-lg"
disabled={loading || locating}
onClick={useMyLocation}
type="button"
variant="outline"
>
<LocateFixed className="size-4" />
{locating
? t("settings.location.locating")
: t("settings.location.useMyLocation")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
);
}
@@ -0,0 +1,117 @@
"use client";
import { ExternalLink, QrCode } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import {
CopyField,
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { authClient } from "@/lib/auth-client";
import { getPortalLink, portalPairingUri } from "@/lib/portal";
import { cn } from "@/lib/utils";
// Patient Portal section (Settings → Signing): surfaces the clinic's public
// portal link so patients can open it, copy it, or scan a QR. The portal lives
// at /portal/<org-slug>. The QR encodes a `temetro-portal:` pairing URI (relay
// URL + clinic signing key) — the wallet app scans it and talks to this clinic
// over the Temetro Network relay, so it works from a real phone (no localhost).
export function PatientPortalSection() {
const { t } = useTranslation();
const { data: activeOrg } = authClient.useActiveOrganization();
const [qrOpen, setQrOpen] = useState(false);
const [qrUri, setQrUri] = useState("");
const slug = activeOrg?.slug;
const origin = typeof window !== "undefined" ? window.location.origin : "";
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
// Fetch the relay-based pairing descriptor for the QR (non-secret).
useEffect(() => {
if (!slug) {
setQrUri("");
return;
}
let active = true;
getPortalLink(slug)
.then((link) => {
if (active) setQrUri(portalPairingUri(link));
})
.catch(() => {
if (active) setQrUri("");
});
return () => {
active = false;
};
}, [slug]);
return (
<SettingsSection
description={t("settings.portal.description")}
title={t("settings.portal.title")}
>
<SettingsCard className="flex flex-col gap-4 p-5">
<CopyField
description={t("settings.portal.linkDescription")}
label={t("settings.portal.linkLabel")}
value={portalUrl || "—"}
/>
<div className="flex flex-wrap gap-2">
<Button
className={cn("rounded-lg", whiteButton)}
disabled={!portalUrl}
onClick={() => window.open(portalUrl, "_blank", "noopener")}
type="button"
>
<ExternalLink className="size-4" />
{t("settings.portal.open")}
</Button>
<Button
className="rounded-lg"
disabled={!qrUri}
onClick={() => setQrOpen(true)}
type="button"
variant="outline"
>
<QrCode className="size-4" />
{t("settings.portal.showQr")}
</Button>
</div>
</SettingsCard>
<Dialog onOpenChange={setQrOpen} open={qrOpen}>
<DialogPopup className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t("settings.portal.qrTitle")}</DialogTitle>
<DialogDescription>
{t("settings.portal.qrDescription")}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col items-center gap-3 pb-2">
{qrUri ? (
<div className="rounded-2xl bg-white p-4">
<QRCodeSvg value={qrUri} size={220} />
</div>
) : null}
<p className="break-all text-center text-sm text-muted-foreground">
{portalUrl}
</p>
</DialogPanel>
</DialogPopup>
</Dialog>
</SettingsSection>
);
}
+12 -3
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useAiAccess } from "@/lib/ai-policy";
import { dirFor } from "@/lib/i18n/config";
import { useActiveRole, visibleNavItems } from "@/lib/roles";
import { motion } from "framer-motion";
import Image from "next/image";
@@ -27,7 +28,11 @@ import { useCallInvites } from "@/components/meetings/use-call-invites";
export function DashboardSidebar() {
const { state } = useSidebar();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Anchor the sidebar to the right for RTL locales (Arabic) so the whole shell
// mirrors instead of leaving the fixed sidebar pinned physically left.
const isRtl = dirFor(i18n.language) === "rtl";
const side = isRtl ? "right" : "left";
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const isCollapsed = state === "collapsed";
@@ -54,13 +59,17 @@ export function DashboardSidebar() {
}));
return (
<Sidebar variant="inset" collapsible="icon">
<Sidebar variant="inset" collapsible="icon" side={side}>
<SidebarHeader
className={cn(
"flex md:pt-3.5",
isCollapsed
? "flex-row items-center justify-between gap-y-4 md:flex-col md:items-start md:justify-start"
: "flex-row items-center justify-between"
: "flex-row items-center justify-between",
// RTL (Arabic): stack the logo and the toggle/bell controls into a
// right-aligned column so the collapse arrow sits ABOVE the nav icons
// instead of being pinned to the opposite (left) edge.
isRtl && !isCollapsed && "flex-col items-end justify-start gap-y-3"
)}
>
<Tooltip>
@@ -15,6 +15,7 @@ import {
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
import { dirFor } from "@/lib/i18n/config";
import { markNotificationRead, notificationHref } from "@/lib/notifications";
import { useNotifications } from "@/lib/use-notifications";
@@ -30,9 +31,11 @@ function relativeTime(iso: string): string {
}
export function NotificationsPopover() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const router = useRouter();
const { items, unread, markAllRead } = useNotifications();
// Open toward the content side; flips to the left under RTL.
const popupSide = dirFor(i18n.language) === "rtl" ? "left" : "right";
return (
<Menu
@@ -58,7 +61,7 @@ export function NotificationsPopover() {
</span>
)}
</MenuTrigger>
<MenuPopup side="right" className="my-6 w-80">
<MenuPopup side={popupSide} className="my-6 w-80">
<MenuGroup>
<MenuGroupLabel>{t("nav.notifications")}</MenuGroupLabel>
</MenuGroup>
+2 -1
View File
@@ -299,7 +299,8 @@ export function SidebarTrigger({
variant="ghost"
{...props}
>
<PanelLeftIcon />
{/* Mirror the panel arrow under RTL so it points toward the sidebar edge. */}
<PanelLeftIcon className="rtl:rotate-180" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
+1 -1
View File
@@ -19,7 +19,7 @@ export function Switch({
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block aspect-square h-full origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] data-checked:translate-x-[calc(var(--thumb-size)-4px)]",
"pointer-events-none block aspect-square h-full ltr:origin-left rtl:origin-right in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] ltr:data-checked:translate-x-[calc(var(--thumb-size)-4px)] rtl:data-checked:-translate-x-[calc(var(--thumb-size)-4px)]",
)}
data-slot="switch-thumb"
/>
+33
View File
@@ -0,0 +1,33 @@
// Client for clinic-level (organization) settings — currently the clinic's
// location (Settings → Signing → Location). Calls the backend over the shared
// fetch wrapper (session cookie sent automatically).
import { apiFetch } from "@/lib/api-client";
export type ClinicLocation = {
address: string;
city: string;
country: string;
latitude: number | null;
longitude: number | null;
};
export type ClinicSettings = {
location: ClinicLocation;
};
// The clinic's settings. Readable by any clinician; returns empty defaults when
// nothing has been set yet.
export async function getClinicSettings(): Promise<ClinicSettings> {
return apiFetch<ClinicSettings>("/api/clinic/settings");
}
// Save the clinic's location (owner/admin only). Returns the updated settings.
export async function saveClinicLocation(
location: ClinicLocation,
): Promise<ClinicSettings> {
return apiFetch<ClinicSettings>("/api/clinic/location", {
method: "PUT",
body: JSON.stringify(location),
});
}
+64
View File
@@ -0,0 +1,64 @@
// Reverse geocoding via OpenStreetMap Nominatim. Used by the clinic-location
// settings so "Use my current location" can fill the address/city/country
// fields, not just the raw coordinates.
//
// Nominatim's usage policy asks for a descriptive User-Agent/Referer and low
// request volumes — this is only hit on an explicit button click, so occasional
// lookups are well within the acceptable-use limits.
export interface ReverseGeocodeResult {
address: string;
city: string;
country: string;
}
// Reverse-geocode a coordinate into a best-effort street address, city, and
// country. Resolves to `null` on any failure (network, rate limit, no match) so
// the caller can silently fall back to coordinates-only.
export async function reverseGeocode(
latitude: number,
longitude: number,
language?: string,
): Promise<ReverseGeocodeResult | null> {
try {
const url = new URL("https://nominatim.openstreetmap.org/reverse");
url.searchParams.set("format", "jsonv2");
url.searchParams.set("lat", String(latitude));
url.searchParams.set("lon", String(longitude));
url.searchParams.set("zoom", "18");
url.searchParams.set("addressdetails", "1");
const res = await fetch(url.toString(), {
headers: {
// Nominatim requires an identifying header; browsers forbid setting
// User-Agent, so Accept-Language localises the returned names.
"Accept-Language": language || "en",
},
});
if (!res.ok) return null;
const data = (await res.json()) as {
address?: Record<string, string>;
display_name?: string;
};
const a = data.address ?? {};
// Compose a street line from the most specific parts available.
const street = [a.house_number, a.road].filter(Boolean).join(" ").trim();
const address =
street ||
a.neighbourhood ||
a.suburb ||
a.pedestrian ||
(data.display_name ? data.display_name.split(",")[0] : "") ||
"";
const city =
a.city || a.town || a.village || a.municipality || a.county || a.state || "";
const country = a.country || "";
if (!address && !city && !country) return null;
return { address, city, country };
} catch {
return null;
}
}
+180 -1
View File
@@ -1,4 +1,95 @@
{
"scribe": {
"recordVisit": "تسجيل الزيارة",
"title": "كاتب الزيارة",
"subtitle": "سجّل أو الصق زيارة لـ {{name}}، ثم راجع الملاحظة المُصاغة قبل الحفظ.",
"tabs": {
"record": "تسجيل",
"paste": "لصق النص"
},
"record": {
"start": "بدء التسجيل",
"stop": "إيقاف",
"ready": "التسجيل جاهز ({{duration}})",
"discard": "تجاهل"
},
"paste": {
"placeholder": "الصق نص الزيارة هنا…"
},
"visitType": {
"label": "نوع الزيارة (اختياري)",
"placeholder": "مثال: متابعة"
},
"consent": "يُخزَّن الصوت في ملف المريض. عند استخدام مزوّد ذكاء اصطناعي خارجي، يغادر التسجيل العيادة ليُفرَّغ نصيًّا — لا يستطيع Veil إخفاء الكلام، لذا تُخفى هوية الملاحظة المُصاغة فقط.",
"generate": "صياغة الملاحظة",
"processing": "جارٍ العمل…",
"cancel": "إلغاء",
"review": {
"type": "نوع الزيارة",
"date": "التاريخ",
"summary": "الملاحظة",
"provider": "الطبيب: {{provider}}",
"veil": "أُخفيت الهوية عبر Veil قبل {{provider}}.",
"back": "رجوع",
"save": "الحفظ في السجل"
},
"saved": {
"title": "تم حفظ ملاحظة الزيارة"
},
"errors": {
"mic": "تعذّر الوصول إلى الميكروفون. تحقّق من أذونات المتصفح أو الصق نصًّا بدلاً من ذلك.",
"noRecording": "سجّل الزيارة أولاً، أو انتقل إلى تبويب اللصق.",
"empty": "النص فارغ.",
"generic": "حدث خطأ ما. يُرجى المحاولة مرة أخرى."
}
},
"walletUpdatesList": {
"title": "التحديثات المُرسَلة إلى المحافظ",
"description": "تحديثات السجلات التي أرسلتها إلى محافظ المرضى، وما إذا كان المريض قد وافق عليها.",
"none": "لم ترسل أي تحديثات إلى المحافظ بعد."
},
"walletPush": {
"action": "إرسال إلى المحفظة",
"title": "إرسال التحديث إلى المحفظة",
"subtitle": "أرسل سجل {{name}} المُحدَّث إلى تطبيق المحفظة الخاص به. يجب أن يوافق عليه على هاتفه قبل أن يحل محل النسخة الموجودة على جهازه.",
"sectionsLabel": "ما الذي تغيّر",
"sections": {
"demographics": "البيانات الديموغرافية",
"problems": "المشكلات",
"medications": "الأدوية",
"allergies": "الحساسية",
"labs": "التحاليل",
"vitals": "العلامات الحيوية",
"visits": "الزيارات"
},
"noteLabel": "ملاحظة (اختياري)",
"notePlaceholder": "ملاحظة قصيرة عن هذا التحديث…",
"notice": "يُرسِل هذا السجل الحالي كاملاً، موقَّعًا بمفتاح عيادتك. ولا يحل محل النسخة الموجودة على جهاز المريض إلا بعد موافقته.",
"cancel": "إلغاء",
"send": "إرسال التحديث",
"done": "تم",
"errors": {
"generic": "تعذّر إرسال التحديث. يُرجى المحاولة مرة أخرى."
},
"status": {
"pending": {
"title": "بانتظار المريض",
"body": "تم إرسال التحديث. سيصل إلى هاتف المريض الآن، أو في المرة التالية التي يفتح فيها التطبيق."
},
"delivered": {
"title": "تم التسليم إلى الهاتف",
"body": "استلم المريض التحديث ويلزمه الموافقة عليه على جهازه."
},
"approved": {
"title": "وافق المريض",
"body": "وافق المريض على التحديث، وسجلّه على الجهاز محدَّث الآن."
},
"denied": {
"title": "رفض المريض",
"body": "رفض المريض هذا التحديث، لذا بقي سجلّه على الجهاز دون تغيير."
}
}
},
"common": {
"appName": "temetro",
"email": "البريد الإلكتروني",
@@ -231,6 +322,7 @@
"expiredTitle": "انتهت صلاحية الطلب",
"expiredBody": "انتهت مهلة الطلب قبل أن يستجيب المريض.",
"invalidWallet": "لا يبدو هذا رقم محفظة صالحًا.",
"networkOff": "انضم أولاً إلى شبكة Temetro (الإعدادات ← التوقيع) للمشاركة مع تطبيقات المرضى.",
"errorTitle": "تعذّر الوصول إلى المحفظة",
"error": "يرجى التحقّق من رقم المحفظة والمحاولة مرة أخرى.",
"savedTitle": "تم استيراد المريض",
@@ -1371,6 +1463,8 @@
"allergies": "الحساسيات",
"activeMeds": "الأدوية النشطة",
"openProblems": "المشكلات المفتوحة",
"bloodType": "فصيلة الدم",
"phone": "الهاتف",
"none": "لا شيء",
"editRecord": "تعديل السجل"
},
@@ -1473,6 +1567,10 @@
"primaryCare": "الرعاية الأساسية",
"primaryCarePlaceholder": "مثال: د. لينا أورتيز",
"primaryCareUnassigned": "غير مُعيّن",
"phone": "الهاتف",
"phonePlaceholder": "مثال: +1 555 010 2938",
"bloodType": "فصيلة الدم",
"bloodTypeUnknown": "غير معروف",
"currentVitals": "العلامات الحيوية الحالية",
"bp": "ضغط الدم",
"hr": "معدل ضربات القلب",
@@ -1601,6 +1699,32 @@
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON مع رمز مركز المقاصة ومعرّفات المرسل/المستقبل."
},
"fhirServer": {
"title": "خادم FHIR (مشاركة سجلاتك)",
"description": "اعرض سجلات هذه العيادة للأنظمة الخارجية كخادم FHIR R4 للقراءة فقط، مع المصادقة عبر مفاتيح API خاصة بكل عيادة.",
"baseUrl": "عنوان URL الأساسي",
"baseUrlHint": "وجّه عميل FHIR إلى هذا العنوان. يقدّم Patient وObservation وCondition وAllergyIntolerance وMedicationRequest وEncounter وAppointment.",
"copiedUrl": "تم نسخ العنوان الأساسي",
"newKey": "إنشاء مفتاح API",
"newKeyPlaceholder": "اسم المفتاح (مثل مستودع الأبحاث)",
"create": "إنشاء",
"creating": "جارٍ الإنشاء…",
"createFailed": "تعذّر إنشاء المفتاح",
"createFailedBody": "يرجى المحاولة مرة أخرى.",
"secretTitle": "انسخ مفتاح API الآن",
"secretHint": "هذه هي المرة الوحيدة التي يظهر فيها السر. احفظه في مكان آمن — لا يمكن استرجاعه لاحقًا.",
"copiedSecret": "تم نسخ مفتاح API",
"dismissSecret": "تم",
"noKeys": "لا توجد مفاتيح API بعد. أنشئ واحدًا للسماح لعميل FHIR بالاتصال.",
"lastUsed": "آخر استخدام {{when}}",
"neverUsed": "لم يُستخدم قط",
"revoke": "إبطال",
"confirmRevoke": "إبطال المفتاح",
"revoked": "تم الإبطال",
"revokeFailed": "تعذّر إبطال المفتاح",
"revokeFailedBody": "يرجى المحاولة مرة أخرى.",
"cancel": "إلغاء"
}
},
"empty": "لا شيء هنا بعد.",
@@ -1879,6 +2003,43 @@
"errorTitle": "تعذّر إضافة العضو"
}
},
"network": {
"title": "شبكة Temetro",
"description": "يتيح مُرحّل شبكة Temetro لتطبيقات محفظة المرضى الاتصال بعيادتك لمشاركة السجلات والموافقة عليها — مشفّرة من طرف إلى طرف، مع تعريف عيادتك بمفتاح التوقيع الخاص بها.",
"toggleLabel": "الانضمام إلى شبكة Temetro",
"toggleDesc": "عند التفعيل، تتصل هذه العيادة بالمُرحّل لاستيراد السجلات من تطبيقات المرضى وإرسال التحديثات إلى محافظهم.",
"statusConnected": "متصل",
"statusOff": "معطّل",
"joinedTitle": "تم الانضمام إلى شبكة Temetro",
"joinedBody": "يمكن الآن لتطبيقات محفظة المرضى الاتصال بهذه العيادة.",
"leftTitle": "تم مغادرة شبكة Temetro",
"leftBody": "لم تعد هذه العيادة متصلة بالمُرحّل.",
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
"error": "يرجى المحاولة مرة أخرى."
},
"location": {
"title": "موقع العيادة",
"description": "عنوان عيادتك وإحداثيات الخريطة. تظهر للمرضى في تطبيق المحفظة.",
"address": "العنوان",
"addressPlaceholder": "عنوان الشارع",
"city": "المدينة",
"country": "الدولة",
"latitude": "خط العرض",
"longitude": "خط الطول",
"coordinatesHint": "الإحداثيات اختيارية — تُستخدم لعرض عيادتك على الخريطة.",
"save": "حفظ الموقع",
"saving": "جارٍ الحفظ…",
"savedTitle": "تم حفظ الموقع",
"savedBody": "تم تحديث موقع عيادتك.",
"invalidCoords": "يجب أن يكون خطا العرض والطول أرقامًا.",
"errorTitle": "تعذّر حفظ الموقع",
"error": "يرجى المحاولة مرة أخرى.",
"useMyLocation": "استخدام موقعي الحالي",
"locating": "جارٍ تحديد الموقع…",
"geoUnsupported": "الموقع غير متاح على هذا الجهاز.",
"geoError": "تعذّر الحصول على موقعك. تحقّق من الأذونات وحاول مرة أخرى.",
"geoPartial": "تم إدخال الإحداثيات — الرجاء إضافة تفاصيل العنوان يدويًا."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
"active": "نشط",
@@ -1989,6 +2150,16 @@
"savedBody": "تم تحديث تهيئة الذكاء الاصطناعي الخاصة بك.",
"saveFailedTitle": "تعذّر الحفظ",
"saveFailedBody": "فشل حفظ إعدادات الذكاء الاصطناعي. يرجى المحاولة مرة أخرى."
},
"portal": {
"title": "بوابة المريض",
"description": "شارك بوابة الحجز العامة لعيادتك مع المرضى — يمكنهم رؤية أطبائك وحجز المواعيد.",
"linkLabel": "رابط البوابة",
"linkDescription": "الصفحة العامة التي يفتحها المرضى للحجز في عيادتك.",
"open": "فتح البوابة",
"showQr": "عرض رمز QR",
"qrTitle": "رمز QR لبوابة المريض",
"qrDescription": "يمسح المرضى هذا لفتح بوابة عيادتك — أو يمسحونه في تطبيق محفظة Temetro للحجز."
}
},
"portal": {
@@ -2001,7 +2172,15 @@
"bookTitle": "حجز موعد",
"bookDesc": "جدول زيارة مع فريق الرعاية الخاص بك.",
"resultsTitle": "عرض نتائجي",
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة."
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة.",
"walletTitle": "ربط محفظتي",
"walletDesc": "استخدم تطبيق temetro لحجز المواعيد وعرض النتائج بنفسك."
},
"wallet": {
"title": "اربط محفظتك",
"subtitle": "امسح هذا الرمز بتطبيق محفظة temetro لربطه بهذه العيادة.",
"error": "تعذّر تحميل رمز الربط. الرجاء سؤال مكتب الاستقبال.",
"hint": "بعد الربط، يمكنك حجز المواعيد وعرض النتائج من هاتفك."
},
"field": {
"name": "الاسم الكامل",
+180 -1
View File
@@ -1,4 +1,95 @@
{
"scribe": {
"recordVisit": "Besuch aufnehmen",
"title": "Besuchs-Schreiber",
"subtitle": "Nehmen Sie einen Besuch für {{name}} auf oder fügen Sie ihn ein und prüfen Sie die entworfene Notiz vor dem Speichern.",
"tabs": {
"record": "Aufnehmen",
"paste": "Transkript einfügen"
},
"record": {
"start": "Aufnahme starten",
"stop": "Stopp",
"ready": "Aufnahme bereit ({{duration}})",
"discard": "Verwerfen"
},
"paste": {
"placeholder": "Besuchstranskript hier einfügen…"
},
"visitType": {
"label": "Besuchsart (optional)",
"placeholder": "z. B. Nachsorge"
},
"consent": "Die Audioaufnahme wird in der Patientenakte gespeichert. Bei Nutzung eines externen KI-Anbieters verlässt die Aufnahme zur Transkription die Klinik — Veil kann Sprache nicht schwärzen, daher wird nur die entworfene Notiz anonymisiert.",
"generate": "Notiz entwerfen",
"processing": "In Arbeit…",
"cancel": "Abbrechen",
"review": {
"type": "Besuchsart",
"date": "Datum",
"summary": "Notiz",
"provider": "Behandler: {{provider}}",
"veil": "Über Veil anonymisiert vor {{provider}}.",
"back": "Zurück",
"save": "In Akte speichern"
},
"saved": {
"title": "Besuchsnotiz gespeichert"
},
"errors": {
"mic": "Kein Zugriff auf das Mikrofon. Prüfen Sie die Browser-Berechtigungen oder fügen Sie ein Transkript ein.",
"noRecording": "Nehmen Sie zuerst den Besuch auf oder wechseln Sie zum Einfügen-Tab.",
"empty": "Das Transkript ist leer.",
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
}
},
"walletUpdatesList": {
"title": "An Wallets gesendete Updates",
"description": "Aktenupdates, die Sie an Patienten-Wallets gesendet haben, und ob der Patient sie bestätigt hat.",
"none": "Sie haben noch keine Wallet-Updates gesendet."
},
"walletPush": {
"action": "An Wallet senden",
"title": "Update an Wallet senden",
"subtitle": "Senden Sie {{name}}s aktualisierte Akte an die Wallet-App. Der Patient muss sie auf dem Telefon bestätigen, bevor sie die Kopie auf dem Gerät ersetzt.",
"sectionsLabel": "Was sich geändert hat",
"sections": {
"demographics": "Stammdaten",
"problems": "Probleme",
"medications": "Medikamente",
"allergies": "Allergien",
"labs": "Labor",
"vitals": "Vitalwerte",
"visits": "Besuche"
},
"noteLabel": "Notiz (optional)",
"notePlaceholder": "Eine kurze Notiz zu diesem Update…",
"notice": "Dies sendet die vollständige aktuelle Akte, signiert mit Ihrem Klinikschlüssel. Sie ersetzt die Kopie auf dem Gerät des Patienten erst nach dessen Zustimmung.",
"cancel": "Abbrechen",
"send": "Update senden",
"done": "Fertig",
"errors": {
"generic": "Update konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
},
"status": {
"pending": {
"title": "Warten auf den Patienten",
"body": "Das Update wurde gesendet. Es erreicht das Telefon des Patienten jetzt oder beim nächsten Öffnen der App."
},
"delivered": {
"title": "An das Telefon zugestellt",
"body": "Der Patient hat das Update erhalten und muss es auf seinem Gerät bestätigen."
},
"approved": {
"title": "Vom Patienten bestätigt",
"body": "Der Patient hat das Update bestätigt; seine Akte auf dem Gerät ist nun aktuell."
},
"denied": {
"title": "Vom Patienten abgelehnt",
"body": "Der Patient hat dieses Update abgelehnt; seine Akte auf dem Gerät bleibt unverändert."
}
}
},
"common": {
"appName": "temetro",
"email": "E-Mail",
@@ -219,6 +310,7 @@
"expiredTitle": "Anfrage abgelaufen",
"expiredBody": "Die Anfrage ist abgelaufen, bevor der Patient geantwortet hat.",
"invalidWallet": "Das sieht nicht nach einer gültigen Wallet-Nummer aus.",
"networkOff": "Treten Sie zuerst dem Temetro-Netzwerk bei (Einstellungen → Signierung), um mit Patienten-Apps zu teilen.",
"errorTitle": "Wallet nicht erreichbar",
"error": "Bitte prüfen Sie die Wallet-Nummer und versuchen Sie es erneut.",
"savedTitle": "Patient importiert",
@@ -1351,6 +1443,8 @@
"allergies": "Allergien",
"activeMeds": "Aktive Medikamente",
"openProblems": "Offene Probleme",
"bloodType": "Blutgruppe",
"phone": "Telefon",
"none": "Keine",
"editRecord": "Datensatz bearbeiten"
},
@@ -1453,6 +1547,10 @@
"primaryCare": "Primärversorgung",
"primaryCarePlaceholder": "z. B. Dr. Lena Ortiz",
"primaryCareUnassigned": "Nicht zugewiesen",
"phone": "Telefon",
"phonePlaceholder": "z. B. +1 555 010 2938",
"bloodType": "Blutgruppe",
"bloodTypeUnknown": "Unbekannt",
"currentVitals": "Aktuelle Vitalwerte",
"bp": "Blutdruck",
"hr": "Herzfrequenz",
@@ -1581,6 +1679,32 @@
"endpointPlaceholder": "https://ihre-verrechnungsstelle.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON mit Ihrem Token und Absender-/Empfänger-IDs der Verrechnungsstelle."
},
"fhirServer": {
"title": "FHIR-Server (Datensätze teilen)",
"description": "Stellen Sie die Datensätze dieser Praxis externen Systemen als schreibgeschützten FHIR-R4-Server bereit, authentifiziert über praxiseigene API-Schlüssel.",
"baseUrl": "Basis-URL",
"baseUrlHint": "Richten Sie einen FHIR-Client auf diese URL. Sie liefert Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter und Appointment.",
"copiedUrl": "Basis-URL kopiert",
"newKey": "API-Schlüssel erstellen",
"newKeyPlaceholder": "Schlüsselname (z. B. Forschungslager)",
"create": "Erstellen",
"creating": "Wird erstellt…",
"createFailed": "Schlüssel konnte nicht erstellt werden",
"createFailedBody": "Bitte erneut versuchen.",
"secretTitle": "Kopieren Sie Ihren API-Schlüssel jetzt",
"secretHint": "Das Geheimnis wird nur dieses eine Mal angezeigt. Bewahren Sie es sicher auf — es kann später nicht abgerufen werden.",
"copiedSecret": "API-Schlüssel kopiert",
"dismissSecret": "Fertig",
"noKeys": "Noch keine API-Schlüssel. Erstellen Sie einen, damit sich ein FHIR-Client verbinden kann.",
"lastUsed": "Zuletzt verwendet {{when}}",
"neverUsed": "Nie verwendet",
"revoke": "Widerrufen",
"confirmRevoke": "Schlüssel widerrufen",
"revoked": "Widerrufen",
"revokeFailed": "Schlüssel konnte nicht widerrufen werden",
"revokeFailedBody": "Bitte erneut versuchen.",
"cancel": "Abbrechen"
}
},
"empty": "Hier gibt es noch nichts.",
@@ -1859,6 +1983,43 @@
"errorTitle": "Mitglied konnte nicht hinzugefügt werden"
}
},
"network": {
"title": "Temetro-Netzwerk",
"description": "Das Temetro-Netzwerk-Relay ermöglicht es den Wallet-Apps der Patienten, sich mit Ihrer Praxis zu verbinden, um Akten zu teilen und zu genehmigen — Ende-zu-Ende-verschlüsselt, wobei Ihre Praxis über ihren Signierschlüssel identifiziert wird.",
"toggleLabel": "Temetro-Netzwerk beitreten",
"toggleDesc": "Wenn aktiviert, verbindet sich diese Praxis mit dem Relay, sodass Sie Akten aus Patienten-Apps importieren und Aktualisierungen an deren Wallets senden können.",
"statusConnected": "Verbunden",
"statusOff": "Aus",
"joinedTitle": "Temetro-Netzwerk beigetreten",
"joinedBody": "Wallet-Apps der Patienten können sich jetzt mit dieser Praxis verbinden.",
"leftTitle": "Temetro-Netzwerk verlassen",
"leftBody": "Diese Praxis ist nicht mehr mit dem Relay verbunden.",
"errorTitle": "Netzwerkzugriff konnte nicht aktualisiert werden",
"error": "Bitte versuchen Sie es erneut."
},
"location": {
"title": "Standort der Klinik",
"description": "Adresse und Kartenkoordinaten Ihrer Klinik. Wird Patienten in der Wallet-App angezeigt.",
"address": "Adresse",
"addressPlaceholder": "Straße und Hausnummer",
"city": "Stadt",
"country": "Land",
"latitude": "Breitengrad",
"longitude": "Längengrad",
"coordinatesHint": "Koordinaten sind optional um Ihre Klinik auf einer Karte anzuzeigen.",
"save": "Standort speichern",
"saving": "Wird gespeichert …",
"savedTitle": "Standort gespeichert",
"savedBody": "Der Standort Ihrer Klinik wurde aktualisiert.",
"invalidCoords": "Breiten- und Längengrad müssen Zahlen sein.",
"errorTitle": "Standort konnte nicht gespeichert werden",
"error": "Bitte versuchen Sie es erneut.",
"useMyLocation": "Aktuellen Standort verwenden",
"locating": "Standort wird ermittelt …",
"geoUnsupported": "Standort ist auf diesem Gerät nicht verfügbar.",
"geoError": "Standort konnte nicht ermittelt werden. Bitte Berechtigungen prüfen und erneut versuchen.",
"geoPartial": "Koordinaten eingetragen bitte Adressdetails manuell ergänzen."
},
"signing": {
"keyTitle": "Signierschlüssel",
"active": "Aktiv",
@@ -1969,6 +2130,16 @@
"savedBody": "Ihre KI-Konfiguration wurde aktualisiert.",
"saveFailedTitle": "Speichern fehlgeschlagen",
"saveFailedBody": "Das Speichern Ihrer KI-Einstellungen ist fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"portal": {
"title": "Patientenportal",
"description": "Teilen Sie das öffentliche Buchungsportal Ihrer Klinik mit Patienten sie können Ihre Ärzte sehen und Termine buchen.",
"linkLabel": "Portal-Link",
"linkDescription": "Die öffentliche Seite, die Patienten zum Buchen bei Ihrer Klinik öffnen.",
"open": "Portal öffnen",
"showQr": "QR-Code anzeigen",
"qrTitle": "Patientenportal-QR",
"qrDescription": "Patienten scannen dies, um das Portal Ihrer Klinik zu öffnen oder scannen es in der Temetro-Wallet-App, um zu buchen."
}
},
"portal": {
@@ -1981,7 +2152,15 @@
"bookTitle": "Einen Termin buchen",
"bookDesc": "Vereinbaren Sie einen Besuch bei Ihrem Behandlungsteam.",
"resultsTitle": "Meine Ergebnisse ansehen",
"resultsDesc": "Prüfen Sie bevorstehende Besuche und ob Ergebnisse bereit sind."
"resultsDesc": "Prüfen Sie bevorstehende Besuche und ob Ergebnisse bereit sind.",
"walletTitle": "Wallet verknüpfen",
"walletDesc": "Nutzen Sie die temetro-App, um selbst zu buchen und Ergebnisse zu sehen."
},
"wallet": {
"title": "Wallet verknüpfen",
"subtitle": "Scannen Sie diesen Code mit der temetro-Wallet-App, um sie mit dieser Klinik zu verbinden.",
"error": "Der Kopplungscode konnte nicht geladen werden. Bitte fragen Sie an der Rezeption.",
"hint": "Nach der Verknüpfung können Sie Termine buchen und Ergebnisse auf Ihrem Telefon ansehen."
},
"field": {
"name": "Vollständiger Name",
+180 -1
View File
@@ -1,4 +1,95 @@
{
"scribe": {
"recordVisit": "Record visit",
"title": "Visit scribe",
"subtitle": "Record or paste a visit for {{name}}, then review the drafted note before saving.",
"tabs": {
"record": "Record",
"paste": "Paste transcript"
},
"record": {
"start": "Start recording",
"stop": "Stop",
"ready": "Recording ready ({{duration}})",
"discard": "Discard"
},
"paste": {
"placeholder": "Paste the visit transcript here…"
},
"visitType": {
"label": "Visit type (optional)",
"placeholder": "e.g. Follow-up"
},
"consent": "The audio is stored on the patient's chart. When you use an external AI provider, the recording leaves the clinic to be transcribed — Veil cannot redact speech, so only the drafted note is de-identified.",
"generate": "Draft note",
"processing": "Working…",
"cancel": "Cancel",
"review": {
"type": "Visit type",
"date": "Date",
"summary": "Note",
"provider": "Provider: {{provider}}",
"veil": "De-identified through Veil before {{provider}}.",
"back": "Back",
"save": "Save to record"
},
"saved": {
"title": "Visit note saved"
},
"errors": {
"mic": "Couldn't access the microphone. Check browser permissions or paste a transcript instead.",
"noRecording": "Record the visit first, or switch to the paste tab.",
"empty": "The transcript is empty.",
"generic": "Something went wrong. Please try again."
}
},
"walletUpdatesList": {
"title": "Updates sent to wallets",
"description": "Record updates you've pushed to patient wallets, and whether the patient has approved them.",
"none": "You haven't pushed any wallet updates yet."
},
"walletPush": {
"action": "Push to wallet",
"title": "Push update to wallet",
"subtitle": "Send {{name}}'s updated record to their wallet app. They must approve it on their phone before it replaces the copy on their device.",
"sectionsLabel": "What changed",
"sections": {
"demographics": "Demographics",
"problems": "Problems",
"medications": "Medications",
"allergies": "Allergies",
"labs": "Labs",
"vitals": "Vitals",
"visits": "Visits"
},
"noteLabel": "Note (optional)",
"notePlaceholder": "A short note about this update…",
"notice": "This sends the full current record, signed with your clinic key. It replaces the patient's on-device copy only after they approve it.",
"cancel": "Cancel",
"send": "Send update",
"done": "Done",
"errors": {
"generic": "Couldn't send the update. Please try again."
},
"status": {
"pending": {
"title": "Waiting for the patient",
"body": "The update was sent. It will arrive on the patient's phone now, or the next time they open the app."
},
"delivered": {
"title": "Delivered to the phone",
"body": "The patient has received the update and needs to approve it on their device."
},
"approved": {
"title": "Approved by the patient",
"body": "The patient approved the update and their on-device record is now up to date."
},
"denied": {
"title": "Declined by the patient",
"body": "The patient declined this update, so their on-device record is unchanged."
}
}
},
"common": {
"appName": "temetro",
"email": "Email",
@@ -219,6 +310,7 @@
"expiredTitle": "Request expired",
"expiredBody": "The request timed out before the patient responded.",
"invalidWallet": "That doesn't look like a valid wallet number.",
"networkOff": "Join the Temetro Network first (Settings → Signing) to share with patient apps.",
"errorTitle": "Couldn't reach the wallet",
"error": "Please check the wallet number and try again.",
"savedTitle": "Patient imported",
@@ -1351,6 +1443,8 @@
"allergies": "Allergies",
"activeMeds": "Active meds",
"openProblems": "Open problems",
"bloodType": "Blood type",
"phone": "Phone",
"none": "None",
"editRecord": "Edit record"
},
@@ -1453,6 +1547,10 @@
"primaryCare": "Primary care",
"primaryCarePlaceholder": "e.g. Dr. Lena Ortiz",
"primaryCareUnassigned": "Unassigned",
"phone": "Phone",
"phonePlaceholder": "e.g. +1 555 010 2938",
"bloodType": "Blood type",
"bloodTypeUnknown": "Unknown",
"currentVitals": "Current vitals",
"bp": "Blood pressure",
"hr": "Heart rate",
@@ -1581,6 +1679,32 @@
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids."
},
"fhirServer": {
"title": "FHIR server (share your records)",
"description": "Expose this clinic's records to external systems as a read-only FHIR R4 server, authenticated with per-clinic API keys.",
"baseUrl": "Base URL",
"baseUrlHint": "Point a FHIR client at this URL. It serves Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter and Appointment.",
"copiedUrl": "Base URL copied",
"newKey": "Create an API key",
"newKeyPlaceholder": "Key name (e.g. Research warehouse)",
"create": "Create",
"creating": "Creating…",
"createFailed": "Couldn't create the key",
"createFailedBody": "Please try again.",
"secretTitle": "Copy your API key now",
"secretHint": "This is the only time the secret is shown. Store it somewhere safe — it can't be retrieved later.",
"copiedSecret": "API key copied",
"dismissSecret": "Done",
"noKeys": "No API keys yet. Create one to let a FHIR client connect.",
"lastUsed": "Last used {{when}}",
"neverUsed": "Never used",
"revoke": "Revoke",
"confirmRevoke": "Revoke key",
"revoked": "Revoked",
"revokeFailed": "Couldn't revoke the key",
"revokeFailedBody": "Please try again.",
"cancel": "Cancel"
}
},
"empty": "Nothing here yet.",
@@ -1859,6 +1983,43 @@
"errorTitle": "Could not add member"
}
},
"network": {
"title": "Temetro Network",
"description": "The Temetro Network relay lets patients' wallet apps connect to your clinic to share and approve records — end-to-end encrypted, with your clinic identified by its signing key.",
"toggleLabel": "Join Temetro Network",
"toggleDesc": "When on, this clinic connects to the relay so you can import records from patient apps and push updates to their wallets.",
"statusConnected": "Connected",
"statusOff": "Off",
"joinedTitle": "Joined the Temetro Network",
"joinedBody": "Patient wallet apps can now connect to this clinic.",
"leftTitle": "Left the Temetro Network",
"leftBody": "This clinic is no longer connected to the relay.",
"errorTitle": "Couldn't update network access",
"error": "Please try again."
},
"location": {
"title": "Clinic location",
"description": "Your clinic's address and map coordinates. Shown to patients in the wallet app.",
"address": "Address",
"addressPlaceholder": "Street address",
"city": "City",
"country": "Country",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Coordinates are optional — used to show your clinic on a map.",
"save": "Save location",
"saving": "Saving…",
"savedTitle": "Location saved",
"savedBody": "Your clinic location has been updated.",
"invalidCoords": "Latitude and longitude must be numbers.",
"errorTitle": "Couldn't save location",
"error": "Please try again.",
"useMyLocation": "Use my current location",
"locating": "Locating…",
"geoUnsupported": "Location isn't available on this device.",
"geoError": "Couldn't get your location. Check permissions and try again.",
"geoPartial": "Filled the coordinates — please add the address details manually."
},
"signing": {
"keyTitle": "Signing key",
"active": "Active",
@@ -1969,6 +2130,16 @@
"savedBody": "Your AI configuration has been updated.",
"saveFailedTitle": "Could not save",
"saveFailedBody": "Saving your AI settings failed. Please try again."
},
"portal": {
"title": "Patient Portal",
"description": "Share your clinic's public booking portal with patients — they can view your doctors and book appointments.",
"linkLabel": "Portal link",
"linkDescription": "The public page patients open to book with your clinic.",
"open": "Open portal",
"showQr": "Show QR code",
"qrTitle": "Patient Portal QR",
"qrDescription": "Patients scan this to open your clinic's portal — or scan it in the Temetro wallet app to book."
}
},
"portal": {
@@ -1981,7 +2152,15 @@
"bookTitle": "Book an appointment",
"bookDesc": "Schedule a visit with your care team.",
"resultsTitle": "View my results",
"resultsDesc": "Check upcoming visits and whether results are ready."
"resultsDesc": "Check upcoming visits and whether results are ready.",
"walletTitle": "Link my wallet",
"walletDesc": "Use the temetro app to book and view results yourself."
},
"wallet": {
"title": "Link your wallet",
"subtitle": "Scan this code with the temetro wallet app to connect it to this clinic.",
"error": "Couldn't load the pairing code. Please ask the front desk.",
"hint": "After linking, you can book appointments and view results from your phone."
},
"field": {
"name": "Full name",
+180 -1
View File
@@ -1,4 +1,95 @@
{
"scribe": {
"recordVisit": "Enregistrer la visite",
"title": "Scribe de visite",
"subtitle": "Enregistrez ou collez une visite pour {{name}}, puis vérifiez la note rédigée avant de l'enregistrer.",
"tabs": {
"record": "Enregistrer",
"paste": "Coller la transcription"
},
"record": {
"start": "Démarrer l'enregistrement",
"stop": "Arrêter",
"ready": "Enregistrement prêt ({{duration}})",
"discard": "Supprimer"
},
"paste": {
"placeholder": "Collez la transcription de la visite ici…"
},
"visitType": {
"label": "Type de visite (facultatif)",
"placeholder": "ex. Suivi"
},
"consent": "L'audio est enregistré dans le dossier du patient. Si vous utilisez un fournisseur d'IA externe, l'enregistrement quitte la clinique pour être transcrit — Veil ne peut pas expurger la parole, seule la note rédigée est dépersonnalisée.",
"generate": "Rédiger la note",
"processing": "En cours…",
"cancel": "Annuler",
"review": {
"type": "Type de visite",
"date": "Date",
"summary": "Note",
"provider": "Praticien : {{provider}}",
"veil": "Dépersonnalisé via Veil avant {{provider}}.",
"back": "Retour",
"save": "Enregistrer dans le dossier"
},
"saved": {
"title": "Note de visite enregistrée"
},
"errors": {
"mic": "Impossible d'accéder au microphone. Vérifiez les autorisations du navigateur ou collez une transcription.",
"noRecording": "Enregistrez d'abord la visite, ou passez à l'onglet coller.",
"empty": "La transcription est vide.",
"generic": "Une erreur s'est produite. Veuillez réessayer."
}
},
"walletUpdatesList": {
"title": "Mises à jour envoyées aux portefeuilles",
"description": "Les mises à jour de dossiers que vous avez envoyées aux portefeuilles des patients, et si le patient les a approuvées.",
"none": "Vous n'avez encore envoyé aucune mise à jour de portefeuille."
},
"walletPush": {
"action": "Envoyer au portefeuille",
"title": "Envoyer la mise à jour au portefeuille",
"subtitle": "Envoyez le dossier mis à jour de {{name}} vers son application portefeuille. Le patient doit l'approuver sur son téléphone avant qu'elle ne remplace la copie sur son appareil.",
"sectionsLabel": "Ce qui a changé",
"sections": {
"demographics": "Données démographiques",
"problems": "Problèmes",
"medications": "Médicaments",
"allergies": "Allergies",
"labs": "Analyses",
"vitals": "Signes vitaux",
"visits": "Visites"
},
"noteLabel": "Note (facultatif)",
"notePlaceholder": "Une courte note sur cette mise à jour…",
"notice": "Ceci envoie le dossier actuel complet, signé avec la clé de votre clinique. Il ne remplace la copie sur l'appareil du patient qu'après son approbation.",
"cancel": "Annuler",
"send": "Envoyer la mise à jour",
"done": "Terminé",
"errors": {
"generic": "Impossible d'envoyer la mise à jour. Veuillez réessayer."
},
"status": {
"pending": {
"title": "En attente du patient",
"body": "La mise à jour a été envoyée. Elle arrivera sur le téléphone du patient maintenant, ou à la prochaine ouverture de l'application."
},
"delivered": {
"title": "Reçue sur le téléphone",
"body": "Le patient a reçu la mise à jour et doit l'approuver sur son appareil."
},
"approved": {
"title": "Approuvée par le patient",
"body": "Le patient a approuvé la mise à jour ; son dossier sur l'appareil est maintenant à jour."
},
"denied": {
"title": "Refusée par le patient",
"body": "Le patient a refusé cette mise à jour ; son dossier sur l'appareil est inchangé."
}
}
},
"common": {
"appName": "temetro",
"email": "E-mail",
@@ -219,6 +310,7 @@
"expiredTitle": "Demande expirée",
"expiredBody": "La demande a expiré avant que le patient ne réponde.",
"invalidWallet": "Cela ne ressemble pas à un numéro de portefeuille valide.",
"networkOff": "Rejoignez d'abord le Réseau Temetro (Paramètres → Signature) pour partager avec les applications des patients.",
"errorTitle": "Impossible de joindre le portefeuille",
"error": "Veuillez vérifier le numéro de portefeuille et réessayer.",
"savedTitle": "Patient importé",
@@ -1351,6 +1443,8 @@
"allergies": "Allergies",
"activeMeds": "Médicaments actifs",
"openProblems": "Problèmes ouverts",
"bloodType": "Groupe sanguin",
"phone": "Téléphone",
"none": "Aucun",
"editRecord": "Modifier le dossier"
},
@@ -1453,6 +1547,10 @@
"primaryCare": "Praticien référent",
"primaryCarePlaceholder": "ex. Dr Lena Ortiz",
"primaryCareUnassigned": "Non attribué",
"phone": "Téléphone",
"phonePlaceholder": "p. ex. +1 555 010 2938",
"bloodType": "Groupe sanguin",
"bloodTypeUnknown": "Inconnu",
"currentVitals": "Signes vitaux actuels",
"bp": "Tension artérielle",
"hr": "Fréquence cardiaque",
@@ -1581,6 +1679,32 @@
"endpointPlaceholder": "https://votre-chambre-compensation.example/claims",
"credentialsPlaceholder": "JSON : {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON avec le jeton de votre chambre de compensation et les identifiants d'expéditeur/destinataire."
},
"fhirServer": {
"title": "Serveur FHIR (partager vos dossiers)",
"description": "Exposez les dossiers de cette clinique à des systèmes externes via un serveur FHIR R4 en lecture seule, authentifié par des clés d'API propres à la clinique.",
"baseUrl": "URL de base",
"baseUrlHint": "Pointez un client FHIR vers cette URL. Elle expose Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter et Appointment.",
"copiedUrl": "URL de base copiée",
"newKey": "Créer une clé d'API",
"newKeyPlaceholder": "Nom de la clé (ex. Entrepôt de recherche)",
"create": "Créer",
"creating": "Création…",
"createFailed": "Impossible de créer la clé",
"createFailedBody": "Veuillez réessayer.",
"secretTitle": "Copiez votre clé d'API maintenant",
"secretHint": "Le secret n'est affiché qu'une seule fois. Conservez-le en lieu sûr — il ne pourra pas être récupéré ensuite.",
"copiedSecret": "Clé d'API copiée",
"dismissSecret": "Terminé",
"noKeys": "Aucune clé d'API pour l'instant. Créez-en une pour qu'un client FHIR puisse se connecter.",
"lastUsed": "Dernière utilisation {{when}}",
"neverUsed": "Jamais utilisée",
"revoke": "Révoquer",
"confirmRevoke": "Révoquer la clé",
"revoked": "Révoquée",
"revokeFailed": "Impossible de révoquer la clé",
"revokeFailedBody": "Veuillez réessayer.",
"cancel": "Annuler"
}
},
"empty": "Rien ici pour le moment.",
@@ -1859,6 +1983,43 @@
"errorTitle": "Impossible d'ajouter le membre"
}
},
"network": {
"title": "Réseau Temetro",
"description": "Le relais Réseau Temetro permet aux applications portefeuille des patients de se connecter à votre clinique pour partager et approuver des dossiers — chiffrés de bout en bout, votre clinique étant identifiée par sa clé de signature.",
"toggleLabel": "Rejoindre le Réseau Temetro",
"toggleDesc": "Une fois activé, cette clinique se connecte au relais pour importer des dossiers depuis les applications des patients et envoyer des mises à jour vers leurs portefeuilles.",
"statusConnected": "Connecté",
"statusOff": "Désactivé",
"joinedTitle": "Réseau Temetro rejoint",
"joinedBody": "Les applications portefeuille des patients peuvent désormais se connecter à cette clinique.",
"leftTitle": "Réseau Temetro quitté",
"leftBody": "Cette clinique n'est plus connectée au relais.",
"errorTitle": "Impossible de mettre à jour l'accès au réseau",
"error": "Veuillez réessayer."
},
"location": {
"title": "Emplacement de la clinique",
"description": "L'adresse et les coordonnées cartographiques de votre clinique. Affichées aux patients dans l'application wallet.",
"address": "Adresse",
"addressPlaceholder": "Adresse postale",
"city": "Ville",
"country": "Pays",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Les coordonnées sont facultatives — utilisées pour afficher votre clinique sur une carte.",
"save": "Enregistrer l'emplacement",
"saving": "Enregistrement…",
"savedTitle": "Emplacement enregistré",
"savedBody": "L'emplacement de votre clinique a été mis à jour.",
"invalidCoords": "La latitude et la longitude doivent être des nombres.",
"errorTitle": "Impossible d'enregistrer l'emplacement",
"error": "Veuillez réessayer.",
"useMyLocation": "Utiliser ma position actuelle",
"locating": "Localisation…",
"geoUnsupported": "La localisation n'est pas disponible sur cet appareil.",
"geoError": "Impossible d'obtenir votre position. Vérifiez les autorisations et réessayez.",
"geoPartial": "Coordonnées renseignées — veuillez ajouter l'adresse manuellement."
},
"signing": {
"keyTitle": "Clé de signature",
"active": "Active",
@@ -1969,6 +2130,16 @@
"savedBody": "Votre configuration d'IA a été mise à jour.",
"saveFailedTitle": "Impossible d'enregistrer",
"saveFailedBody": "L'enregistrement de vos paramètres d'IA a échoué. Veuillez réessayer."
},
"portal": {
"title": "Portail patient",
"description": "Partagez le portail de réservation public de votre clinique avec les patients — ils peuvent voir vos médecins et prendre rendez-vous.",
"linkLabel": "Lien du portail",
"linkDescription": "La page publique que les patients ouvrent pour réserver avec votre clinique.",
"open": "Ouvrir le portail",
"showQr": "Afficher le QR code",
"qrTitle": "QR du portail patient",
"qrDescription": "Les patients le scannent pour ouvrir le portail de votre clinique — ou le scannent dans l'application Temetro pour réserver."
}
},
"portal": {
@@ -1981,7 +2152,15 @@
"bookTitle": "Prendre un rendez-vous",
"bookDesc": "Planifiez une visite avec votre équipe soignante.",
"resultsTitle": "Voir mes résultats",
"resultsDesc": "Consultez les prochaines visites et si les résultats sont prêts."
"resultsDesc": "Consultez les prochaines visites et si les résultats sont prêts.",
"walletTitle": "Lier mon portefeuille",
"walletDesc": "Utilisez l'application temetro pour réserver et voir vos résultats vous-même."
},
"wallet": {
"title": "Lier votre portefeuille",
"subtitle": "Scannez ce code avec l'application portefeuille temetro pour la connecter à cette clinique.",
"error": "Impossible de charger le code d'association. Veuillez demander à l'accueil.",
"hint": "Une fois lié, vous pourrez prendre rendez-vous et consulter vos résultats depuis votre téléphone."
},
"field": {
"name": "Nom complet",
+180 -1
View File
@@ -1,4 +1,95 @@
{
"scribe": {
"recordVisit": "Duub booqasho",
"title": "Qoraaga booqashada",
"subtitle": "Duub ama ku dhaji booqasho {{name}}, ka dibna dib u eeg qoraalka la sameeyay ka hor inta aadan keydin.",
"tabs": {
"record": "Duub",
"paste": "Ku dhaji qoraalka"
},
"record": {
"start": "Bilow duubista",
"stop": "Jooji",
"ready": "Duubista diyaar ({{duration}})",
"discard": "Tirtir"
},
"paste": {
"placeholder": "Halkan ku dhaji qoraalka booqashada…"
},
"visitType": {
"label": "Nooca booqashada (ikhtiyaari)",
"placeholder": "tusaale: Dib-u-eegis"
},
"consent": "Codka waxaa lagu keydiyaa faylka bukaanka. Marka aad isticmaasho bixiye AI dibadeed, duubistu waxay ka baxdaa rugta si loo qoro — Veil ma tirtiri karo hadalka, sidaas darteed kaliya qoraalka la sameeyay ayaa la qariyaa.",
"generate": "Samee qoraalka",
"processing": "Waa la shaqaynayaa…",
"cancel": "Jooji",
"review": {
"type": "Nooca booqashada",
"date": "Taariikhda",
"summary": "Qoraalka",
"provider": "Bixiye: {{provider}}",
"veil": "Waxaa lagu qariyay Veil ka hor {{provider}}.",
"back": "Dib u noqo",
"save": "Ku keydi diiwaanka"
},
"saved": {
"title": "Qoraalka booqashada waa la keydiyay"
},
"errors": {
"mic": "Lama gaari karin makarafoonka. Hubi oggolaanshaha browserka ama ku dhaji qoraal.",
"noRecording": "Marka hore duub booqashada, ama u wareeg tabka ku-dhajinta.",
"empty": "Qoraalku waa madhan yahay.",
"generic": "Wax baa qaldamay. Fadlan isku day mar kale."
}
},
"walletUpdatesList": {
"title": "Cusboonaysiimaha loo diray walletyada",
"description": "Cusboonaysiimaha diiwaanka ee aad u dirtay walletyada bukaannada, iyo haddii bukaanku ansixiyay.",
"none": "Weli ma aadan dirin wax cusboonaysiin walletka ah."
},
"walletPush": {
"action": "U dir walletka",
"title": "U dir cusboonaysiinta walletka",
"subtitle": "U dir diiwaanka la cusboonaysiiyay ee {{name}} abka walletkooda. Waa inay taleefankooda ku ansixiyaan ka hor inta uusan bedelin nuqulka qalabkooda ku jira.",
"sectionsLabel": "Waxa isbedelay",
"sections": {
"demographics": "Xogta shakhsi",
"problems": "Dhibaatooyinka",
"medications": "Daawooyinka",
"allergies": "Xasaasiyadda",
"labs": "Baaritaannada",
"vitals": "Calaamadaha muhiimka ah",
"visits": "Booqashooyinka"
},
"noteLabel": "Qoraal (ikhtiyaari)",
"notePlaceholder": "Qoraal gaaban oo ku saabsan cusboonaysiintan…",
"notice": "Tan waxay dirtaa diiwaanka hadda oo dhan, oo lagu saxeexay furaha rugtaada. Waxay bedeshaa nuqulka qalabka bukaanka oo keliya ka dib marka ay ansixiyaan.",
"cancel": "Jooji",
"send": "Dir cusboonaysiinta",
"done": "Dhammaystiran",
"errors": {
"generic": "Lama diri karin cusboonaysiinta. Fadlan isku day mar kale."
},
"status": {
"pending": {
"title": "Sugaya bukaanka",
"body": "Cusboonaysiintii waa la diray. Waxay ku iman doontaa taleefanka bukaanka hadda, ama marka xigta ee ay furaan abka."
},
"delivered": {
"title": "La gaarsiiyay taleefanka",
"body": "Bukaanku wuu helay cusboonaysiinta wuxuuna u baahan yahay inuu ku ansixiyo qalabkiisa."
},
"approved": {
"title": "Bukaanku wuu ansixiyay",
"body": "Bukaanku wuu ansixiyay cusboonaysiinta, diiwaanka qalabkooduna hadda waa cusub yahay."
},
"denied": {
"title": "Bukaanku wuu diiday",
"body": "Bukaanku wuu diiday cusboonaysiintan, sidaas darteed diiwaanka qalabkoodu isma bedelin."
}
}
},
"common": {
"appName": "temetro",
"email": "Iimayl",
@@ -219,6 +310,7 @@
"expiredTitle": "Codsiga waa dhacay",
"expiredBody": "Codsiga wuu dhacay ka hor inta uusan bukaanku ka jawaabin.",
"invalidWallet": "Taasi uma muuqato lambar wallet oo sax ah.",
"networkOff": "Marka hore ku biir Shabakadda Temetro (Dejinta → Saxiixa) si aad ula wadaagto abaabulka bukaannada.",
"errorTitle": "Wallet-ka lama gaari karin",
"error": "Fadlan hubi lambarka wallet-ka oo isku day mar kale.",
"savedTitle": "Bukaanka waa la soo dejiyay",
@@ -1351,6 +1443,8 @@
"allergies": "Xasaasiyadaha",
"activeMeds": "Daawooyin firfircoon",
"openProblems": "Dhibaatooyin furan",
"bloodType": "Nooca dhiigga",
"phone": "Taleefanka",
"none": "Midna",
"editRecord": "Wax ka beddel diiwaanka"
},
@@ -1453,6 +1547,10 @@
"primaryCare": "Daryeelka aasaasiga ah",
"primaryCarePlaceholder": "tusaale Dr. Lena Ortiz",
"primaryCareUnassigned": "Aan la qoondayn",
"phone": "Taleefanka",
"phonePlaceholder": "tusaale: +1 555 010 2938",
"bloodType": "Nooca dhiigga",
"bloodTypeUnknown": "Aan la garanayn",
"currentVitals": "Calaamadaha muhiimka ah ee hadda",
"bp": "Cadaadiska dhiigga",
"hr": "Garaaca wadnaha",
@@ -1581,6 +1679,32 @@
"endpointPlaceholder": "https://xarunta-xisaabintaada.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON leh token-ka xarunta xisaabinta iyo aqoonsiyada diraha/qaataha."
},
"fhirServer": {
"title": "Serfarka FHIR (la wadaag diiwaannadaada)",
"description": "U soo bandhig diiwaannada rugtan nidaamyada dibadda ah sida serfar FHIR R4 akhris-oo-keliya, oo lagu ansixiyo furayaal API oo rug walba gaar u ah.",
"baseUrl": "URL-ka aasaasiga ah",
"baseUrlHint": "U tilmaam macmiil FHIR URL-kan. Wuxuu adeegaa Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter iyo Appointment.",
"copiedUrl": "URL-ka aasaasiga waa la koobiyeeyay",
"newKey": "Samee fure API",
"newKeyPlaceholder": "Magaca furaha (tusaale, Bakhaarka cilmi-baarista)",
"create": "Samee",
"creating": "Waa la samaynayaa…",
"createFailed": "Furaha lama abuuri karin",
"createFailedBody": "Fadlan mar kale isku day.",
"secretTitle": "Hadda koobiyee furahaaga API",
"secretHint": "Tanu waa markii kaliya ee sirta la muujiyo. Meel ammaan ah ku kaydi — lama soo ceshan karo dabadeed.",
"copiedSecret": "Furaha API waa la koobiyeeyay",
"dismissSecret": "Diyaar",
"noKeys": "Weli ma jiraan furayaal API ah. Mid samee si macmiil FHIR u xidho.",
"lastUsed": "Markii ugu dambeysay la isticmaalay {{when}}",
"neverUsed": "Weligeed lama isticmaalin",
"revoke": "Baabbi'i",
"confirmRevoke": "Baabbi'i furaha",
"revoked": "La baabbi'iyay",
"revokeFailed": "Furaha lama baabbi'in karin",
"revokeFailedBody": "Fadlan mar kale isku day.",
"cancel": "Jooji"
}
},
"empty": "Weli halkan waxba ma jiraan.",
@@ -1859,6 +1983,43 @@
"errorTitle": "Xubinta lama ku dari karin"
}
},
"network": {
"title": "Shabakadda Temetro",
"description": "Gudbiyaha Shabakadda Temetro wuxuu u oggolaanayaa abaabulka boorsada bukaannada inay ku xidhaan rugtaada si ay u wadaagaan oo u ansixiyaan diiwaannada — sir gaba-gabo ah, iyadoo rugtaada lagu aqoonsado furaheeda saxiixa.",
"toggleLabel": "Ku biir Shabakadda Temetro",
"toggleDesc": "Marka la shido, rugtan waxay ku xidhmaysaa gudbiyaha si aad u soo dejiso diiwaannada abaabulka bukaannada oo aad cusboonaysiin ugu dirto boorsadooda.",
"statusConnected": "La xidhiidhay",
"statusOff": "Daminaan",
"joinedTitle": "Waxaad ku biirtay Shabakadda Temetro",
"joinedBody": "Abaabulka boorsada bukaannada hadda way ku xidhmi karaan rugtan.",
"leftTitle": "Waxaad ka baxday Shabakadda Temetro",
"leftBody": "Rugtan hadda kuma xidhna gudbiyaha.",
"errorTitle": "Lama cusboonaysiin karin gelitaanka shabakadda",
"error": "Fadlan isku day mar kale."
},
"location": {
"title": "Goobta rugta caafimaadka",
"description": "Cinwaanka iyo isutagga khariidadda rugtaada. Waxaa loo tusayaa bukaannada app-ka wallet-ka.",
"address": "Cinwaanka",
"addressPlaceholder": "Cinwaanka waddada",
"city": "Magaalada",
"country": "Dalka",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Isutaggu waa ikhtiyaari — waxaa loo isticmaalaa in rugtaada lagu tuso khariidad.",
"save": "Kaydi goobta",
"saving": "Waa la kaydinayaa…",
"savedTitle": "Goobta waa la kaydiyay",
"savedBody": "Goobta rugtaada waa la cusboonaysiiyay.",
"invalidCoords": "Latitude iyo longitude waa inay noqdaan tirooyin.",
"errorTitle": "Lama kaydin karin goobta",
"error": "Fadlan mar kale isku day.",
"useMyLocation": "Isticmaal goobtayda hadda",
"locating": "Waa la helayaa goobta…",
"geoUnsupported": "Goobta laguma heli karo qalabkan.",
"geoError": "Lama heli karin goobtaada. Hubi oggolaanshaha oo isku day mar kale.",
"geoPartial": "Isudhigyada waa la buuxiyay — fadlan gacanta ku gali faahfaahinta ciwaanka."
},
"signing": {
"keyTitle": "Furaha saxiixa",
"active": "Firfircoon",
@@ -1969,6 +2130,16 @@
"savedBody": "Qaabaynta AI-gaaga waa la cusbooneysiiyay.",
"saveFailedTitle": "Lama kaydin karin",
"saveFailedBody": "Kaydinta dejinta AI waa fashilantay. Fadlan isku day mar kale."
},
"portal": {
"title": "Boggaga Bukaanka",
"description": "La wadaag bukaannada boggaga ballanqaadka guud ee rugtaada — waxay arki karaan dhakhtarradaada oo ballan qaadan karaan.",
"linkLabel": "Linkiga boggaga",
"linkDescription": "Bogga guud ee bukaannadu furaan si ay ballan ugu qaataan rugtaada.",
"open": "Fur boggaga",
"showQr": "Muuji koodhka QR",
"qrTitle": "Koodhka QR ee Boggaga Bukaanka",
"qrDescription": "Bukaannadu waxay tan sawiraan si ay u furaan boggaga rugtaada — ama ku sawir abka Temetro wallet si aad ballan u qaadato."
}
},
"portal": {
@@ -1981,7 +2152,15 @@
"bookTitle": "Ballan qabso",
"bookDesc": "Qorshee booqasho kooxdaada daryeelka.",
"resultsTitle": "Eeg natiijooyinkayga",
"resultsDesc": "Hubi booqashooyinka soo socda iyo haddii natiijooyinku diyaar yihiin."
"resultsDesc": "Hubi booqashooyinka soo socda iyo haddii natiijooyinku diyaar yihiin.",
"walletTitle": " Isku xir walletkayga",
"walletDesc": "Isticmaal abka temetro si aad adigu u ballansato oo aad u aragto natiijooyinka."
},
"wallet": {
"title": "Isku xir walletkaaga",
"subtitle": "Ku sawir koodhkan abka wallet-ka temetro si aad ugu xidho rugtan caafimaad.",
"error": "Lama soo rari karin koodhka isku xirka. Fadlan weydii miiska hore.",
"hint": "Kadib xirka, waxaad ballan ka samaysan kartaa oo natiijooyinka ka arki kartaa taleefankaaga."
},
"field": {
"name": "Magaca oo dhan",
+31
View File
@@ -64,6 +64,37 @@ export function submitInsuranceClaim(
});
}
// --- FHIR server API keys (owner/admin only) --------------------------------
export type FhirApiKey = {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
revoked: boolean;
};
// A freshly created key includes the one-time plaintext secret; it is never
// returned again.
export type CreatedFhirApiKey = FhirApiKey & { secret: string };
export function listFhirKeys(): Promise<FhirApiKey[]> {
return apiFetch<FhirApiKey[]>("/api/integrations/fhir-server/keys");
}
export function createFhirKey(name: string): Promise<CreatedFhirApiKey> {
return apiFetch<CreatedFhirApiKey>("/api/integrations/fhir-server/keys", {
method: "POST",
body: JSON.stringify({ name }),
});
}
export function revokeFhirKey(id: string): Promise<{ revoked: boolean }> {
return apiFetch(`/api/integrations/fhir-server/keys/${id}`, {
method: "DELETE",
});
}
// Convenience hook-style fetch reused by the on-page sections: returns the
// config for one type (or null while loading/absent).
export async function getIntegration(
+2
View File
@@ -66,6 +66,8 @@ export type Patient = {
primaryProviderId?: string | null; // user id of the responsible clinician
status: "active" | "inpatient" | "discharged";
initials: string; // for AvatarFallback
phone?: string; // contact number (demographic; visible to reception)
bloodType?: string; // e.g. "O+"; clinical — redacted for reception
allergies: Allergy[];
alerts: string[];
medications: Medication[];
+24
View File
@@ -65,6 +65,30 @@ export function getPortalClinic(clinic: string): Promise<PortalClinic> {
return portalFetch<PortalClinic>(`/${encodeURIComponent(clinic)}`);
}
// The relay-based pairing descriptor the wallet app scans: the clinic's signing
// public key (relay routing id) + the relay URL. Non-secret values.
export type PortalLink = {
clinicId: string;
relay: string;
slug: string;
name: string;
};
export function getPortalLink(clinic: string): Promise<PortalLink> {
return portalFetch<PortalLink>(`/${encodeURIComponent(clinic)}/link`);
}
// Build the `temetro-portal:` URI the wallet app scans to reach this clinic over
// the Temetro Network relay (no localhost API URL — works from a real phone).
export function portalPairingUri(link: PortalLink): string {
const params = new URLSearchParams({
relay: link.relay,
clinic: link.clinicId,
slug: link.slug,
});
return `temetro-portal:?${params.toString()}`;
}
export type PortalNewPatient = {
name: string;
sex?: string;
+51
View File
@@ -0,0 +1,51 @@
// Client for the ambient AI visit-scribe API. The dialog records (or accepts a
// pasted transcript of) a clinician↔patient visit, transcribes it, drafts a
// structured encounter note, and — after the clinician reviews it — appends the
// note to the patient record (the same write-approval gate as the chat agent).
import { apiFetch } from "@/lib/api-client";
import type { Encounter, Patient } from "@/lib/patients";
export type ScribeVeil = {
active: boolean;
level: string;
classes: string[];
provider: string;
};
// Turn a stored audio attachment into a raw transcript (server streams it to the
// user's OpenAI/Gemini speech provider). Audio does NOT pass through Veil.
export function transcribeRecording(
attachmentId: string,
): Promise<{ transcript: string }> {
return apiFetch<{ transcript: string }>("/api/scribe/transcribe", {
method: "POST",
body: JSON.stringify({ attachmentId }),
});
}
// Draft an encounter note from a transcript. The transcript + patient context
// are de-identified through Veil before any external call; the draft is never
// saved automatically.
export function draftNote(input: {
fileNumber: string;
transcript: string;
visitType?: string;
date?: string;
}): Promise<{ draft: Encounter; veil: ScribeVeil }> {
return apiFetch<{ draft: Encounter; veil: ScribeVeil }>("/api/scribe/draft", {
method: "POST",
body: JSON.stringify(input),
});
}
// The approval step: append the reviewed note to the patient record.
export function saveNote(
fileNumber: string,
encounter: Encounter,
): Promise<Patient> {
return apiFetch<Patient>("/api/scribe/save", {
method: "POST",
body: JSON.stringify({ fileNumber, encounter }),
});
}
+18
View File
@@ -38,3 +38,21 @@ export async function rotateSigningKey(): Promise<SigningKey> {
export async function listSignedRecords(): Promise<SharedRecord[]> {
return apiFetch<SharedRecord[]>("/api/signing/records");
}
// Whether this clinic has joined the Temetro Network relay (patient-wallet
// sharing rides it). Readable by any clinician.
export async function getNetworkEnabled(): Promise<boolean> {
const { enabled } = await apiFetch<{ enabled: boolean }>(
"/api/signing/network",
);
return enabled;
}
// Join or leave the Temetro Network (owner/admin only). Returns the new state.
export async function setNetworkEnabled(enabled: boolean): Promise<boolean> {
const res = await apiFetch<{ enabled: boolean }>("/api/signing/network", {
method: "PUT",
body: JSON.stringify({ enabled }),
});
return res.enabled;
}
+47
View File
@@ -0,0 +1,47 @@
// Client for the clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient, they can push the updated record to the patient's app;
// it stays pending until the patient approves it on their phone.
import { apiFetch } from "@/lib/api-client";
export type WalletUpdateStatus = "pending" | "delivered" | "approved" | "denied";
export type WalletUpdate = {
id: string;
fileNumber: string;
walletNumber: string;
status: WalletUpdateStatus;
changes: string[];
createdAt: string;
deliveredAt: string | null;
resolvedAt: string | null;
};
// Resolve the wallet a patient is linked to. Rejects (404) when not wallet-backed.
export function getWalletLink(
fileNumber: string,
): Promise<{ walletNumber: string }> {
return apiFetch<{ walletNumber: string }>(
`/api/patients/wallet/link/${encodeURIComponent(fileNumber)}`,
);
}
export function pushWalletUpdate(input: {
fileNumber: string;
changes: string[];
}): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>("/api/patients/wallet/push", {
method: "POST",
body: JSON.stringify(input),
});
}
export function listWalletUpdates(): Promise<WalletUpdate[]> {
return apiFetch<WalletUpdate[]>("/api/patients/wallet/updates");
}
export function getWalletUpdate(id: string): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>(
`/api/patients/wallet/updates/${encodeURIComponent(id)}`,
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.3.0",
"version": "0.11.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.3.0",
"version": "0.11.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"