mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb536ba6da |
@@ -7,6 +7,24 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] — 2026-07-03
|
||||
|
||||
### Added
|
||||
- **Clinic → wallet record-update push** — a clinician can push an updated record to a
|
||||
**wallet-linked** patient (a permanent, approved share). The record snapshot is **signed**
|
||||
with the clinic's 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`, and delivered over the `/wallet` relay live **and** on
|
||||
the wallet's next authenticated connect (so an offline phone catches up). The patient
|
||||
reviews it in a **pending-updates inbox** and approves/denies; the wallet signs its
|
||||
decision, the backend verifies it, and the on-device record is replaced only on approval.
|
||||
The wallet **pins** the clinic key (TOFU) and warns on a key change. New
|
||||
`POST /api/patients/wallet/push`, `GET /api/patients/wallet/{link/:fileNumber,updates,updates/:id}`,
|
||||
`walletRecordUpdates` table + service, `wallet:update-request` / `wallet:update-response`
|
||||
relay events, a "Push to wallet" dialog with live status, and a "Sent updates" list under
|
||||
Settings → Signing. New `walletPush` / `walletUpdatesList` locale namespaces across all five
|
||||
languages. (The wallet app half ships in the sibling `temetro-app` repo.)
|
||||
|
||||
## [0.4.0] — 2026-07-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE "wallet_record_updates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"file_number" text NOT NULL,
|
||||
"wallet_number" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"payload_sealed" text NOT NULL,
|
||||
"clinic_signature" text NOT NULL,
|
||||
"clinic_public_key" text NOT NULL,
|
||||
"clinic_fingerprint" text NOT NULL,
|
||||
"changes" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"delivered_at" timestamp,
|
||||
"resolved_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "wallet_updates_org_idx" ON "wallet_record_updates" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "wallet_updates_wallet_idx" ON "wallet_record_updates" USING btree ("wallet_number");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,13 @@
|
||||
"when": 1782057030557,
|
||||
"tag": "0029_tiny_starhawk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1783093188246,
|
||||
"tag": "0030_medical_blur",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
|
||||
@@ -21,3 +21,4 @@ export * from "./staff-profile.js";
|
||||
export * from "./meetings.js";
|
||||
export * from "./signing.js";
|
||||
export * from "./wallet-share.js";
|
||||
export * from "./wallet-updates.js";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
export type WalletUpdateStatus =
|
||||
| "pending"
|
||||
| "delivered"
|
||||
| "approved"
|
||||
| "denied";
|
||||
|
||||
// One row per clinic→wallet record-update push. When a clinician edits a
|
||||
// wallet-linked patient they can push the updated record to the patient's app;
|
||||
// it lands here as `pending`, is sealed to the wallet's (X25519-from-Ed25519)
|
||||
// key and signed with the clinic's Ed25519 key. The relay delivers it live if
|
||||
// the device is connected, and again on the wallet's next authenticated connect
|
||||
// (so an offline phone still receives it). The patient reviews the change,
|
||||
// verifies the clinic signature, and approves/denies in-app — only then is the
|
||||
// on-device record replaced. The clinic polls `status` for delivery/approval.
|
||||
export const walletRecordUpdates = pgTable(
|
||||
"wallet_record_updates",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
fileNumber: text("file_number").notNull(),
|
||||
walletNumber: text("wallet_number").notNull(),
|
||||
status: text("status")
|
||||
.$type<WalletUpdateStatus>()
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
// base64 sealed box of the full updated patient snapshot (sealed to the
|
||||
// wallet's derived X25519 key).
|
||||
payloadSealed: text("payload_sealed").notNull(),
|
||||
// The clinic's Ed25519 signature over the plaintext bundle bytes + its
|
||||
// public key + fingerprint, so the wallet can verify provenance (TOFU pin).
|
||||
clinicSignature: text("clinic_signature").notNull(),
|
||||
clinicPublicKey: text("clinic_public_key").notNull(),
|
||||
clinicFingerprint: text("clinic_fingerprint").notNull(),
|
||||
// Human-readable summary of what changed (shown in the wallet inbox).
|
||||
changes: jsonb("changes").$type<string[]>().notNull().default([]),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
deliveredAt: timestamp("delivered_at"),
|
||||
resolvedAt: timestamp("resolved_at"),
|
||||
},
|
||||
(t) => [
|
||||
index("wallet_updates_org_idx").on(t.organizationId),
|
||||
index("wallet_updates_wallet_idx").on(t.walletNumber),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { bytesToHex } from "@noble/hashes/utils.js";
|
||||
|
||||
// Convert an Ed25519 public key to the matching X25519 (Montgomery) public key,
|
||||
// so the clinic can `seal()` a record update to a wallet that only publishes an
|
||||
// Ed25519 identity (its wallet number). The patient wallet derives the matching
|
||||
// X25519 *private* key from its Ed25519 seed (SHA-512 clamp) to `open()` it —
|
||||
// this file MUST stay byte-for-byte compatible with the wallet app's
|
||||
// src/lib/crypto.ts. @noble/curves does not export edwardsToMontgomery in the
|
||||
// pinned version, so the birational map u = (1 + y) / (1 - y) mod p is done here
|
||||
// with BigInt. Verified: edPubToMontU(A) === x25519.getPublicKey(edClamp(seed)).
|
||||
|
||||
const P = 2n ** 255n - 19n;
|
||||
|
||||
function modpow(base: bigint, exp: bigint, mod: bigint): bigint {
|
||||
let result = 1n;
|
||||
let b = base % mod;
|
||||
let e = exp;
|
||||
while (e > 0n) {
|
||||
if (e & 1n) result = (result * b) % mod;
|
||||
b = (b * b) % mod;
|
||||
e >>= 1n;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Modular inverse via Fermat's little theorem (p is prime).
|
||||
function inv(a: bigint): bigint {
|
||||
return modpow(((a % P) + P) % P, P - 2n, P);
|
||||
}
|
||||
|
||||
// Ed25519 public key (compressed, little-endian y with the x-sign in the high
|
||||
// bit) → X25519 u-coordinate, returned as 32-byte little-endian hex.
|
||||
export function ed25519PubToX25519Hex(edPub: Uint8Array): string {
|
||||
if (edPub.length !== 32) throw new Error("Ed25519 public key must be 32 bytes.");
|
||||
const bytes = edPub.slice();
|
||||
bytes[31] = (bytes[31] as number) & 0x7f; // clear the x sign bit
|
||||
let y = 0n;
|
||||
for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i] as number);
|
||||
y %= P;
|
||||
// u = (1 + y) / (1 - y) (mod p)
|
||||
const u = ((1n + y) * inv((1n - y + P) % P)) % P;
|
||||
const out = new Uint8Array(32);
|
||||
let v = u;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return bytesToHex(out);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import * as meetings from "./services/meetings.js";
|
||||
import * as messaging from "./services/messaging.js";
|
||||
import { createNotification } from "./services/notifications.js";
|
||||
import * as walletShare from "./services/wallet-share.js";
|
||||
import * as walletUpdates from "./services/wallet-updates.js";
|
||||
import type { MessageAttachment } from "./types/messaging.js";
|
||||
|
||||
let io: Server | null = null;
|
||||
@@ -317,12 +318,57 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
socket.data.walletNumber = walletNumber;
|
||||
socket.join(walletRoom(walletNumber));
|
||||
ack?.({ ok: true });
|
||||
// Deliver any record updates the device missed while offline. Sent
|
||||
// after the ack so the client is ready to receive them.
|
||||
void walletUpdates
|
||||
.pendingUpdatesForWallet(walletNumber)
|
||||
.then(async (rows) => {
|
||||
for (const row of rows) {
|
||||
socket.emit("wallet:update-request", await walletUpdates.toEvent(row));
|
||||
await walletUpdates.markDelivered(row.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a clinic→wallet record update on their device.
|
||||
// We verify the wallet's signature over the decision and resolve the row.
|
||||
socket.on(
|
||||
"wallet:update-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const view = await walletUpdates.applyUpdateResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a share on their device; the sealed bundle (if
|
||||
// approved) rides along and is decrypted + verified server-side.
|
||||
socket.on(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
||||
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { organization } from "../db/schema/auth.js";
|
||||
import { walletRecordUpdates } from "../db/schema/wallet-updates.js";
|
||||
import { walletShareRequests } from "../db/schema/wallet-share.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import {
|
||||
decodeWalletNumber,
|
||||
fingerprint,
|
||||
seal,
|
||||
verifySignature,
|
||||
} from "../lib/wallet-crypto.js";
|
||||
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
|
||||
import { getPatient } from "./patients.js";
|
||||
import { signWithClinicKey } from "./signing.js";
|
||||
|
||||
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.
|
||||
export type WalletUpdateEvent = {
|
||||
requestId: string;
|
||||
clinicName: string;
|
||||
sealed: string;
|
||||
signature: string;
|
||||
clinicPublicKey: string;
|
||||
fingerprint: string;
|
||||
changes: string[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// The clinic-facing view (no ciphertext) for the "Sent updates" list + polling.
|
||||
export type WalletUpdateView = {
|
||||
id: string;
|
||||
fileNumber: string;
|
||||
walletNumber: string;
|
||||
status: UpdateRow["status"];
|
||||
changes: string[];
|
||||
createdAt: string;
|
||||
deliveredAt: string | null;
|
||||
resolvedAt: string | null;
|
||||
};
|
||||
|
||||
export function viewOf(row: UpdateRow): WalletUpdateView {
|
||||
return toView(row);
|
||||
}
|
||||
|
||||
function toView(row: UpdateRow): WalletUpdateView {
|
||||
return {
|
||||
id: row.id,
|
||||
fileNumber: row.fileNumber,
|
||||
walletNumber: row.walletNumber,
|
||||
status: row.status,
|
||||
changes: row.changes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
deliveredAt: row.deliveredAt ? row.deliveredAt.toISOString() : null,
|
||||
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// The wallet number a patient's record is linked to, or null when it isn't
|
||||
// wallet-backed. Only *permanent, approved, committed* shares qualify —
|
||||
// temporary shares auto-delete, so pushing an update to them is meaningless.
|
||||
export async function walletNumberForPatient(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ walletNumber: walletShareRequests.walletNumber })
|
||||
.from(walletShareRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
eq(walletShareRequests.committedFileNumber, fileNumber),
|
||||
eq(walletShareRequests.status, "approved"),
|
||||
eq(walletShareRequests.shareMode, "permanent"),
|
||||
isNotNull(walletShareRequests.walletNumber),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.walletNumber ?? null;
|
||||
}
|
||||
|
||||
// Compose, seal and sign a record-update push, and store it as pending. Loads
|
||||
// the current patient snapshot, seals it to the wallet's derived X25519 key, and
|
||||
// signs the plaintext bundle with the clinic's Ed25519 key. Returns the row.
|
||||
export async function createRecordUpdate(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
fileNumber: string,
|
||||
changes: string[],
|
||||
): Promise<UpdateRow> {
|
||||
const walletNumber = await walletNumberForPatient(orgId, fileNumber);
|
||||
if (!walletNumber) {
|
||||
throw new HttpError(409, "This patient is not linked to a wallet.");
|
||||
}
|
||||
const patient = await getPatient(orgId, fileNumber);
|
||||
if (!patient) throw new HttpError(404, "Patient not found.");
|
||||
|
||||
// The wallet opens this, verifies the signature over the same bytes, then
|
||||
// replaces its on-device record with `patient`.
|
||||
const bundle = utf8ToBytes(JSON.stringify({ patient, changes }));
|
||||
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
|
||||
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
|
||||
const sealed = seal(x25519Hex, bundle);
|
||||
|
||||
const [row] = await db
|
||||
.insert(walletRecordUpdates)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
createdBy: userId,
|
||||
fileNumber,
|
||||
walletNumber,
|
||||
payloadSealed: sealed,
|
||||
clinicSignature: signature,
|
||||
clinicPublicKey: publicKey,
|
||||
clinicFingerprint: fingerprint(hexToBytes(publicKey)),
|
||||
changes,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
// Build the wire event for a stored update row (joins the clinic name).
|
||||
export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
|
||||
const [org] = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, row.organizationId));
|
||||
return {
|
||||
requestId: row.id,
|
||||
clinicName: org?.name ?? "A clinic",
|
||||
sealed: row.payloadSealed,
|
||||
signature: row.clinicSignature,
|
||||
clinicPublicKey: row.clinicPublicKey,
|
||||
fingerprint: row.clinicFingerprint,
|
||||
changes: row.changes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Every unresolved update for a wallet — re-sent on each authenticated connect
|
||||
// so an offline device eventually receives what it missed.
|
||||
export async function pendingUpdatesForWallet(
|
||||
walletNumber: string,
|
||||
): Promise<UpdateRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(walletRecordUpdates)
|
||||
.where(
|
||||
and(
|
||||
eq(walletRecordUpdates.walletNumber, walletNumber),
|
||||
isNull(walletRecordUpdates.resolvedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(walletRecordUpdates.createdAt);
|
||||
}
|
||||
|
||||
// Mark a pending update delivered (best-effort; only advances from pending).
|
||||
export async function markDelivered(id: string): Promise<void> {
|
||||
await db
|
||||
.update(walletRecordUpdates)
|
||||
.set({ status: "delivered", deliveredAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(walletRecordUpdates.id, id),
|
||||
eq(walletRecordUpdates.status, "pending"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the patient's decision relayed back from the wallet. Verifies the
|
||||
// wallet's Ed25519 signature over `${decision}:${requestId}` (provenance) before
|
||||
// resolving. Returns the resolved view, or null when unknown/already resolved.
|
||||
export async function applyUpdateResponse(
|
||||
requestId: string,
|
||||
walletNumber: string,
|
||||
decision: "approved" | "denied",
|
||||
signatureHex?: string,
|
||||
): Promise<WalletUpdateView | null> {
|
||||
const [row] = await db
|
||||
.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;
|
||||
|
||||
const publicKey = decodeWalletNumber(walletNumber);
|
||||
const message = utf8ToBytes(`${decision}:${requestId}`);
|
||||
if (!verifySignature(publicKey, signatureHex, message)) {
|
||||
throw new HttpError(400, "Response signature did not match the wallet.");
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(walletRecordUpdates)
|
||||
.set({ status: decision, resolvedAt: new Date() })
|
||||
.where(eq(walletRecordUpdates.id, requestId))
|
||||
.returning();
|
||||
return updated ? toView(updated) : null;
|
||||
}
|
||||
|
||||
// Recent update pushes for the clinic (Signing panel "Sent updates" list).
|
||||
export async function listUpdates(
|
||||
orgId: string,
|
||||
limit = 30,
|
||||
): Promise<WalletUpdateView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(walletRecordUpdates)
|
||||
.where(eq(walletRecordUpdates.organizationId, orgId))
|
||||
.orderBy(desc(walletRecordUpdates.createdAt))
|
||||
.limit(limit);
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
export async function getUpdate(
|
||||
orgId: string,
|
||||
id: string,
|
||||
): Promise<WalletUpdateView | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(walletRecordUpdates)
|
||||
.where(
|
||||
and(
|
||||
eq(walletRecordUpdates.id, id),
|
||||
eq(walletRecordUpdates.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
return row ? toView(row) : null;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { RecordGraph } from "@/components/graph/record-graph";
|
||||
import { PatientDetail } from "@/components/patients/patient-detail";
|
||||
import { ScribeDialog } from "@/components/patients/scribe-dialog";
|
||||
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
|
||||
import { WalletPushDialog } from "@/components/patients/wallet-push-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -32,6 +33,7 @@ import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
|
||||
import { useAiAccess } from "@/lib/ai-policy";
|
||||
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { getWalletLink } from "@/lib/wallet-updates";
|
||||
|
||||
type Status = "loading" | "ready" | "not-found";
|
||||
|
||||
@@ -86,6 +88,9 @@ export function PatientDetailSheet({
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [scribeOpen, setScribeOpen] = useState(false);
|
||||
const [walletPushOpen, setWalletPushOpen] = useState(false);
|
||||
// Set once we confirm this patient is linked to a wallet (permanent share).
|
||||
const [walletLinked, setWalletLinked] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
// Graph popped out of the sheet into its own dialog (the sheet closes first).
|
||||
@@ -131,6 +136,21 @@ export function PatientDetailSheet({
|
||||
};
|
||||
}, [open, fileNumber]);
|
||||
|
||||
// Whether this patient is wallet-linked (drives the "Push update" button).
|
||||
// Separate from the main load so it re-checks once the role resolves without
|
||||
// refetching the record. Only clinicians can push.
|
||||
useEffect(() => {
|
||||
setWalletLinked(false);
|
||||
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
|
||||
let active = true;
|
||||
getWalletLink(fileNumber)
|
||||
.then(() => active && setWalletLinked(true))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, fileNumber, role]);
|
||||
|
||||
const remove = async () => {
|
||||
if (!patient) return;
|
||||
try {
|
||||
@@ -179,6 +199,9 @@ export function PatientDetailSheet({
|
||||
setEditOpen(true);
|
||||
}}
|
||||
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
|
||||
onWalletPush={
|
||||
walletLinked ? () => setWalletPushOpen(true) : undefined
|
||||
}
|
||||
onOpenGraph={() => {
|
||||
onOpenChange(false);
|
||||
setGraphOpen(true);
|
||||
@@ -214,6 +237,14 @@ export function PatientDetailSheet({
|
||||
/>
|
||||
)}
|
||||
|
||||
{patient && (
|
||||
<WalletPushDialog
|
||||
onOpenChange={setWalletPushOpen}
|
||||
open={walletPushOpen}
|
||||
patient={patient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{patient && (
|
||||
<TransferPatientDialog
|
||||
onOpenChange={setTransferOpen}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
@@ -181,6 +182,7 @@ export function PatientDetail({
|
||||
patient,
|
||||
onEdit,
|
||||
onScribe,
|
||||
onWalletPush,
|
||||
onTransfer,
|
||||
onDelete,
|
||||
onOpenGraph,
|
||||
@@ -192,6 +194,8 @@ export function PatientDetail({
|
||||
onEdit?: () => void;
|
||||
// Opens the ambient AI visit scribe (record/transcribe → draft note).
|
||||
onScribe?: () => void;
|
||||
// Pushes the record to the patient's wallet (only when wallet-linked).
|
||||
onWalletPush?: () => void;
|
||||
onTransfer?: () => void;
|
||||
onDelete?: () => void;
|
||||
// Pops the record graph out into its own dialog (closing this sheet).
|
||||
@@ -309,6 +313,17 @@ export function PatientDetail({
|
||||
{t("patientCard.edit")}
|
||||
</Button>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<Button
|
||||
onClick={onWalletPush}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
aria-label={t("patients.delete.action")}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, Send, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import type { Patient } from "@/lib/patients";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getWalletUpdate,
|
||||
pushWalletUpdate,
|
||||
type WalletUpdate,
|
||||
} from "@/lib/wallet-updates";
|
||||
|
||||
// The record sections a clinician can flag as changed. The labels double as the
|
||||
// human-readable change summary the patient sees when approving.
|
||||
const SECTION_KEYS = [
|
||||
"demographics",
|
||||
"problems",
|
||||
"medications",
|
||||
"allergies",
|
||||
"labs",
|
||||
"vitals",
|
||||
"visits",
|
||||
] as const;
|
||||
|
||||
type Phase = "compose" | "sent";
|
||||
|
||||
// Push the current record to a wallet-linked patient's app. The patient must
|
||||
// approve it on their phone before their on-device record is replaced.
|
||||
export function WalletPushDialog({
|
||||
patient,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
patient: Patient;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("compose");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [note, setNote] = useState("");
|
||||
const [update, setUpdate] = useState<WalletUpdate | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setPhase("compose");
|
||||
setSelected(new Set());
|
||||
setNote("");
|
||||
setUpdate(null);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
// Poll the update's status until the patient approves/denies (or the dialog
|
||||
// closes). Live pushes usually resolve within seconds.
|
||||
useEffect(() => {
|
||||
if (phase !== "sent" || !update || update.resolvedAt) return;
|
||||
let active = true;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const fresh = await getWalletUpdate(update.id);
|
||||
if (!active) return;
|
||||
setUpdate(fresh);
|
||||
if (fresh.resolvedAt) clearInterval(timer);
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [phase, update]);
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const push = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const changes = [
|
||||
...[...selected].map((k) => t(`walletPush.sections.${k}`)),
|
||||
...(note.trim() ? [note.trim()] : []),
|
||||
];
|
||||
const created = await pushWalletUpdate({
|
||||
fileNumber: patient.fileNumber,
|
||||
changes,
|
||||
});
|
||||
setUpdate(created);
|
||||
setPhase("sent");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : t("walletPush.errors.generic"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canPush = selected.size > 0 || note.trim().length > 0;
|
||||
const status = update?.status ?? "pending";
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Send className="size-4 text-primary" />
|
||||
{t("walletPush.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("walletPush.subtitle", { name: patient.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
|
||||
{phase === "compose" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t("walletPush.sectionsLabel")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SECTION_KEYS.map((key) => {
|
||||
const on = selected.has(key);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-sm transition-colors",
|
||||
on
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
key={key}
|
||||
onClick={() => toggle(key)}
|
||||
type="button"
|
||||
>
|
||||
{t(`walletPush.sections.${key}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="wallet-push-note">
|
||||
{t("walletPush.noteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
className="min-h-20"
|
||||
id="wallet-push-note"
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("walletPush.notePlaceholder")}
|
||||
value={note}
|
||||
/>
|
||||
</div>
|
||||
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
|
||||
{t("walletPush.notice")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
{status === "approved" ? (
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-success/15 text-success">
|
||||
<Check className="size-6" />
|
||||
</div>
|
||||
) : status === "denied" ? (
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/15 text-destructive">
|
||||
<X className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{t(`walletPush.status.${status}.title`)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`walletPush.status.${status}.body`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
{phase === "compose" ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("walletPush.cancel")}
|
||||
</Button>
|
||||
<Button disabled={busy || !canPush} onClick={push} type="button">
|
||||
{busy && <Spinner className="size-4" />}
|
||||
{t("walletPush.send")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => handleOpenChange(false)} type="button">
|
||||
{t("walletPush.done")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type SharedRecord,
|
||||
type SigningKey,
|
||||
} from "@/lib/signing";
|
||||
import { listWalletUpdates, type WalletUpdate } from "@/lib/wallet-updates";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -33,17 +34,23 @@ export function SigningPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [key, setKey] = useState<SigningKey | null>(null);
|
||||
const [records, setRecords] = useState<SharedRecord[]>([]);
|
||||
const [updates, setUpdates] = useState<WalletUpdate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
|
||||
.then(([k, r]) => {
|
||||
Promise.all([
|
||||
getSigningKey(),
|
||||
listSignedRecords().catch(() => []),
|
||||
listWalletUpdates().catch(() => []),
|
||||
])
|
||||
.then(([k, r, u]) => {
|
||||
if (!active) return;
|
||||
setKey(k);
|
||||
setRecords(r);
|
||||
setUpdates(u);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -223,6 +230,40 @@ export function SigningPanel() {
|
||||
</SettingsCard>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description={t("walletUpdatesList.description")}
|
||||
title={t("walletUpdatesList.title")}
|
||||
>
|
||||
{updates.length === 0 ? (
|
||||
<SettingsCard className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("walletUpdatesList.none")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<SettingsCard className="divide-y divide-border">
|
||||
{updates.map((update) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-4 py-3.5"
|
||||
key={update.id}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="truncate text-sm">
|
||||
{update.changes.join(" · ") || `#${update.fileNumber}`}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
#{update.fileNumber} · {formatDate(update.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t(`walletPush.status.${update.status}.title`)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</SettingsCard>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,53 @@
|
||||
"generic": "حدث خطأ ما. يُرجى المحاولة مرة أخرى."
|
||||
}
|
||||
},
|
||||
"walletUpdatesList": {
|
||||
"title": "التحديثات المُرسَلة إلى المحافظ",
|
||||
"description": "تحديثات السجلات التي أرسلتها إلى محافظ المرضى، وما إذا كان المريض قد وافق عليها.",
|
||||
"none": "لم ترسل أي تحديثات إلى المحافظ بعد."
|
||||
},
|
||||
"walletPush": {
|
||||
"action": "إرسال إلى المحفظة",
|
||||
"title": "إرسال التحديث إلى المحفظة",
|
||||
"subtitle": "أرسل سجل {{name}} المُحدَّث إلى تطبيق المحفظة الخاص به. يجب أن يوافق عليه على هاتفه قبل أن يحل محل النسخة الموجودة على جهازه.",
|
||||
"sectionsLabel": "ما الذي تغيّر",
|
||||
"sections": {
|
||||
"demographics": "البيانات الديموغرافية",
|
||||
"problems": "المشكلات",
|
||||
"medications": "الأدوية",
|
||||
"allergies": "الحساسية",
|
||||
"labs": "التحاليل",
|
||||
"vitals": "العلامات الحيوية",
|
||||
"visits": "الزيارات"
|
||||
},
|
||||
"noteLabel": "ملاحظة (اختياري)",
|
||||
"notePlaceholder": "ملاحظة قصيرة عن هذا التحديث…",
|
||||
"notice": "يُرسِل هذا السجل الحالي كاملاً، موقَّعًا بمفتاح عيادتك. ولا يحل محل النسخة الموجودة على جهاز المريض إلا بعد موافقته.",
|
||||
"cancel": "إلغاء",
|
||||
"send": "إرسال التحديث",
|
||||
"done": "تم",
|
||||
"errors": {
|
||||
"generic": "تعذّر إرسال التحديث. يُرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"status": {
|
||||
"pending": {
|
||||
"title": "بانتظار المريض",
|
||||
"body": "تم إرسال التحديث. سيصل إلى هاتف المريض الآن، أو في المرة التالية التي يفتح فيها التطبيق."
|
||||
},
|
||||
"delivered": {
|
||||
"title": "تم التسليم إلى الهاتف",
|
||||
"body": "استلم المريض التحديث ويلزمه الموافقة عليه على جهازه."
|
||||
},
|
||||
"approved": {
|
||||
"title": "وافق المريض",
|
||||
"body": "وافق المريض على التحديث، وسجلّه على الجهاز محدَّث الآن."
|
||||
},
|
||||
"denied": {
|
||||
"title": "رفض المريض",
|
||||
"body": "رفض المريض هذا التحديث، لذا بقي سجلّه على الجهاز دون تغيير."
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"appName": "temetro",
|
||||
"email": "البريد الإلكتروني",
|
||||
|
||||
@@ -43,6 +43,53 @@
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"walletUpdatesList": {
|
||||
"title": "An Wallets gesendete Updates",
|
||||
"description": "Aktenupdates, die Sie an Patienten-Wallets gesendet haben, und ob der Patient sie bestätigt hat.",
|
||||
"none": "Sie haben noch keine Wallet-Updates gesendet."
|
||||
},
|
||||
"walletPush": {
|
||||
"action": "An Wallet senden",
|
||||
"title": "Update an Wallet senden",
|
||||
"subtitle": "Senden Sie {{name}}s aktualisierte Akte an die Wallet-App. Der Patient muss sie auf dem Telefon bestätigen, bevor sie die Kopie auf dem Gerät ersetzt.",
|
||||
"sectionsLabel": "Was sich geändert hat",
|
||||
"sections": {
|
||||
"demographics": "Stammdaten",
|
||||
"problems": "Probleme",
|
||||
"medications": "Medikamente",
|
||||
"allergies": "Allergien",
|
||||
"labs": "Labor",
|
||||
"vitals": "Vitalwerte",
|
||||
"visits": "Besuche"
|
||||
},
|
||||
"noteLabel": "Notiz (optional)",
|
||||
"notePlaceholder": "Eine kurze Notiz zu diesem Update…",
|
||||
"notice": "Dies sendet die vollständige aktuelle Akte, signiert mit Ihrem Klinikschlüssel. Sie ersetzt die Kopie auf dem Gerät des Patienten erst nach dessen Zustimmung.",
|
||||
"cancel": "Abbrechen",
|
||||
"send": "Update senden",
|
||||
"done": "Fertig",
|
||||
"errors": {
|
||||
"generic": "Update konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"status": {
|
||||
"pending": {
|
||||
"title": "Warten auf den Patienten",
|
||||
"body": "Das Update wurde gesendet. Es erreicht das Telefon des Patienten jetzt oder beim nächsten Öffnen der App."
|
||||
},
|
||||
"delivered": {
|
||||
"title": "An das Telefon zugestellt",
|
||||
"body": "Der Patient hat das Update erhalten und muss es auf seinem Gerät bestätigen."
|
||||
},
|
||||
"approved": {
|
||||
"title": "Vom Patienten bestätigt",
|
||||
"body": "Der Patient hat das Update bestätigt; seine Akte auf dem Gerät ist nun aktuell."
|
||||
},
|
||||
"denied": {
|
||||
"title": "Vom Patienten abgelehnt",
|
||||
"body": "Der Patient hat dieses Update abgelehnt; seine Akte auf dem Gerät bleibt unverändert."
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"appName": "temetro",
|
||||
"email": "E-Mail",
|
||||
|
||||
@@ -43,6 +43,53 @@
|
||||
"generic": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"walletUpdatesList": {
|
||||
"title": "Updates sent to wallets",
|
||||
"description": "Record updates you've pushed to patient wallets, and whether the patient has approved them.",
|
||||
"none": "You haven't pushed any wallet updates yet."
|
||||
},
|
||||
"walletPush": {
|
||||
"action": "Push to wallet",
|
||||
"title": "Push update to wallet",
|
||||
"subtitle": "Send {{name}}'s updated record to their wallet app. They must approve it on their phone before it replaces the copy on their device.",
|
||||
"sectionsLabel": "What changed",
|
||||
"sections": {
|
||||
"demographics": "Demographics",
|
||||
"problems": "Problems",
|
||||
"medications": "Medications",
|
||||
"allergies": "Allergies",
|
||||
"labs": "Labs",
|
||||
"vitals": "Vitals",
|
||||
"visits": "Visits"
|
||||
},
|
||||
"noteLabel": "Note (optional)",
|
||||
"notePlaceholder": "A short note about this update…",
|
||||
"notice": "This sends the full current record, signed with your clinic key. It replaces the patient's on-device copy only after they approve it.",
|
||||
"cancel": "Cancel",
|
||||
"send": "Send update",
|
||||
"done": "Done",
|
||||
"errors": {
|
||||
"generic": "Couldn't send the update. Please try again."
|
||||
},
|
||||
"status": {
|
||||
"pending": {
|
||||
"title": "Waiting for the patient",
|
||||
"body": "The update was sent. It will arrive on the patient's phone now, or the next time they open the app."
|
||||
},
|
||||
"delivered": {
|
||||
"title": "Delivered to the phone",
|
||||
"body": "The patient has received the update and needs to approve it on their device."
|
||||
},
|
||||
"approved": {
|
||||
"title": "Approved by the patient",
|
||||
"body": "The patient approved the update and their on-device record is now up to date."
|
||||
},
|
||||
"denied": {
|
||||
"title": "Declined by the patient",
|
||||
"body": "The patient declined this update, so their on-device record is unchanged."
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"appName": "temetro",
|
||||
"email": "Email",
|
||||
|
||||
@@ -43,6 +43,53 @@
|
||||
"generic": "Une erreur s'est produite. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"walletUpdatesList": {
|
||||
"title": "Mises à jour envoyées aux portefeuilles",
|
||||
"description": "Les mises à jour de dossiers que vous avez envoyées aux portefeuilles des patients, et si le patient les a approuvées.",
|
||||
"none": "Vous n'avez encore envoyé aucune mise à jour de portefeuille."
|
||||
},
|
||||
"walletPush": {
|
||||
"action": "Envoyer au portefeuille",
|
||||
"title": "Envoyer la mise à jour au portefeuille",
|
||||
"subtitle": "Envoyez le dossier mis à jour de {{name}} vers son application portefeuille. Le patient doit l'approuver sur son téléphone avant qu'elle ne remplace la copie sur son appareil.",
|
||||
"sectionsLabel": "Ce qui a changé",
|
||||
"sections": {
|
||||
"demographics": "Données démographiques",
|
||||
"problems": "Problèmes",
|
||||
"medications": "Médicaments",
|
||||
"allergies": "Allergies",
|
||||
"labs": "Analyses",
|
||||
"vitals": "Signes vitaux",
|
||||
"visits": "Visites"
|
||||
},
|
||||
"noteLabel": "Note (facultatif)",
|
||||
"notePlaceholder": "Une courte note sur cette mise à jour…",
|
||||
"notice": "Ceci envoie le dossier actuel complet, signé avec la clé de votre clinique. Il ne remplace la copie sur l'appareil du patient qu'après son approbation.",
|
||||
"cancel": "Annuler",
|
||||
"send": "Envoyer la mise à jour",
|
||||
"done": "Terminé",
|
||||
"errors": {
|
||||
"generic": "Impossible d'envoyer la mise à jour. Veuillez réessayer."
|
||||
},
|
||||
"status": {
|
||||
"pending": {
|
||||
"title": "En attente du patient",
|
||||
"body": "La mise à jour a été envoyée. Elle arrivera sur le téléphone du patient maintenant, ou à la prochaine ouverture de l'application."
|
||||
},
|
||||
"delivered": {
|
||||
"title": "Reçue sur le téléphone",
|
||||
"body": "Le patient a reçu la mise à jour et doit l'approuver sur son appareil."
|
||||
},
|
||||
"approved": {
|
||||
"title": "Approuvée par le patient",
|
||||
"body": "Le patient a approuvé la mise à jour ; son dossier sur l'appareil est maintenant à jour."
|
||||
},
|
||||
"denied": {
|
||||
"title": "Refusée par le patient",
|
||||
"body": "Le patient a refusé cette mise à jour ; son dossier sur l'appareil est inchangé."
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"appName": "temetro",
|
||||
"email": "E-mail",
|
||||
|
||||
@@ -43,6 +43,53 @@
|
||||
"generic": "Wax baa qaldamay. Fadlan isku day mar kale."
|
||||
}
|
||||
},
|
||||
"walletUpdatesList": {
|
||||
"title": "Cusboonaysiimaha loo diray walletyada",
|
||||
"description": "Cusboonaysiimaha diiwaanka ee aad u dirtay walletyada bukaannada, iyo haddii bukaanku ansixiyay.",
|
||||
"none": "Weli ma aadan dirin wax cusboonaysiin walletka ah."
|
||||
},
|
||||
"walletPush": {
|
||||
"action": "U dir walletka",
|
||||
"title": "U dir cusboonaysiinta walletka",
|
||||
"subtitle": "U dir diiwaanka la cusboonaysiiyay ee {{name}} abka walletkooda. Waa inay taleefankooda ku ansixiyaan ka hor inta uusan bedelin nuqulka qalabkooda ku jira.",
|
||||
"sectionsLabel": "Waxa isbedelay",
|
||||
"sections": {
|
||||
"demographics": "Xogta shakhsi",
|
||||
"problems": "Dhibaatooyinka",
|
||||
"medications": "Daawooyinka",
|
||||
"allergies": "Xasaasiyadda",
|
||||
"labs": "Baaritaannada",
|
||||
"vitals": "Calaamadaha muhiimka ah",
|
||||
"visits": "Booqashooyinka"
|
||||
},
|
||||
"noteLabel": "Qoraal (ikhtiyaari)",
|
||||
"notePlaceholder": "Qoraal gaaban oo ku saabsan cusboonaysiintan…",
|
||||
"notice": "Tan waxay dirtaa diiwaanka hadda oo dhan, oo lagu saxeexay furaha rugtaada. Waxay bedeshaa nuqulka qalabka bukaanka oo keliya ka dib marka ay ansixiyaan.",
|
||||
"cancel": "Jooji",
|
||||
"send": "Dir cusboonaysiinta",
|
||||
"done": "Dhammaystiran",
|
||||
"errors": {
|
||||
"generic": "Lama diri karin cusboonaysiinta. Fadlan isku day mar kale."
|
||||
},
|
||||
"status": {
|
||||
"pending": {
|
||||
"title": "Sugaya bukaanka",
|
||||
"body": "Cusboonaysiintii waa la diray. Waxay ku iman doontaa taleefanka bukaanka hadda, ama marka xigta ee ay furaan abka."
|
||||
},
|
||||
"delivered": {
|
||||
"title": "La gaarsiiyay taleefanka",
|
||||
"body": "Bukaanku wuu helay cusboonaysiinta wuxuuna u baahan yahay inuu ku ansixiyo qalabkiisa."
|
||||
},
|
||||
"approved": {
|
||||
"title": "Bukaanku wuu ansixiyay",
|
||||
"body": "Bukaanku wuu ansixiyay cusboonaysiinta, diiwaanka qalabkooduna hadda waa cusub yahay."
|
||||
},
|
||||
"denied": {
|
||||
"title": "Bukaanku wuu diiday",
|
||||
"body": "Bukaanku wuu diiday cusboonaysiintan, sidaas darteed diiwaanka qalabkoodu isma bedelin."
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"appName": "temetro",
|
||||
"email": "Iimayl",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Client for the clinic→wallet record-update push. When a clinician edits a
|
||||
// wallet-linked patient, they can push the updated record to the patient's app;
|
||||
// it stays pending until the patient approves it on their phone.
|
||||
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
export type WalletUpdateStatus = "pending" | "delivered" | "approved" | "denied";
|
||||
|
||||
export type WalletUpdate = {
|
||||
id: string;
|
||||
fileNumber: string;
|
||||
walletNumber: string;
|
||||
status: WalletUpdateStatus;
|
||||
changes: string[];
|
||||
createdAt: string;
|
||||
deliveredAt: string | null;
|
||||
resolvedAt: string | null;
|
||||
};
|
||||
|
||||
// Resolve the wallet a patient is linked to. Rejects (404) when not wallet-backed.
|
||||
export function getWalletLink(
|
||||
fileNumber: string,
|
||||
): Promise<{ walletNumber: string }> {
|
||||
return apiFetch<{ walletNumber: string }>(
|
||||
`/api/patients/wallet/link/${encodeURIComponent(fileNumber)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function pushWalletUpdate(input: {
|
||||
fileNumber: string;
|
||||
changes: string[];
|
||||
}): Promise<WalletUpdate> {
|
||||
return apiFetch<WalletUpdate>("/api/patients/wallet/push", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function listWalletUpdates(): Promise<WalletUpdate[]> {
|
||||
return apiFetch<WalletUpdate[]>("/api/patients/wallet/updates");
|
||||
}
|
||||
|
||||
export function getWalletUpdate(id: string): Promise<WalletUpdate> {
|
||||
return apiFetch<WalletUpdate>(
|
||||
`/api/patients/wallet/updates/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user