mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b33561d7e1 | |||
| 947623a691 | |||
| dd64d689c8 | |||
| 1c5e71eb39 | |||
| fb9fa299c9 | |||
| ab17978b5c | |||
| 36461a5498 | |||
| 01dbc07e92 | |||
| 233ce9f854 | |||
| 99aa534e88 |
+119
@@ -7,6 +7,125 @@ 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
|
||||
|
||||
@@ -36,10 +36,15 @@ FRONTEND_PORT=3000
|
||||
# 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); RELAY_TOKEN is a shared secret this
|
||||
# backend presents on the relay's /hub namespace — set the SAME value here and
|
||||
# on the relay. Generate one with: openssl rand -base64 32
|
||||
RELAY_URL=http://localhost:8080
|
||||
# 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
|
||||
|
||||
+11
-6
@@ -61,12 +61,17 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
|
||||
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`). This backend connects to it
|
||||
as a `/hub` client in **`src/services/relay-client.ts`**; `emitToWallet` (realtime.ts) delegates to
|
||||
its `sendToWallet`, and device responses (`wallet:share-response` / `wallet:update-response` /
|
||||
`wallet:revoke`) + `wallet:online` replay are handled there, calling the same `wallet-share` /
|
||||
`wallet-updates` services the old `/wallet` namespace did. Configure with `RELAY_URL` +
|
||||
`RELAY_TOKEN`.
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -225,6 +225,34 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.7.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.",
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -20,6 +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";
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
+12
-8
@@ -31,9 +31,15 @@ const schema = z.object({
|
||||
// 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); RELAY_TOKEN is the shared secret this backend
|
||||
// presents on the relay's /hub namespace (must match the relay's RELAY_TOKEN).
|
||||
RELAY_URL: z.string().min(1).default("http://localhost:8080"),
|
||||
// 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
|
||||
@@ -87,11 +93,9 @@ if (env.NODE_ENV === "production") {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!env.RELAY_TOKEN) {
|
||||
console.warn(
|
||||
"⚠️ RELAY_TOKEN is unset in production — the Temetro Network /hub connection is unauthenticated. Set a shared secret: openssl rand -base64 32",
|
||||
);
|
||||
}
|
||||
// RELAY_TOKEN is optional now: clinics authenticate to the relay with their
|
||||
// own Ed25519 signing key, so an unset token is the normal "open relay" case —
|
||||
// no warning needed.
|
||||
}
|
||||
|
||||
export const isProd = env.NODE_ENV === "production";
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -90,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);
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -46,11 +46,12 @@ export function emitToConversation(
|
||||
// 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 {
|
||||
sendToWallet(walletNumber, event, data);
|
||||
sendToWallet(orgId, walletNumber, event, data);
|
||||
}
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -17,8 +17,10 @@ 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";
|
||||
|
||||
@@ -26,6 +28,18 @@ 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). 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
|
||||
@@ -71,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!,
|
||||
@@ -78,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,
|
||||
@@ -97,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!,
|
||||
@@ -109,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,
|
||||
@@ -176,6 +198,7 @@ patientsWalletRouter.post(
|
||||
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!,
|
||||
@@ -184,7 +207,7 @@ patientsWalletRouter.post(
|
||||
input.changes,
|
||||
);
|
||||
const event = await walletUpdates.toEvent(row);
|
||||
emitToWallet(row.walletNumber, "wallet:update-request", event);
|
||||
emitToWallet(req.organizationId!, row.walletNumber, "wallet:update-request", event);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
requireAuth,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { connectOrg, disconnectOrg } from "../services/relay-client.js";
|
||||
import * as signing from "../services/signing.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
|
||||
@@ -48,6 +50,52 @@ signingRouter.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Whether this clinic has joined the Temetro Network relay. Readable by any
|
||||
// clinician (the panel shows the toggle state + connection status).
|
||||
signingRouter.get(
|
||||
"/network",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json({ enabled: await signing.getNetworkEnabled(req.organizationId!) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Join / leave the Temetro Network — owner/admin only (same gate as key
|
||||
// rotation). Enabling opens this clinic's relay hub connection; disabling tears
|
||||
// it down.
|
||||
const networkSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
signingRouter.put(
|
||||
"/network",
|
||||
requirePermission({ organization: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { enabled } = networkSchema.parse(req.body);
|
||||
await signing.setNetworkEnabled(req.organizationId!, enabled);
|
||||
if (enabled) {
|
||||
await connectOrg(req.organizationId!);
|
||||
} else {
|
||||
disconnectOrg(req.organizationId!);
|
||||
}
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: enabled
|
||||
? "Joined the Temetro Network"
|
||||
: "Left the Temetro Network",
|
||||
entityType: "settings",
|
||||
});
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Recent records shared from patient wallets — feeds the panel's shared-records
|
||||
// list.
|
||||
signingRouter.get(
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -4,58 +4,142 @@
|
||||
//
|
||||
// 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
|
||||
// privileged `/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.
|
||||
// `/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";
|
||||
|
||||
let hub: Socket | null = null;
|
||||
// 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 relay
|
||||
// (which forwards it to the room keyed by wallet number). Mirrors the old
|
||||
// in-process `emitToWallet`. A no-op if the relay is not connected yet — the
|
||||
// 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 {
|
||||
hub?.emit("wallet:send", { walletNumber, event, data });
|
||||
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
|
||||
}
|
||||
|
||||
export function initRelayClient(): void {
|
||||
hub = connect(`${env.RELAY_URL}/hub`, {
|
||||
auth: { token: env.RELAY_TOKEN },
|
||||
// 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);
|
||||
}
|
||||
|
||||
hub.on("connect", () => {
|
||||
console.log(`Connected to Temetro Network relay at ${env.RELAY_URL}`);
|
||||
// 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}): ${err.message}`);
|
||||
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 (the relay forwards each back to it). Mirrors the replay the
|
||||
// old /wallet namespace did on connect.
|
||||
// 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(walletNumber);
|
||||
const rows = await walletUpdates.pendingUpdatesForWallet(orgId, walletNumber);
|
||||
for (const row of rows) {
|
||||
sendToWallet(walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
||||
sendToWallet(orgId, walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
||||
await walletUpdates.markDelivered(row.id);
|
||||
}
|
||||
} catch {
|
||||
@@ -119,6 +203,34 @@ export function initRelayClient(): void {
|
||||
},
|
||||
);
|
||||
|
||||
// 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",
|
||||
|
||||
@@ -74,6 +74,40 @@ export async function rotateKey(orgId: string): Promise<SigningKeyView> {
|
||||
return mintKey(orgId, true);
|
||||
}
|
||||
|
||||
// Whether this clinic has joined the Temetro Network relay. Defaults to `false`
|
||||
// when the clinic has no signing key yet (it hasn't opted in).
|
||||
export async function getNetworkEnabled(orgId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ networkEnabled: clinicSigningKeys.networkEnabled })
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return row?.networkEnabled ?? false;
|
||||
}
|
||||
|
||||
// Org ids of every clinic currently on the network — used at startup to open a
|
||||
// relay hub connection for each.
|
||||
export async function networkEnabledOrgs(): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ organizationId: clinicSigningKeys.organizationId })
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.networkEnabled, true));
|
||||
return rows.map((r) => r.organizationId);
|
||||
}
|
||||
|
||||
// Join or leave the Temetro Network. Ensures the clinic has a signing key first
|
||||
// (the relay authenticates with it), then flips the flag. Returns the new state.
|
||||
export async function setNetworkEnabled(
|
||||
orgId: string,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
await getOrCreateKey(orgId);
|
||||
await db
|
||||
.update(clinicSigningKeys)
|
||||
.set({ networkEnabled: enabled })
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// Sign a message with the clinic's signing key (creating one if needed). Returns
|
||||
// the signature + public key so a verifier can check provenance.
|
||||
export async function signWithClinicKey(
|
||||
|
||||
@@ -137,6 +137,22 @@ export async function listShareRequests(
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
// Ids of this clinic's still-pending share/pairing requests. Used to re-register
|
||||
// them with the relay when the clinic's hub (re)connects (the relay keeps
|
||||
// routing state in memory, so it's lost on a relay restart / redeploy).
|
||||
export async function pendingRequestIds(orgId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ id: walletShareRequests.id })
|
||||
.from(walletShareRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
eq(walletShareRequests.status, "pending"),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// Apply a response relayed back from the patient's device. On approval we
|
||||
// decrypt the sealed bundle with the request's ephemeral private key and verify
|
||||
// the wallet's Ed25519 signature over it (provenance: it really came from that
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
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";
|
||||
|
||||
@@ -68,6 +71,22 @@ 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)
|
||||
@@ -100,9 +119,23 @@ export async function createRecordUpdate(
|
||||
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`.
|
||||
const bundle = utf8ToBytes(JSON.stringify({ patient, changes }));
|
||||
// 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);
|
||||
@@ -144,7 +177,11 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
|
||||
|
||||
// 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
|
||||
@@ -152,6 +189,7 @@ export async function pendingUpdatesForWallet(
|
||||
.from(walletRecordUpdates)
|
||||
.where(
|
||||
and(
|
||||
eq(walletRecordUpdates.organizationId, orgId),
|
||||
eq(walletRecordUpdates.walletNumber, walletNumber),
|
||||
isNull(walletRecordUpdates.resolvedAt),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -359,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>
|
||||
|
||||
|
||||
@@ -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,16 +6,21 @@ 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";
|
||||
@@ -38,6 +43,8 @@ export function SigningPanel() {
|
||||
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;
|
||||
@@ -45,12 +52,14 @@ export function SigningPanel() {
|
||||
getSigningKey(),
|
||||
listSignedRecords().catch(() => []),
|
||||
listWalletUpdates().catch(() => []),
|
||||
getNetworkEnabled().catch(() => false),
|
||||
])
|
||||
.then(([k, r, u]) => {
|
||||
.then(([k, r, u, n]) => {
|
||||
if (!active) return;
|
||||
setKey(k);
|
||||
setRecords(r);
|
||||
setUpdates(u);
|
||||
setNetworkOn(n);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -83,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${
|
||||
@@ -137,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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,7 @@
|
||||
"expiredTitle": "انتهت صلاحية الطلب",
|
||||
"expiredBody": "انتهت مهلة الطلب قبل أن يستجيب المريض.",
|
||||
"invalidWallet": "لا يبدو هذا رقم محفظة صالحًا.",
|
||||
"networkOff": "انضم أولاً إلى شبكة Temetro (الإعدادات ← التوقيع) للمشاركة مع تطبيقات المرضى.",
|
||||
"errorTitle": "تعذّر الوصول إلى المحفظة",
|
||||
"error": "يرجى التحقّق من رقم المحفظة والمحاولة مرة أخرى.",
|
||||
"savedTitle": "تم استيراد المريض",
|
||||
@@ -1462,6 +1463,8 @@
|
||||
"allergies": "الحساسيات",
|
||||
"activeMeds": "الأدوية النشطة",
|
||||
"openProblems": "المشكلات المفتوحة",
|
||||
"bloodType": "فصيلة الدم",
|
||||
"phone": "الهاتف",
|
||||
"none": "لا شيء",
|
||||
"editRecord": "تعديل السجل"
|
||||
},
|
||||
@@ -1564,6 +1567,10 @@
|
||||
"primaryCare": "الرعاية الأساسية",
|
||||
"primaryCarePlaceholder": "مثال: د. لينا أورتيز",
|
||||
"primaryCareUnassigned": "غير مُعيّن",
|
||||
"phone": "الهاتف",
|
||||
"phonePlaceholder": "مثال: +1 555 010 2938",
|
||||
"bloodType": "فصيلة الدم",
|
||||
"bloodTypeUnknown": "غير معروف",
|
||||
"currentVitals": "العلامات الحيوية الحالية",
|
||||
"bp": "ضغط الدم",
|
||||
"hr": "معدل ضربات القلب",
|
||||
@@ -1996,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": "نشط",
|
||||
@@ -2106,6 +2150,16 @@
|
||||
"savedBody": "تم تحديث تهيئة الذكاء الاصطناعي الخاصة بك.",
|
||||
"saveFailedTitle": "تعذّر الحفظ",
|
||||
"saveFailedBody": "فشل حفظ إعدادات الذكاء الاصطناعي. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"portal": {
|
||||
"title": "بوابة المريض",
|
||||
"description": "شارك بوابة الحجز العامة لعيادتك مع المرضى — يمكنهم رؤية أطبائك وحجز المواعيد.",
|
||||
"linkLabel": "رابط البوابة",
|
||||
"linkDescription": "الصفحة العامة التي يفتحها المرضى للحجز في عيادتك.",
|
||||
"open": "فتح البوابة",
|
||||
"showQr": "عرض رمز QR",
|
||||
"qrTitle": "رمز QR لبوابة المريض",
|
||||
"qrDescription": "يمسح المرضى هذا لفتح بوابة عيادتك — أو يمسحونه في تطبيق محفظة Temetro للحجز."
|
||||
}
|
||||
},
|
||||
"portal": {
|
||||
@@ -2118,7 +2172,15 @@
|
||||
"bookTitle": "حجز موعد",
|
||||
"bookDesc": "جدول زيارة مع فريق الرعاية الخاص بك.",
|
||||
"resultsTitle": "عرض نتائجي",
|
||||
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة."
|
||||
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة.",
|
||||
"walletTitle": "ربط محفظتي",
|
||||
"walletDesc": "استخدم تطبيق temetro لحجز المواعيد وعرض النتائج بنفسك."
|
||||
},
|
||||
"wallet": {
|
||||
"title": "اربط محفظتك",
|
||||
"subtitle": "امسح هذا الرمز بتطبيق محفظة temetro لربطه بهذه العيادة.",
|
||||
"error": "تعذّر تحميل رمز الربط. الرجاء سؤال مكتب الاستقبال.",
|
||||
"hint": "بعد الربط، يمكنك حجز المواعيد وعرض النتائج من هاتفك."
|
||||
},
|
||||
"field": {
|
||||
"name": "الاسم الكامل",
|
||||
|
||||
@@ -310,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",
|
||||
@@ -1442,6 +1443,8 @@
|
||||
"allergies": "Allergien",
|
||||
"activeMeds": "Aktive Medikamente",
|
||||
"openProblems": "Offene Probleme",
|
||||
"bloodType": "Blutgruppe",
|
||||
"phone": "Telefon",
|
||||
"none": "Keine",
|
||||
"editRecord": "Datensatz bearbeiten"
|
||||
},
|
||||
@@ -1544,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",
|
||||
@@ -1976,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",
|
||||
@@ -2086,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": {
|
||||
@@ -2098,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",
|
||||
|
||||
@@ -310,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",
|
||||
@@ -1442,6 +1443,8 @@
|
||||
"allergies": "Allergies",
|
||||
"activeMeds": "Active meds",
|
||||
"openProblems": "Open problems",
|
||||
"bloodType": "Blood type",
|
||||
"phone": "Phone",
|
||||
"none": "None",
|
||||
"editRecord": "Edit record"
|
||||
},
|
||||
@@ -1544,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",
|
||||
@@ -1976,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",
|
||||
@@ -2086,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": {
|
||||
@@ -2098,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",
|
||||
|
||||
@@ -310,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é",
|
||||
@@ -1442,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"
|
||||
},
|
||||
@@ -1544,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",
|
||||
@@ -1976,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",
|
||||
@@ -2086,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": {
|
||||
@@ -2098,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",
|
||||
|
||||
@@ -310,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",
|
||||
@@ -1442,6 +1443,8 @@
|
||||
"allergies": "Xasaasiyadaha",
|
||||
"activeMeds": "Daawooyin firfircoon",
|
||||
"openProblems": "Dhibaatooyin furan",
|
||||
"bloodType": "Nooca dhiigga",
|
||||
"phone": "Taleefanka",
|
||||
"none": "Midna",
|
||||
"editRecord": "Wax ka beddel diiwaanka"
|
||||
},
|
||||
@@ -1544,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",
|
||||
@@ -1976,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",
|
||||
@@ -2086,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": {
|
||||
@@ -2098,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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.7.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user