mirror of
https://github.com/temetro/temetro.git
synced 2026-09-01 05:18:57 +00:00
portal: public Patient Portal kiosk (self-service booking + results)
New chrome-less (portal) route group with /portal/[clinic], a card-style choice of "Book an appointment" vs "View my results", and step flows wired to a new public backend router (src/routes/portal.ts): - GET /api/portal/:clinic -> clinic name - POST /api/portal/:clinic/appointments (verifies name+file#, books a confirmed appointment that appears on the doctor's Appointments page) - GET /api/portal/:clinic/results (minimal, safe status — no clinical values) Excluded /portal from the auth proxy redirect so the kiosk loads without a session. PHI exposure is intentionally minimal (see docs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1791,5 +1791,47 @@
|
||||
"saveFailedTitle": "Could not save",
|
||||
"saveFailedBody": "Saving your AI settings failed. Please try again."
|
||||
}
|
||||
},
|
||||
"portal": {
|
||||
"kicker": "Patient Portal",
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"notFoundTitle": "Clinic not found",
|
||||
"notFoundBody": "This portal link isn't valid. Please ask the front desk for help.",
|
||||
"choose": {
|
||||
"bookTitle": "Book an appointment",
|
||||
"bookDesc": "Schedule a visit with your care team.",
|
||||
"resultsTitle": "View my results",
|
||||
"resultsDesc": "Check upcoming visits and whether results are ready."
|
||||
},
|
||||
"field": {
|
||||
"name": "Full name",
|
||||
"fileNumber": "File number",
|
||||
"date": "Date",
|
||||
"time": "Time",
|
||||
"reason": "Reason for visit (optional)",
|
||||
"reasonPlaceholder": "e.g. Follow-up, check-up"
|
||||
},
|
||||
"book": {
|
||||
"title": "Book an appointment",
|
||||
"submit": "Request appointment",
|
||||
"successTitle": "You're booked",
|
||||
"successBody": "Your appointment is set for {{date}} at {{time}}. Please check in at the front desk.",
|
||||
"errorGeneric": "Couldn't book the appointment. Please try again or ask the front desk."
|
||||
},
|
||||
"results": {
|
||||
"title": "View my results",
|
||||
"subtitle": "Enter your name and file number to continue.",
|
||||
"submit": "Look up",
|
||||
"greeting": "Hi {{name}}",
|
||||
"upcoming": "Upcoming appointments",
|
||||
"noUpcoming": "No upcoming appointments.",
|
||||
"resultsLabel": "Results",
|
||||
"ready": "{{count}} result(s) are on file.",
|
||||
"none": "No results are on file yet.",
|
||||
"askStaff": "Please ask a staff member to review your results with you.",
|
||||
"lookupAnother": "Look up another",
|
||||
"errorGeneric": "Couldn't find your record. Please check your details or ask the front desk."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Client for the public Patient Portal kiosk API (backend src/routes/portal.ts).
|
||||
// Unlike lib/api-client, these calls are unauthenticated (no session cookie) and
|
||||
// must NOT bounce to /login on error — the kiosk has no login.
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api-client";
|
||||
|
||||
export type PortalClinic = { name: string };
|
||||
|
||||
export type PortalBooking = {
|
||||
fileNumber: string;
|
||||
name: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
time: string; // HH:mm
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type PortalBookingResult = {
|
||||
date: string;
|
||||
time: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export type PortalResults = {
|
||||
name: string;
|
||||
upcoming: {
|
||||
date: string;
|
||||
time: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
}[];
|
||||
hasResults: boolean;
|
||||
resultCount: number;
|
||||
};
|
||||
|
||||
export class PortalError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PortalError";
|
||||
}
|
||||
}
|
||||
|
||||
async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/api/portal${path}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| (T & { error?: string })
|
||||
| null;
|
||||
if (!res.ok) {
|
||||
throw new PortalError(
|
||||
res.status,
|
||||
body?.error ?? `Request failed (${res.status}).`,
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function getPortalClinic(clinic: string): Promise<PortalClinic> {
|
||||
return portalFetch<PortalClinic>(`/${encodeURIComponent(clinic)}`);
|
||||
}
|
||||
|
||||
export function bookPortalAppointment(
|
||||
clinic: string,
|
||||
booking: PortalBooking,
|
||||
): Promise<PortalBookingResult> {
|
||||
return portalFetch<PortalBookingResult>(
|
||||
`/${encodeURIComponent(clinic)}/appointments`,
|
||||
{ method: "POST", body: JSON.stringify(booking) },
|
||||
);
|
||||
}
|
||||
|
||||
export function lookupPortalResults(
|
||||
clinic: string,
|
||||
fileNumber: string,
|
||||
name: string,
|
||||
): Promise<PortalResults> {
|
||||
const qs = new URLSearchParams({ fileNumber, name }).toString();
|
||||
return portalFetch<PortalResults>(
|
||||
`/${encodeURIComponent(clinic)}/results?${qs}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user