backend: ship prescriptions + a stable clinic id to wallets

A prescription written in the clinic never reached the patient's wallet.
createPrescription writes to the `prescriptions` table, but the pushed
bundle only carried `patient.medications`, which comes from the separate
`patient_medications` table and never grows when a prescription is
written. The patient approved a bundle identical to what they already
had, so the app's Prescriptions section showed nothing new — the
"New prescription: X" line in `changes` is display text, not data.

Pull listPrescriptions into the bundle alongside the appointments and
invoices that were already handled the same way, keeping the full
prescription shape rather than flattening to {name, dose, frequency}.

Also add `clinicId` (the org id) to the update event. Wallets pinned a
clinic's signing key against `clinicName`, which is mutable and falls
back to the literal "A clinic" for unnamed orgs, so unnamed clinics
collided on one pin and tripped a bogus "key changed" warning. It also
gives the wallet the relay routing id it needs to fetch document bytes.

Log the silent drops on this path: sendToWallet no-ops when an org has
no live hub, and applyUpdateResponse returned null without a word when a
response arrived unsigned or already resolved. Both looked, from the
clinic side, exactly like a patient who never tapped Approve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-15 20:03:50 +03:00
parent 62a5f39683
commit fa566ced86
2 changed files with 67 additions and 16 deletions
+11 -1
View File
@@ -40,7 +40,17 @@ export function sendToWallet(
event: string,
data: unknown,
): void {
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
const hub = hubs.get(orgId);
if (!hub) {
// No live hub means the push is dropped on the floor. The row still sits
// pending and `wallet:online` replays it once a device reconnects, but say
// so — a silent no-op here looks exactly like a wallet that ignored us.
console.warn(
`Temetro Network: no hub connection for clinic ${orgId}; "${event}" not sent (queued for wallet:online replay).`,
);
return;
}
hub.emit("wallet:send", { walletNumber, event, data });
}
// Tell the relay to expect a device response for `requestId` and route it back
+56 -15
View File
@@ -18,6 +18,7 @@ import { listAppointments } from "./appointments.js";
import { listAttachments } from "./attachments.js";
import { listInvoices } from "./invoices.js";
import { getPatient } from "./patients.js";
import { listPrescriptions } from "./prescriptions.js";
import { signWithClinicKey } from "./signing.js";
type UpdateRow = typeof walletRecordUpdates.$inferSelect;
@@ -25,8 +26,15 @@ type UpdateRow = typeof walletRecordUpdates.$inferSelect;
// The payload the relay pushes to a wallet. `sealed` is the encrypted patient
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
// provenance (TOFU pin) before applying.
//
// `clinicId` is the stable org id. The wallet pins a clinic's signing key
// against it — pinning against the mutable `clinicName` meant every unnamed org
// collided on the "A clinic" fallback and tripped a bogus "key changed" warning.
// It also doubles as the relay routing id the wallet needs to fetch document
// bytes over the portal.
export type WalletUpdateEvent = {
requestId: string;
clinicId: string;
clinicName: string;
sealed: string;
signature: string;
@@ -120,20 +128,30 @@ 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.
// Attachments (files/documents) are shipped as metadata so the wallet can list
// them and show a count; the bytes stay on the clinic for now.
const [orgAppointments, orgInvoices, attachmentRows] = await Promise.all([
listAppointments(orgId),
listInvoices(orgId),
listAttachments(orgId, fileNumber),
]);
// Appointments, invoices and prescriptions 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. `patient.medications` comes from `patient_medications`,
// which `createPrescription` never writes to, so a prescription that isn't
// shipped here would leave the approved bundle identical to what the wallet
// already had.
// Attachments (files/documents) are shipped as metadata; the bytes stay on the
// clinic and the wallet fetches them on demand over the portal `result-file`
// action, keyed by the same attachment id shipped here.
const [orgAppointments, orgInvoices, orgPrescriptions, attachmentRows] =
await Promise.all([
listAppointments(orgId),
listInvoices(orgId),
listPrescriptions(orgId),
listAttachments(orgId, fileNumber),
]);
const appointments = orgAppointments.filter(
(a) => a.fileNumber === fileNumber,
);
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
const prescriptions = orgPrescriptions.filter(
(p) => p.fileNumber === fileNumber,
);
const documents = attachmentRows.map((a) => ({
id: a.id,
filename: a.filename,
@@ -143,9 +161,16 @@ export async function createRecordUpdate(
}));
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient` (+ appointments/invoices/documents).
// replaces its on-device record with `patient` (+ the sibling-table lists).
const bundle = utf8ToBytes(
JSON.stringify({ patient, appointments, invoices, documents, changes }),
JSON.stringify({
patient,
appointments,
invoices,
prescriptions,
documents,
changes,
}),
);
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
@@ -176,6 +201,7 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
.where(eq(organization.id, row.organizationId));
return {
requestId: row.id,
clinicId: row.organizationId,
clinicName: org?.name ?? "A clinic",
sealed: row.payloadSealed,
signature: row.clinicSignature,
@@ -234,9 +260,24 @@ export async function applyUpdateResponse(
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.id, requestId));
if (!row || row.resolvedAt) return null;
if (row.walletNumber !== walletNumber.trim()) return null;
if (!signatureHex) return null;
// These all mean the patient tapped Approve and nothing happened, so say so —
// silently returning null here is indistinguishable from a lost connection.
if (!row) {
console.warn(`Wallet update ${requestId}: no such update row.`);
return null;
}
if (row.resolvedAt) {
console.warn(`Wallet update ${requestId}: already resolved.`);
return null;
}
if (row.walletNumber !== walletNumber.trim()) {
console.warn(`Wallet update ${requestId}: wallet number did not match.`);
return null;
}
if (!signatureHex) {
console.warn(`Wallet update ${requestId}: response carried no signature.`);
return null;
}
const publicKey = decodeWalletNumber(walletNumber);
const message = utf8ToBytes(`${decision}:${requestId}`);