mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 11:36:52 +00:00
2d7df1bec0
The portal 'link' action no longer asks the device for a name + file number. The wallet is identified solely by its relay-verified wallet number: the clinic attaches that number to the file ahead of time (Import from a patient app / QR pairing), and linkWallet now just resolves the paired file (friendly 404 when it hasn't been paired yet). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
// Patient Portal actions, shared by the public REST kiosk (routes/portal.ts)
|
|
// and the relay path used by the wallet app (relay-client.ts handles
|
|
// `portal:request` and dispatches here). Both go through the same clinic-scoped
|
|
// logic so a booking made in the phone shows up on the clinic's Appointments
|
|
// page exactly like a kiosk booking.
|
|
//
|
|
// The relay path identifies the patient by their *verified* wallet number (the
|
|
// relay only forwards a request after the device signed the relay challenge),
|
|
// which is more trustworthy than the kiosk's name + file-number check.
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
|
|
import { db } from "../db/index.js";
|
|
import { member, organization, user } from "../db/schema/auth.js";
|
|
import { patients } from "../db/schema/patients.js";
|
|
import { staffProfile } from "../db/schema/staff-profile.js";
|
|
import { appointmentInputSchema } from "../lib/appointment-validation.js";
|
|
import { HttpError } from "../lib/http-error.js";
|
|
import { initialsFromName } from "../lib/initials.js";
|
|
import { recordActivity } from "./activity.js";
|
|
import { createAppointment, listAppointments } from "./appointments.js";
|
|
import {
|
|
absolutePath,
|
|
getAttachmentRow,
|
|
listAttachments,
|
|
} from "./attachments.js";
|
|
import { getPatient } from "./patients.js";
|
|
|
|
// Clinical-capable roles that can be a patient's provider (mirrors portal.ts).
|
|
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
|
|
|
|
export type PortalDoctor = { name: string; specialty: string | null };
|
|
|
|
export async function getClinicInfo(orgId: string): Promise<{ name: string }> {
|
|
const [org] = await db
|
|
.select({ name: organization.name })
|
|
.from(organization)
|
|
.where(eq(organization.id, orgId))
|
|
.limit(1);
|
|
if (!org) throw new HttpError(404, "Clinic not found.");
|
|
return { name: org.name };
|
|
}
|
|
|
|
export async function listDoctors(orgId: string): Promise<PortalDoctor[]> {
|
|
const rows = await db
|
|
.select({ name: user.name, specialty: staffProfile.specialty })
|
|
.from(member)
|
|
.innerJoin(user, eq(user.id, member.userId))
|
|
.leftJoin(
|
|
staffProfile,
|
|
and(
|
|
eq(staffProfile.userId, member.userId),
|
|
eq(staffProfile.organizationId, member.organizationId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(member.organizationId, orgId),
|
|
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
|
|
),
|
|
)
|
|
.orderBy(asc(user.name));
|
|
return rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null }));
|
|
}
|
|
|
|
// Taken time slots for a provider on a day, so the client renders only free
|
|
// ones. Mirrors routes/portal.ts (an empty-provider appointment blocks the slot
|
|
// clinic-wide). Booking re-checks server-side.
|
|
export async function getAvailability(
|
|
orgId: string,
|
|
provider: string,
|
|
date: string,
|
|
): Promise<{ date: string; provider: string; taken: string[] }> {
|
|
const taken = (await listAppointments(orgId))
|
|
.filter(
|
|
(a) =>
|
|
a.status !== "cancelled" &&
|
|
a.date === date &&
|
|
(!provider || !a.provider || a.provider === provider),
|
|
)
|
|
.map((a) => a.time);
|
|
return { date, provider, taken: [...new Set(taken)].sort() };
|
|
}
|
|
|
|
// --- wallet linkage ---------------------------------------------------------
|
|
|
|
// Confirm the wallet link for a device. The wallet is identified purely by its
|
|
// relay-verified wallet number — the clinic attaches that number to the patient
|
|
// file ahead of time (via "Import from a patient app" / QR pairing, which sets
|
|
// `patients.walletNumber`), so the device never types a name or file number.
|
|
// Resolves the linked file, or a friendly 404 when the clinic hasn't paired yet.
|
|
export async function linkWallet(
|
|
orgId: string,
|
|
walletNumber: string,
|
|
): Promise<{ fileNumber: string; name: string }> {
|
|
if (!walletNumber) {
|
|
throw new HttpError(400, "Missing wallet identity.");
|
|
}
|
|
const fileNumber = await fileNumberForWallet(orgId, walletNumber);
|
|
if (!fileNumber) {
|
|
throw new HttpError(
|
|
404,
|
|
"This wallet isn't paired with a record at this clinic yet. Ask the front desk to add your wallet number, then try again.",
|
|
);
|
|
}
|
|
const patient = await getPatient(orgId, fileNumber);
|
|
if (!patient) throw new HttpError(404, "Linked record not found.");
|
|
await recordActivity({
|
|
orgId,
|
|
actor: { id: "", name: patient.name },
|
|
action: `Patient portal — ${patient.name} confirmed their wallet link`,
|
|
entityType: "patient",
|
|
entityId: patient.fileNumber,
|
|
});
|
|
return { fileNumber: patient.fileNumber, name: patient.name };
|
|
}
|
|
|
|
// The file number a linked wallet maps to, or null.
|
|
export async function fileNumberForWallet(
|
|
orgId: string,
|
|
walletNumber: string,
|
|
): Promise<string | null> {
|
|
const [row] = await db
|
|
.select({ fileNumber: patients.fileNumber })
|
|
.from(patients)
|
|
.where(
|
|
and(
|
|
eq(patients.organizationId, orgId),
|
|
eq(patients.walletNumber, walletNumber),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return row?.fileNumber ?? null;
|
|
}
|
|
|
|
async function requireLinkedPatient(orgId: string, walletNumber: string) {
|
|
const fileNumber = await fileNumberForWallet(orgId, walletNumber);
|
|
if (!fileNumber) {
|
|
throw new HttpError(403, "This wallet isn't linked to a record at this clinic.");
|
|
}
|
|
const patient = await getPatient(orgId, fileNumber);
|
|
if (!patient) throw new HttpError(404, "Linked record not found.");
|
|
return patient;
|
|
}
|
|
|
|
// Book an appointment for the linked wallet (conflict-checked), attributed to
|
|
// the patient's file so it appears on the clinic's Appointments page.
|
|
export async function bookForWallet(
|
|
orgId: string,
|
|
walletNumber: string,
|
|
body: { date: string; time: string; type?: string; provider?: string },
|
|
): Promise<{ date: string; time: string; type: string; provider: string }> {
|
|
const patient = await requireLinkedPatient(orgId, walletNumber);
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
if (body.date < today) throw new HttpError(400, "Please pick a future date.");
|
|
|
|
const input = appointmentInputSchema.parse({
|
|
fileNumber: patient.fileNumber,
|
|
name: patient.name,
|
|
initials: patient.initials || initialsFromName(patient.name),
|
|
date: body.date,
|
|
time: body.time,
|
|
type: body.type || "Self-service booking",
|
|
provider: body.provider || patient.pcp || "",
|
|
status: "confirmed",
|
|
source: "manual",
|
|
});
|
|
|
|
const taken = (await listAppointments(orgId)).some(
|
|
(a) =>
|
|
a.status !== "cancelled" &&
|
|
a.date === input.date &&
|
|
a.time === input.time &&
|
|
(!input.provider || !a.provider || a.provider === input.provider),
|
|
);
|
|
if (taken) {
|
|
throw new HttpError(409, "That time slot is already taken. Please choose another time.");
|
|
}
|
|
|
|
const created = await createAppointment(orgId, "", input);
|
|
await recordActivity({
|
|
orgId,
|
|
actor: { id: "", name: patient.name },
|
|
action: `Patient portal booking — ${patient.name} on ${created.date} ${created.time}`,
|
|
entityType: "appointment",
|
|
entityId: created.id,
|
|
});
|
|
return {
|
|
date: created.date,
|
|
time: created.time,
|
|
type: created.type,
|
|
provider: created.provider,
|
|
};
|
|
}
|
|
|
|
// Results view for the linked wallet: upcoming appointments + downloadable lab
|
|
// files (metadata only; bytes come from `getResultFile`).
|
|
export async function resultsForWallet(
|
|
orgId: string,
|
|
walletNumber: string,
|
|
): Promise<{
|
|
name: string;
|
|
upcoming: { date: string; time: string; type: string; provider: string; status: string }[];
|
|
files: { id: string; filename: string; mimeType: string; sizeBytes: number; labKey: string | null }[];
|
|
}> {
|
|
const patient = await requireLinkedPatient(orgId, walletNumber);
|
|
const now = new Date();
|
|
const upcoming = (await listAppointments(orgId))
|
|
.filter(
|
|
(a) =>
|
|
a.fileNumber === patient.fileNumber &&
|
|
a.status !== "cancelled" &&
|
|
new Date(`${a.date}T${a.time}`) >= now,
|
|
)
|
|
.map((a) => ({
|
|
date: a.date,
|
|
time: a.time,
|
|
type: a.type,
|
|
provider: a.provider,
|
|
status: a.status,
|
|
}));
|
|
const files = (await listAttachments(orgId, patient.fileNumber)).map((f) => ({
|
|
id: f.id,
|
|
filename: f.filename,
|
|
mimeType: f.mimeType,
|
|
sizeBytes: f.sizeBytes,
|
|
labKey: f.labKey,
|
|
}));
|
|
return { name: patient.name, upcoming, files };
|
|
}
|
|
|
|
// A single lab/result file for the linked wallet, base64-encoded so it can ride
|
|
// back over the relay. Verifies the file belongs to the patient's own record.
|
|
export async function resultFileForWallet(
|
|
orgId: string,
|
|
walletNumber: string,
|
|
attachmentId: string,
|
|
): Promise<{ filename: string; mimeType: string; base64: string }> {
|
|
const patient = await requireLinkedPatient(orgId, walletNumber);
|
|
const row = await getAttachmentRow(orgId, attachmentId);
|
|
if (!row || row.fileNumber !== patient.fileNumber) {
|
|
throw new HttpError(404, "File not found.");
|
|
}
|
|
const bytes = await readFile(absolutePath(row.storagePath));
|
|
return {
|
|
filename: row.filename,
|
|
mimeType: row.mimeType,
|
|
base64: bytes.toString("base64"),
|
|
};
|
|
}
|
|
|
|
// --- relay dispatch ---------------------------------------------------------
|
|
|
|
type PortalPayload = Record<string, unknown>;
|
|
|
|
// Dispatch a `portal:request` relayed from a wallet device. `walletNumber` is
|
|
// the device's relay-verified wallet number (empty for the public reads).
|
|
export async function handlePortalRequest(
|
|
orgId: string,
|
|
req: { action: string; payload: PortalPayload; walletNumber: string },
|
|
): Promise<unknown> {
|
|
const { action, payload, walletNumber } = req;
|
|
const s = (k: string): string => String(payload[k] ?? "");
|
|
switch (action) {
|
|
case "clinic":
|
|
return getClinicInfo(orgId);
|
|
case "doctors":
|
|
return listDoctors(orgId);
|
|
case "availability":
|
|
return getAvailability(orgId, s("provider"), s("date"));
|
|
case "link":
|
|
return linkWallet(orgId, walletNumber);
|
|
case "book":
|
|
return bookForWallet(orgId, walletNumber, {
|
|
date: s("date"),
|
|
time: s("time"),
|
|
type: s("type") || undefined,
|
|
provider: s("provider") || undefined,
|
|
});
|
|
case "results":
|
|
return resultsForWallet(orgId, walletNumber);
|
|
case "result-file":
|
|
return resultFileForWallet(orgId, walletNumber, s("id"));
|
|
default:
|
|
throw new HttpError(400, `Unknown portal action: ${action}`);
|
|
}
|
|
}
|