mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -40,7 +40,17 @@ export function sendToWallet(
|
|||||||
event: string,
|
event: string,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
): void {
|
): 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
|
// Tell the relay to expect a device response for `requestId` and route it back
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { listAppointments } from "./appointments.js";
|
|||||||
import { listAttachments } from "./attachments.js";
|
import { listAttachments } from "./attachments.js";
|
||||||
import { listInvoices } from "./invoices.js";
|
import { listInvoices } from "./invoices.js";
|
||||||
import { getPatient } from "./patients.js";
|
import { getPatient } from "./patients.js";
|
||||||
|
import { listPrescriptions } from "./prescriptions.js";
|
||||||
import { signWithClinicKey } from "./signing.js";
|
import { signWithClinicKey } from "./signing.js";
|
||||||
|
|
||||||
type UpdateRow = typeof walletRecordUpdates.$inferSelect;
|
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
|
// The payload the relay pushes to a wallet. `sealed` is the encrypted patient
|
||||||
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
|
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
|
||||||
// provenance (TOFU pin) before applying.
|
// 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 = {
|
export type WalletUpdateEvent = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
clinicId: string;
|
||||||
clinicName: string;
|
clinicName: string;
|
||||||
sealed: string;
|
sealed: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
@@ -120,20 +128,30 @@ export async function createRecordUpdate(
|
|||||||
const patient = await getPatient(orgId, fileNumber);
|
const patient = await getPatient(orgId, fileNumber);
|
||||||
if (!patient) throw new HttpError(404, "Patient not found.");
|
if (!patient) throw new HttpError(404, "Patient not found.");
|
||||||
|
|
||||||
// Appointments and invoices live in their own tables (not on the Patient
|
// Appointments, invoices and prescriptions live in their own tables (not on
|
||||||
// snapshot), so pull the ones for this patient and ship them alongside — the
|
// the Patient snapshot), so pull the ones for this patient and ship them
|
||||||
// wallet has no other way to see them and they'd otherwise silently vanish.
|
// alongside — the wallet has no other way to see them and they'd otherwise
|
||||||
// Attachments (files/documents) are shipped as metadata so the wallet can list
|
// silently vanish. `patient.medications` comes from `patient_medications`,
|
||||||
// them and show a count; the bytes stay on the clinic for now.
|
// which `createPrescription` never writes to, so a prescription that isn't
|
||||||
const [orgAppointments, orgInvoices, attachmentRows] = await Promise.all([
|
// shipped here would leave the approved bundle identical to what the wallet
|
||||||
listAppointments(orgId),
|
// already had.
|
||||||
listInvoices(orgId),
|
// Attachments (files/documents) are shipped as metadata; the bytes stay on the
|
||||||
listAttachments(orgId, fileNumber),
|
// 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(
|
const appointments = orgAppointments.filter(
|
||||||
(a) => a.fileNumber === fileNumber,
|
(a) => a.fileNumber === fileNumber,
|
||||||
);
|
);
|
||||||
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
|
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
|
||||||
|
const prescriptions = orgPrescriptions.filter(
|
||||||
|
(p) => p.fileNumber === fileNumber,
|
||||||
|
);
|
||||||
const documents = attachmentRows.map((a) => ({
|
const documents = attachmentRows.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
@@ -143,9 +161,16 @@ export async function createRecordUpdate(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// The wallet opens this, verifies the signature over the same bytes, then
|
// 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(
|
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 { signature, publicKey } = await signWithClinicKey(orgId, bundle);
|
||||||
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
|
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
|
||||||
@@ -176,6 +201,7 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
|
|||||||
.where(eq(organization.id, row.organizationId));
|
.where(eq(organization.id, row.organizationId));
|
||||||
return {
|
return {
|
||||||
requestId: row.id,
|
requestId: row.id,
|
||||||
|
clinicId: row.organizationId,
|
||||||
clinicName: org?.name ?? "A clinic",
|
clinicName: org?.name ?? "A clinic",
|
||||||
sealed: row.payloadSealed,
|
sealed: row.payloadSealed,
|
||||||
signature: row.clinicSignature,
|
signature: row.clinicSignature,
|
||||||
@@ -234,9 +260,24 @@ export async function applyUpdateResponse(
|
|||||||
.select()
|
.select()
|
||||||
.from(walletRecordUpdates)
|
.from(walletRecordUpdates)
|
||||||
.where(eq(walletRecordUpdates.id, requestId));
|
.where(eq(walletRecordUpdates.id, requestId));
|
||||||
if (!row || row.resolvedAt) return null;
|
// These all mean the patient tapped Approve and nothing happened, so say so —
|
||||||
if (row.walletNumber !== walletNumber.trim()) return null;
|
// silently returning null here is indistinguishable from a lost connection.
|
||||||
if (!signatureHex) return null;
|
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 publicKey = decodeWalletNumber(walletNumber);
|
||||||
const message = utf8ToBytes(`${decision}:${requestId}`);
|
const message = utf8ToBytes(`${decision}:${requestId}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user