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
+23
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { db } from "../db/index.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";
@@ -12,6 +13,7 @@ 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.
@@ -50,6 +52,27 @@ 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.