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
@@ -0,0 +1 @@
ALTER TABLE "patients" ADD COLUMN "wallet_number" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -246,6 +246,13 @@
"when": 1783363217049,
"tag": "0034_chunky_blacklash",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1783530491321,
"tag": "0035_slippery_retro_girl",
"breakpoints": true
}
]
}
+4
View File
@@ -64,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",
}),
+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.
+295
View File
@@ -0,0 +1,295 @@
// Patient Portal actions, shared by the public REST kiosk (routes/portal.ts)
// and the relay path used by the wallet app (relay-client.ts handles
// `portal:request` and dispatches here). Both go through the same clinic-scoped
// logic so a booking made in the phone shows up on the clinic's Appointments
// page exactly like a kiosk booking.
//
// The relay path identifies the patient by their *verified* wallet number (the
// relay only forwards a request after the device signed the relay challenge),
// which is more trustworthy than the kiosk's name + file-number check.
import { readFile } from "node:fs/promises";
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "../db/index.js";
import { member, organization, user } from "../db/schema/auth.js";
import { patients } from "../db/schema/patients.js";
import { staffProfile } from "../db/schema/staff-profile.js";
import { appointmentInputSchema } from "../lib/appointment-validation.js";
import { HttpError } from "../lib/http-error.js";
import { initialsFromName } from "../lib/initials.js";
import { recordActivity } from "./activity.js";
import { createAppointment, listAppointments } from "./appointments.js";
import {
absolutePath,
getAttachmentRow,
listAttachments,
} from "./attachments.js";
import { getPatient } from "./patients.js";
// Clinical-capable roles that can be a patient's provider (mirrors portal.ts).
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
const norm = (s: string) => s.trim().toLowerCase();
export type PortalDoctor = { name: string; specialty: string | null };
export async function getClinicInfo(orgId: string): Promise<{ name: string }> {
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, orgId))
.limit(1);
if (!org) throw new HttpError(404, "Clinic not found.");
return { name: org.name };
}
export async function listDoctors(orgId: string): Promise<PortalDoctor[]> {
const rows = await db
.select({ name: user.name, specialty: staffProfile.specialty })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.leftJoin(
staffProfile,
and(
eq(staffProfile.userId, member.userId),
eq(staffProfile.organizationId, member.organizationId),
),
)
.where(
and(
eq(member.organizationId, orgId),
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
),
)
.orderBy(asc(user.name));
return rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null }));
}
// Taken time slots for a provider on a day, so the client renders only free
// ones. Mirrors routes/portal.ts (an empty-provider appointment blocks the slot
// clinic-wide). Booking re-checks server-side.
export async function getAvailability(
orgId: string,
provider: string,
date: string,
): Promise<{ date: string; provider: string; taken: string[] }> {
const taken = (await listAppointments(orgId))
.filter(
(a) =>
a.status !== "cancelled" &&
a.date === date &&
(!provider || !a.provider || a.provider === provider),
)
.map((a) => a.time);
return { date, provider, taken: [...new Set(taken)].sort() };
}
// --- wallet linkage ---------------------------------------------------------
// Save a wallet number onto a patient file after verifying the patient's
// identity (name + file number, like the kiosk). Once linked, clinic→wallet
// pushes and portal actions resolve to this file directly.
export async function linkWallet(
orgId: string,
walletNumber: string,
fileNumber: string,
name: string,
): Promise<{ fileNumber: string; name: string }> {
const patient = await getPatient(orgId, fileNumber);
if (!patient || norm(patient.name) !== norm(name)) {
throw new HttpError(
404,
"We couldn't find a record matching that name and file number.",
);
}
await db
.update(patients)
.set({ walletNumber })
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, patient.fileNumber),
),
);
await recordActivity({
orgId,
actor: { id: "", name: patient.name },
action: `Patient portal — ${patient.name} linked a wallet`,
entityType: "patient",
entityId: patient.fileNumber,
});
return { fileNumber: patient.fileNumber, name: patient.name };
}
// The file number a linked wallet maps to, or null.
export async function fileNumberForWallet(
orgId: string,
walletNumber: string,
): Promise<string | null> {
const [row] = await db
.select({ fileNumber: patients.fileNumber })
.from(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.walletNumber, walletNumber),
),
)
.limit(1);
return row?.fileNumber ?? null;
}
async function requireLinkedPatient(orgId: string, walletNumber: string) {
const fileNumber = await fileNumberForWallet(orgId, walletNumber);
if (!fileNumber) {
throw new HttpError(403, "This wallet isn't linked to a record at this clinic.");
}
const patient = await getPatient(orgId, fileNumber);
if (!patient) throw new HttpError(404, "Linked record not found.");
return patient;
}
// Book an appointment for the linked wallet (conflict-checked), attributed to
// the patient's file so it appears on the clinic's Appointments page.
export async function bookForWallet(
orgId: string,
walletNumber: string,
body: { date: string; time: string; type?: string; provider?: string },
): Promise<{ date: string; time: string; type: string; provider: string }> {
const patient = await requireLinkedPatient(orgId, walletNumber);
const today = new Date().toISOString().slice(0, 10);
if (body.date < today) throw new HttpError(400, "Please pick a future date.");
const input = appointmentInputSchema.parse({
fileNumber: patient.fileNumber,
name: patient.name,
initials: patient.initials || initialsFromName(patient.name),
date: body.date,
time: body.time,
type: body.type || "Self-service booking",
provider: body.provider || patient.pcp || "",
status: "confirmed",
source: "manual",
});
const taken = (await listAppointments(orgId)).some(
(a) =>
a.status !== "cancelled" &&
a.date === input.date &&
a.time === input.time &&
(!input.provider || !a.provider || a.provider === input.provider),
);
if (taken) {
throw new HttpError(409, "That time slot is already taken. Please choose another time.");
}
const created = await createAppointment(orgId, "", input);
await recordActivity({
orgId,
actor: { id: "", name: patient.name },
action: `Patient portal booking — ${patient.name} on ${created.date} ${created.time}`,
entityType: "appointment",
entityId: created.id,
});
return {
date: created.date,
time: created.time,
type: created.type,
provider: created.provider,
};
}
// Results view for the linked wallet: upcoming appointments + downloadable lab
// files (metadata only; bytes come from `getResultFile`).
export async function resultsForWallet(
orgId: string,
walletNumber: string,
): Promise<{
name: string;
upcoming: { date: string; time: string; type: string; provider: string; status: string }[];
files: { id: string; filename: string; mimeType: string; sizeBytes: number; labKey: string | null }[];
}> {
const patient = await requireLinkedPatient(orgId, walletNumber);
const now = new Date();
const upcoming = (await listAppointments(orgId))
.filter(
(a) =>
a.fileNumber === patient.fileNumber &&
a.status !== "cancelled" &&
new Date(`${a.date}T${a.time}`) >= now,
)
.map((a) => ({
date: a.date,
time: a.time,
type: a.type,
provider: a.provider,
status: a.status,
}));
const files = (await listAttachments(orgId, patient.fileNumber)).map((f) => ({
id: f.id,
filename: f.filename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
labKey: f.labKey,
}));
return { name: patient.name, upcoming, files };
}
// A single lab/result file for the linked wallet, base64-encoded so it can ride
// back over the relay. Verifies the file belongs to the patient's own record.
export async function resultFileForWallet(
orgId: string,
walletNumber: string,
attachmentId: string,
): Promise<{ filename: string; mimeType: string; base64: string }> {
const patient = await requireLinkedPatient(orgId, walletNumber);
const row = await getAttachmentRow(orgId, attachmentId);
if (!row || row.fileNumber !== patient.fileNumber) {
throw new HttpError(404, "File not found.");
}
const bytes = await readFile(absolutePath(row.storagePath));
return {
filename: row.filename,
mimeType: row.mimeType,
base64: bytes.toString("base64"),
};
}
// --- relay dispatch ---------------------------------------------------------
type PortalPayload = Record<string, unknown>;
// Dispatch a `portal:request` relayed from a wallet device. `walletNumber` is
// the device's relay-verified wallet number (empty for the public reads).
export async function handlePortalRequest(
orgId: string,
req: { action: string; payload: PortalPayload; walletNumber: string },
): Promise<unknown> {
const { action, payload, walletNumber } = req;
const s = (k: string): string => String(payload[k] ?? "");
switch (action) {
case "clinic":
return getClinicInfo(orgId);
case "doctors":
return listDoctors(orgId);
case "availability":
return getAvailability(orgId, s("provider"), s("date"));
case "link":
return linkWallet(orgId, walletNumber, s("fileNumber"), s("name"));
case "book":
return bookForWallet(orgId, walletNumber, {
date: s("date"),
time: s("time"),
type: s("type") || undefined,
provider: s("provider") || undefined,
});
case "results":
return resultsForWallet(orgId, walletNumber);
case "result-file":
return resultFileForWallet(orgId, walletNumber, s("id"));
default:
throw new HttpError(400, `Unknown portal action: ${action}`);
}
}
+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",
+17
View File
@@ -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";
@@ -70,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)