mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
feat: multi-clinic Temetro Network with per-clinic identity (v0.8.0)
The relay is now multi-clinic. Each clinic authenticates to the /hub namespace by signing a challenge with its own Ed25519 clinic signing key (a per-clinic identity, not a shared RELAY_TOKEN), and the relay routes every device response back to only the clinic that originated the request (keyed by requestId) — so clinics never see each other's traffic. Backend: - clinic_signing_keys.network_enabled + GET/PUT /api/signing/network (owner/admin) to join/leave the network. - relay-client keeps one authenticated hub connection per network-enabled org (connectOrg/disconnectOrg, hubs map keyed by orgId); emitToWallet/ sendToWallet take orgId; offline flush is org-scoped. - Wallet import/push return 409 until a clinic joins. - RELAY_TOKEN is now optional/legacy (open relay needs no shared secret). Frontend: - "Join Temetro Network" toggle in Settings → Signing, localized in all five languages (en, fr, de, so, ar). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,10 @@ FRONTEND_PORT=3000
|
||||
# The standalone relay (github.com/temetro/temetro-network) that connects this
|
||||
# backend to patient phones. Deploy it (e.g. on Railway) and point both this
|
||||
# backend and the wallet app at it. RELAY_URL is the relay's public URL (also
|
||||
# baked into the QR a patient scans); RELAY_TOKEN is a shared secret this
|
||||
# backend presents on the relay's /hub namespace — set the SAME value here and
|
||||
# on the relay. Generate one with: openssl rand -base64 32
|
||||
# baked into the QR a patient scans). This clinic authenticates to the relay's
|
||||
# /hub with its own Ed25519 signing key, so no shared secret is needed.
|
||||
# RELAY_TOKEN is OPTIONAL/LEGACY — set it only for a private relay that also
|
||||
# gates on a shared token (then use the SAME value here and on the relay).
|
||||
RELAY_URL=http://localhost:8080
|
||||
RELAY_TOKEN=
|
||||
|
||||
|
||||
+11
-6
@@ -61,12 +61,17 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
|
||||
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
|
||||
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
|
||||
- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro
|
||||
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`). This backend connects to it
|
||||
as a `/hub` client in **`src/services/relay-client.ts`**; `emitToWallet` (realtime.ts) delegates to
|
||||
its `sendToWallet`, and device responses (`wallet:share-response` / `wallet:update-response` /
|
||||
`wallet:revoke`) + `wallet:online` replay are handled there, calling the same `wallet-share` /
|
||||
`wallet-updates` services the old `/wallet` namespace did. Configure with `RELAY_URL` +
|
||||
`RELAY_TOKEN`.
|
||||
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`), which is **multi-clinic**.
|
||||
This backend connects to it as a `/hub` client in **`src/services/relay-client.ts`**, keeping **one
|
||||
authenticated connection per network-enabled org** (`hubs` map keyed by `orgId`). Each org
|
||||
authenticates by signing the relay's `hub:challenge` with its clinic signing key
|
||||
(`signWithClinicKey`) — no shared `RELAY_TOKEN` needed (it's now optional/legacy, only for a
|
||||
private relay). `emitToWallet(orgId, …)` (realtime.ts) delegates to `sendToWallet(orgId, …)`, and
|
||||
device responses (`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) +
|
||||
`wallet:online` replay are handled per-org there, calling the same `wallet-share` /
|
||||
`wallet-updates` services the old `/wallet` namespace did. A clinic opts in via **"Join Temetro
|
||||
Network"** (Settings → Signing → `PUT /api/signing/network`, `clinic_signing_keys.network_enabled`);
|
||||
`connectOrg`/`disconnectOrg` open/close its connection, and wallet routes 409 when it's off.
|
||||
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
|
||||
|
||||
## Gotchas / conventions
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "clinic_signing_keys" ADD COLUMN "network_enabled" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,13 @@
|
||||
"when": 1783117115021,
|
||||
"tag": "0031_stiff_gateway",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1783263738631,
|
||||
"tag": "0032_closed_dakota_north",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization } from "./auth.js";
|
||||
|
||||
@@ -16,6 +16,11 @@ export const clinicSigningKeys = pgTable("clinic_signing_keys", {
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
|
||||
privateKeyEnc: text("private_key_enc").notNull(),
|
||||
// Whether this clinic has joined the Temetro Network relay ("Join Temetro
|
||||
// Network" in Settings → Signing). Off by default: only when enabled does the
|
||||
// backend open this clinic's relay hub connection and expose wallet features.
|
||||
// The relay identity *is* this signing key, so the flag lives on the same row.
|
||||
networkEnabled: boolean("network_enabled").notNull().default(false),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
rotatedAt: timestamp("rotated_at"),
|
||||
});
|
||||
|
||||
+7
-7
@@ -31,8 +31,10 @@ const schema = z.object({
|
||||
// Temetro Network relay (github.com/temetro/temetro-network). Both this
|
||||
// backend and patient phones connect to it; it routes the encrypted wallet
|
||||
// messages between them. RELAY_URL is the relay's public URL (also baked into
|
||||
// the QR a patient scans); RELAY_TOKEN is the shared secret this backend
|
||||
// presents on the relay's /hub namespace (must match the relay's RELAY_TOKEN).
|
||||
// the QR a patient scans). Each clinic authenticates to the relay's /hub with
|
||||
// its own Ed25519 signing key, so no shared secret is needed. RELAY_TOKEN is
|
||||
// now *optional/legacy* — set it only for a private relay that also gates on a
|
||||
// shared token (must then match the relay's RELAY_TOKEN).
|
||||
RELAY_URL: z.string().min(1).default("http://localhost:8080"),
|
||||
RELAY_TOKEN: z.string().default(""),
|
||||
// Public, device-reachable URL of this backend's wallet relay, baked into the
|
||||
@@ -87,11 +89,9 @@ if (env.NODE_ENV === "production") {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!env.RELAY_TOKEN) {
|
||||
console.warn(
|
||||
"⚠️ RELAY_TOKEN is unset in production — the Temetro Network /hub connection is unauthenticated. Set a shared secret: openssl rand -base64 32",
|
||||
);
|
||||
}
|
||||
// RELAY_TOKEN is optional now: clinics authenticate to the relay with their
|
||||
// own Ed25519 signing key, so an unset token is the normal "open relay" case —
|
||||
// no warning needed.
|
||||
}
|
||||
|
||||
export const isProd = env.NODE_ENV === "production";
|
||||
|
||||
@@ -46,11 +46,12 @@ export function emitToConversation(
|
||||
// push over the relay's /hub namespace (see services/relay-client.ts). The
|
||||
// relay only ever forwards ciphertext — it cannot read the record bundle.
|
||||
export function emitToWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
sendToWallet(walletNumber, event, data);
|
||||
sendToWallet(orgId, walletNumber, event, data);
|
||||
}
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { emitToWallet } from "../realtime.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as patientService from "../services/patients.js";
|
||||
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
|
||||
import { getNetworkEnabled } from "../services/signing.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
import * as walletUpdates from "../services/wallet-updates.js";
|
||||
|
||||
@@ -26,6 +27,18 @@ export const patientsWalletRouter = Router();
|
||||
|
||||
patientsWalletRouter.use(requireAuth, requireOrg);
|
||||
|
||||
// Wallet sharing rides the Temetro Network relay, which a clinic must opt into
|
||||
// ("Join Temetro Network" in Settings → Signing). Guard the actions that need a
|
||||
// live relay connection so a disabled clinic gets a clear message, not silence.
|
||||
async function requireNetwork(orgId: string): Promise<void> {
|
||||
if (!(await getNetworkEnabled(orgId))) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"This clinic hasn't joined the Temetro Network. Enable it in Settings → Signing to share with patient wallets.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The device-reachable URL the patient's app should connect to (baked into the
|
||||
// QR). Devices connect to the standalone Temetro Network relay — the same relay
|
||||
// this backend is hubbed to — so RELAY_URL is the canonical answer. The legacy
|
||||
@@ -97,6 +110,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = requestSchema.parse(req.body);
|
||||
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
|
||||
req.organizationId!,
|
||||
@@ -109,7 +123,7 @@ patientsWalletRouter.post(
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, req.organizationId!));
|
||||
emitToWallet(input.walletNumber, "wallet:share-request", {
|
||||
emitToWallet(req.organizationId!, input.walletNumber, "wallet:share-request", {
|
||||
requestId: view.id,
|
||||
clinicName: org?.name ?? "A clinic",
|
||||
requestedBy: req.user!.name,
|
||||
@@ -176,6 +190,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = pushSchema.parse(req.body);
|
||||
const row = await walletUpdates.createRecordUpdate(
|
||||
req.organizationId!,
|
||||
@@ -184,7 +199,7 @@ patientsWalletRouter.post(
|
||||
input.changes,
|
||||
);
|
||||
const event = await walletUpdates.toEvent(row);
|
||||
emitToWallet(row.walletNumber, "wallet:update-request", event);
|
||||
emitToWallet(req.organizationId!, row.walletNumber, "wallet:update-request", event);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
requireAuth,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { connectOrg, disconnectOrg } from "../services/relay-client.js";
|
||||
import * as signing from "../services/signing.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
|
||||
@@ -48,6 +50,52 @@ signingRouter.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Whether this clinic has joined the Temetro Network relay. Readable by any
|
||||
// clinician (the panel shows the toggle state + connection status).
|
||||
signingRouter.get(
|
||||
"/network",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json({ enabled: await signing.getNetworkEnabled(req.organizationId!) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Join / leave the Temetro Network — owner/admin only (same gate as key
|
||||
// rotation). Enabling opens this clinic's relay hub connection; disabling tears
|
||||
// it down.
|
||||
const networkSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
signingRouter.put(
|
||||
"/network",
|
||||
requirePermission({ organization: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { enabled } = networkSchema.parse(req.body);
|
||||
await signing.setNetworkEnabled(req.organizationId!, enabled);
|
||||
if (enabled) {
|
||||
await connectOrg(req.organizationId!);
|
||||
} else {
|
||||
disconnectOrg(req.organizationId!);
|
||||
}
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: enabled
|
||||
? "Joined the Temetro Network"
|
||||
: "Left the Temetro Network",
|
||||
entityType: "settings",
|
||||
});
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Recent records shared from patient wallets — feeds the panel's shared-records
|
||||
// list.
|
||||
signingRouter.get(
|
||||
|
||||
@@ -4,58 +4,121 @@
|
||||
//
|
||||
// This backend was previously the device-facing Socket.io server itself (the
|
||||
// `/wallet` namespace in realtime.ts). Now it is a *client* of the relay's
|
||||
// privileged `/hub` namespace: it pushes messages to devices via `sendToWallet`
|
||||
// and handles their responses here, calling the same wallet service functions
|
||||
// the old socket handlers did. Sealed bundles are decrypted here (we hold the
|
||||
// ephemeral key); the relay only ever forwards ciphertext.
|
||||
// `/hub` namespace: it pushes messages to devices via `sendToWallet` and handles
|
||||
// their responses here, calling the same wallet service functions the old socket
|
||||
// handlers did. Sealed bundles are decrypted here (we hold the ephemeral key);
|
||||
// the relay only ever forwards ciphertext.
|
||||
//
|
||||
// The relay is **multi-clinic**: each clinic (organization) authenticates to
|
||||
// `/hub` with its own Ed25519 signing key (a per-clinic identity, not a shared
|
||||
// password), and the relay routes each device response back only to the clinic
|
||||
// that originated the request. So this backend keeps **one hub connection per
|
||||
// network-enabled org**, opened when the org joins the network ("Join Temetro
|
||||
// Network" in Settings → Signing) and torn down when it leaves.
|
||||
|
||||
import { io as connect, type Socket } from "socket.io-client";
|
||||
|
||||
import { env } from "../env.js";
|
||||
import { networkEnabledOrgs, signWithClinicKey } from "./signing.js";
|
||||
import * as walletShare from "./wallet-share.js";
|
||||
import * as walletUpdates from "./wallet-updates.js";
|
||||
|
||||
let hub: Socket | null = null;
|
||||
// One authenticated hub connection per network-enabled organization.
|
||||
const hubs = new Map<string, Socket>();
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
|
||||
// Push an end-to-end-encrypted message to a patient wallet device via the relay
|
||||
// (which forwards it to the room keyed by wallet number). Mirrors the old
|
||||
// in-process `emitToWallet`. A no-op if the relay is not connected yet — the
|
||||
// Push an end-to-end-encrypted message to a patient wallet device via the given
|
||||
// clinic's relay connection (the relay forwards it to the room keyed by wallet
|
||||
// number). A no-op if the clinic isn't on the network / not connected yet — the
|
||||
// device replays anything it missed on its next connect (see `wallet:online`).
|
||||
export function sendToWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
hub?.emit("wallet:send", { walletNumber, event, data });
|
||||
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
|
||||
}
|
||||
|
||||
export function initRelayClient(): void {
|
||||
hub = connect(`${env.RELAY_URL}/hub`, {
|
||||
auth: { token: env.RELAY_TOKEN },
|
||||
// Open (and authenticate) a hub connection for a clinic, if not already open.
|
||||
// Idempotent — safe to call on startup and again when an org joins the network.
|
||||
export async function connectOrg(orgId: string): Promise<void> {
|
||||
if (hubs.has(orgId)) return;
|
||||
|
||||
const hub = connect(`${env.RELAY_URL}/hub`, {
|
||||
transports: ["websocket"],
|
||||
reconnection: true,
|
||||
reconnectionDelayMax: 10_000,
|
||||
});
|
||||
hubs.set(orgId, hub);
|
||||
registerHubHandlers(orgId, hub);
|
||||
}
|
||||
|
||||
hub.on("connect", () => {
|
||||
console.log(`Connected to Temetro Network relay at ${env.RELAY_URL}`);
|
||||
// Leave the network for a clinic: close and forget its hub connection.
|
||||
export function disconnectOrg(orgId: string): void {
|
||||
const hub = hubs.get(orgId);
|
||||
if (!hub) return;
|
||||
hub.disconnect();
|
||||
hubs.delete(orgId);
|
||||
}
|
||||
|
||||
// Open a hub connection for every clinic already on the network. Called once at
|
||||
// startup; runtime joins/leaves go through connectOrg/disconnectOrg.
|
||||
export async function initRelayClient(): Promise<void> {
|
||||
try {
|
||||
const orgs = await networkEnabledOrgs();
|
||||
await Promise.all(orgs.map((orgId) => connectOrg(orgId)));
|
||||
} catch (err) {
|
||||
console.warn(`Temetro Network: failed to open hub connections: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up auth + device-response handlers for one clinic's hub socket.
|
||||
function registerHubHandlers(orgId: string, hub: Socket): void {
|
||||
// Authenticate by signing the relay's challenge with this clinic's signing
|
||||
// key. `clinicId` is that key's public half (hex), which is how the relay
|
||||
// identifies and routes to this clinic.
|
||||
hub.on("hub:challenge", async (payload: { challenge?: string }) => {
|
||||
const challenge = String(payload?.challenge ?? "");
|
||||
if (!challenge) return;
|
||||
try {
|
||||
const { signature, publicKey } = await signWithClinicKey(
|
||||
orgId,
|
||||
new TextEncoder().encode(challenge),
|
||||
);
|
||||
hub.emit(
|
||||
"hub:auth",
|
||||
// `token` is only meaningful for a private relay (optional shared gate);
|
||||
// an empty value is ignored by an open relay.
|
||||
{ clinicId: publicKey, signature, token: env.RELAY_TOKEN || undefined },
|
||||
(ack: { ok?: boolean } | undefined) => {
|
||||
if (ack?.ok) {
|
||||
console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`);
|
||||
} else {
|
||||
console.warn(`Temetro Network: relay rejected clinic ${orgId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Temetro Network: failed to sign relay challenge for ${orgId}: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
hub.on("connect_error", (err) => {
|
||||
console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}): ${err.message}`);
|
||||
console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}) for ${orgId}: ${err.message}`);
|
||||
});
|
||||
|
||||
// A device authenticated on the relay — flush any record updates it missed
|
||||
// while offline (the relay forwards each back to it). Mirrors the replay the
|
||||
// old /wallet namespace did on connect.
|
||||
// while offline (scoped to this clinic; the relay only delivers this to
|
||||
// clinics with pending work for the wallet).
|
||||
hub.on("wallet:online", async (payload: { walletNumber?: string }) => {
|
||||
const walletNumber = String(payload?.walletNumber ?? "");
|
||||
if (!walletNumber) return;
|
||||
try {
|
||||
const rows = await walletUpdates.pendingUpdatesForWallet(walletNumber);
|
||||
const rows = await walletUpdates.pendingUpdatesForWallet(orgId, walletNumber);
|
||||
for (const row of rows) {
|
||||
sendToWallet(walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
||||
sendToWallet(orgId, walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
||||
await walletUpdates.markDelivered(row.id);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -74,6 +74,40 @@ export async function rotateKey(orgId: string): Promise<SigningKeyView> {
|
||||
return mintKey(orgId, true);
|
||||
}
|
||||
|
||||
// Whether this clinic has joined the Temetro Network relay. Defaults to `false`
|
||||
// when the clinic has no signing key yet (it hasn't opted in).
|
||||
export async function getNetworkEnabled(orgId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ networkEnabled: clinicSigningKeys.networkEnabled })
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return row?.networkEnabled ?? false;
|
||||
}
|
||||
|
||||
// Org ids of every clinic currently on the network — used at startup to open a
|
||||
// relay hub connection for each.
|
||||
export async function networkEnabledOrgs(): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ organizationId: clinicSigningKeys.organizationId })
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.networkEnabled, true));
|
||||
return rows.map((r) => r.organizationId);
|
||||
}
|
||||
|
||||
// Join or leave the Temetro Network. Ensures the clinic has a signing key first
|
||||
// (the relay authenticates with it), then flips the flag. Returns the new state.
|
||||
export async function setNetworkEnabled(
|
||||
orgId: string,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
await getOrCreateKey(orgId);
|
||||
await db
|
||||
.update(clinicSigningKeys)
|
||||
.set({ networkEnabled: enabled })
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// Sign a message with the clinic's signing key (creating one if needed). Returns
|
||||
// the signature + public key so a verifier can check provenance.
|
||||
export async function signWithClinicKey(
|
||||
|
||||
@@ -144,7 +144,11 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
|
||||
|
||||
// Every unresolved update for a wallet — re-sent on each authenticated connect
|
||||
// so an offline device eventually receives what it missed.
|
||||
// Pending updates a wallet missed, scoped to one clinic — the relay delivers a
|
||||
// `wallet:online` over that clinic's own hub connection, so a clinic only ever
|
||||
// re-sends its *own* updates (never another clinic's).
|
||||
export async function pendingUpdatesForWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
): Promise<UpdateRow[]> {
|
||||
return db
|
||||
@@ -152,6 +156,7 @@ export async function pendingUpdatesForWallet(
|
||||
.from(walletRecordUpdates)
|
||||
.where(
|
||||
and(
|
||||
eq(walletRecordUpdates.organizationId, orgId),
|
||||
eq(walletRecordUpdates.walletNumber, walletNumber),
|
||||
isNull(walletRecordUpdates.resolvedAt),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user