backend: Patient Portal over the relay + wallet linking

- services/portal.ts: clinic-scoped portal actions (clinic info, doctors,
  availability, linkWallet, conflict-checked booking, results, downloadable lab
  files) plus handlePortalRequest to dispatch a relayed portal:request.
- relay-client.ts: handle portal:request on the hub and ack the result back down
  the relay (device path identifies the patient by verified wallet number).
- patients: new nullable wallet_number column (+ migration); linking stores it,
  and walletNumberForPatient now resolves via it so pushes work after a portal
  link, not only after a permanent share.
- routes/portal.ts: GET /:clinic/link returns the relay-based pairing descriptor
  (clinic signing key + relay URL) for the QR — no more localhost-baked API URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-08 20:08:36 +03:00
parent 1c5e71eb39
commit dd64d689c8
8 changed files with 5057 additions and 0 deletions
+30
View File
@@ -19,6 +19,8 @@
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";
@@ -201,6 +203,34 @@ function registerHubHandlers(orgId: string, hub: Socket): 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",