mirror of
https://github.com/temetro/temetro.git
synced 2026-08-21 23:47:14 +00:00
feat: clinic→wallet record-update push
A clinician can push an updated record to a wallet-linked patient (permanent share). The snapshot is signed with the clinic Ed25519 key and sealed to the wallet's X25519 key — derived from its Ed25519 wallet number via the birational map, verified byte-for-byte against the wallet's own derivation. Stored pending, delivered over the /wallet relay live and on the wallet's next authenticated connect (offline catch-up). The patient approves/denies in-app; the wallet signs its decision, the backend verifies it, and the record is replaced only on approval. Wallet pins the clinic key (TOFU) and warns on change. Backend: walletRecordUpdates table + service, ed25519PubToX25519Hex helper, POST /api/patients/wallet/push, GET .../link/:fileNumber|updates|updates/:id, wallet:update-request / wallet:update-response relay events. Frontend: "Push to wallet" dialog with live status, wallet-link gating on the patient sheet, "Sent updates" list under Settings → Signing, walletPush / walletUpdatesList locale namespaces across all five languages. Bumps to v0.5.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { recordActivity } from "../services/activity.js";
|
||||
import * as patientService from "../services/patients.js";
|
||||
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
import * as walletUpdates from "../services/wallet-updates.js";
|
||||
|
||||
export const patientsWalletRouter = Router();
|
||||
|
||||
@@ -138,6 +139,94 @@ patientsWalletRouter.get(
|
||||
},
|
||||
);
|
||||
|
||||
// --- Clinic → wallet record-update push ------------------------------------
|
||||
|
||||
// Whether a patient is linked to a wallet (drives the "Push update" button).
|
||||
// Returns the wallet number when linked, 404 otherwise.
|
||||
patientsWalletRouter.get(
|
||||
"/link/:fileNumber",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const walletNumber = await walletUpdates.walletNumberForPatient(
|
||||
req.organizationId!,
|
||||
req.params.fileNumber as string,
|
||||
);
|
||||
if (!walletNumber) throw new HttpError(404, "Not wallet-linked.");
|
||||
res.json({ walletNumber });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const pushSchema = z.object({
|
||||
fileNumber: z.string().trim().min(1),
|
||||
changes: z.array(z.string().trim().min(1)).min(1).max(50),
|
||||
});
|
||||
|
||||
// Push the current record snapshot to the linked wallet. Seals + signs it,
|
||||
// stores it pending, and delivers live if the device is connected (it is also
|
||||
// re-sent on the wallet's next connect). The patient must approve in-app.
|
||||
patientsWalletRouter.post(
|
||||
"/push",
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const input = pushSchema.parse(req.body);
|
||||
const row = await walletUpdates.createRecordUpdate(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input.fileNumber,
|
||||
input.changes,
|
||||
);
|
||||
const event = await walletUpdates.toEvent(row);
|
||||
emitToWallet(row.walletNumber, "wallet:update-request", event);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Pushed a record update to a patient wallet (#${row.fileNumber})`,
|
||||
entityType: "patient",
|
||||
entityId: row.fileNumber,
|
||||
patientFileNumber: row.fileNumber,
|
||||
});
|
||||
res.status(201).json(walletUpdates.viewOf(row));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The clinic's recent update pushes (Signing panel + status polling).
|
||||
patientsWalletRouter.get(
|
||||
"/updates",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json(await walletUpdates.listUpdates(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
patientsWalletRouter.get(
|
||||
"/updates/:id",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const view = await walletUpdates.getUpdate(
|
||||
req.organizationId!,
|
||||
req.params.id as string,
|
||||
);
|
||||
if (!view) throw new HttpError(404, "Update not found.");
|
||||
res.json(view);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Commit the (possibly clinician-edited) draft into a real patient record. The
|
||||
// temporary-share metadata (origin + auto-delete deadline) is taken from the
|
||||
// request server-side, so the clinic can't quietly keep a temporary record.
|
||||
|
||||
Reference in New Issue
Block a user