From 516de6ad60cfb42172f6a32565079f51902b13fe Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 20 Jun 2026 18:49:39 +0300 Subject: [PATCH] portal: public Patient Portal kiosk (self-service booking + results) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/index.ts | 3 + backend/src/routes/portal.ts | 152 +++++++ frontend/app/(portal)/layout.tsx | 9 + .../app/(portal)/portal/[clinic]/page.tsx | 11 + frontend/components/portal/portal-kiosk.tsx | 371 ++++++++++++++++++ frontend/lib/i18n/locales/en/translation.json | 42 ++ frontend/lib/portal.ts | 87 ++++ frontend/proxy.ts | 2 +- 8 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/portal.ts create mode 100644 frontend/app/(portal)/layout.tsx create mode 100644 frontend/app/(portal)/portal/[clinic]/page.tsx create mode 100644 frontend/components/portal/portal-kiosk.tsx create mode 100644 frontend/lib/portal.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 5a26250..12b6c88 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,6 +23,7 @@ import { meetingsRouter } from "./routes/meetings.js"; import { notesRouter } from "./routes/notes.js"; import { notificationsRouter } from "./routes/notifications.js"; import { patientsRouter } from "./routes/patients.js"; +import { portalRouter } from "./routes/portal.js"; import { prescriptionsRouter } from "./routes/prescriptions.js"; import { settingsRouter } from "./routes/settings.js"; import { staffRouter } from "./routes/staff.js"; @@ -84,6 +85,7 @@ app.use("/api/settings", settingsRouter); app.use("/api/ai", aiRouter); app.use("/api/chat", chatRouter); app.use("/api/integrations", integrationsRouter); +app.use("/api/portal", portalRouter); app.use(notFound); app.use(errorHandler); @@ -112,4 +114,5 @@ server.listen(env.PORT, () => { console.log(` • ai: /api/ai (config + import)`); console.log(` • chat: /api/chat (LLM agent)`); console.log(` • integr.: /api/integrations (FHIR / e-Rx / claims)`); + console.log(` • portal: /api/portal (public clinic kiosk)`); }); diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts new file mode 100644 index 0000000..99dffb1 --- /dev/null +++ b/backend/src/routes/portal.ts @@ -0,0 +1,152 @@ +import { eq } from "drizzle-orm"; +import { Router, type Request } from "express"; +import { z } from "zod"; + +import { db } from "../db/index.js"; +import { organization } from "../db/schema/auth.js"; +import { appointmentInputSchema } from "../lib/appointment-validation.js"; +import { HttpError } from "../lib/http-error.js"; +import { initialsFromName } from "../lib/initials.js"; +import { recordActivity } from "../services/activity.js"; +import { createAppointment, listAppointments } from "../services/appointments.js"; +import { getPatient } from "../services/patients.js"; + +// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the +// waiting room). Scoped by the clinic slug in the URL — there is no session. +// +// PHI exposure is deliberately minimal: lookups require BOTH a file number and a +// matching name, and "results" return only appointment status + whether results +// exist, never lab values. A kiosk token / one-time code would be the safer +// long-term design (see docs). +export const portalRouter = Router(); + +async function resolveClinic(req: Request): Promise<{ id: string; name: string }> { + const slug = String(req.params.clinic ?? "").trim(); + if (!slug) throw new HttpError(404, "Clinic not found."); + const [org] = await db + .select({ id: organization.id, name: organization.name }) + .from(organization) + .where(eq(organization.slug, slug)) + .limit(1); + if (!org) throw new HttpError(404, "Clinic not found."); + return org; +} + +const norm = (s: string) => s.trim().toLowerCase(); + +// GET /api/portal/:clinic — clinic name for the kiosk header. +portalRouter.get("/:clinic", async (req, res, next) => { + try { + const clinic = await resolveClinic(req); + res.json({ name: clinic.name }); + } catch (err) { + next(err); + } +}); + +const bookingSchema = z.object({ + fileNumber: z.string().trim().min(1, "A file number is required.").max(64), + name: z.string().trim().min(1, "Your name is required.").max(200), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."), + time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."), + type: z.string().trim().max(120).optional(), +}); + +// POST /api/portal/:clinic/appointments — self-service booking for a registered +// patient. Verifies the file number + name, then creates a confirmed appointment +// that shows up on the clinic's Appointments page. +portalRouter.post("/:clinic/appointments", async (req, res, next) => { + try { + const clinic = await resolveClinic(req); + const body = bookingSchema.parse(req.body); + + const patient = await getPatient(clinic.id, body.fileNumber); + if (!patient || norm(patient.name) !== norm(body.name)) { + throw new HttpError( + 404, + "We couldn't find a record matching that name and file number.", + ); + } + // Don't allow booking in the past. + 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: patient.pcp || "", + status: "confirmed", + source: "manual", + }); + const created = await createAppointment(clinic.id, "", input); + await recordActivity({ + orgId: clinic.id, + actor: { id: "", name: patient.name }, + action: `Patient portal booking — ${patient.name} on ${created.date} ${created.time}`, + entityType: "appointment", + entityId: created.id, + }); + res.status(201).json({ + date: created.date, + time: created.time, + type: created.type, + provider: created.provider, + }); + } catch (err) { + next(err); + } +}); + +const lookupSchema = z.object({ + fileNumber: z.string().trim().min(1).max(64), + name: z.string().trim().min(1).max(200), +}); + +// GET /api/portal/:clinic/results?fileNumber=&name= — minimal status view. +// Returns upcoming appointments and whether results are on file, never the +// underlying clinical values. +portalRouter.get("/:clinic/results", async (req, res, next) => { + try { + const clinic = await resolveClinic(req); + const q = lookupSchema.parse({ + fileNumber: req.query.fileNumber, + name: req.query.name, + }); + const patient = await getPatient(clinic.id, q.fileNumber); + if (!patient || norm(patient.name) !== norm(q.name)) { + throw new HttpError( + 404, + "We couldn't find a record matching that name and file number.", + ); + } + const now = new Date(); + const upcoming = (await listAppointments(clinic.id)) + .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, + })); + res.json({ + name: patient.name, + upcoming, + hasResults: patient.labs.length > 0, + resultCount: patient.labs.length, + }); + } catch (err) { + next(err); + } +}); diff --git a/frontend/app/(portal)/layout.tsx b/frontend/app/(portal)/layout.tsx new file mode 100644 index 0000000..ae1b1ca --- /dev/null +++ b/frontend/app/(portal)/layout.tsx @@ -0,0 +1,9 @@ +// The Patient Portal kiosk runs with no app chrome — no sidebar, no auth guard. +// It's a standalone full-screen surface for a clinic iPad / self-service device. +export default function PortalLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/frontend/app/(portal)/portal/[clinic]/page.tsx b/frontend/app/(portal)/portal/[clinic]/page.tsx new file mode 100644 index 0000000..4ba18c6 --- /dev/null +++ b/frontend/app/(portal)/portal/[clinic]/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import { PortalKiosk } from "@/components/portal/portal-kiosk"; + +export default function PatientPortalPage() { + const params = useParams<{ clinic: string }>(); + const clinic = Array.isArray(params.clinic) ? params.clinic[0] : params.clinic; + return ; +} diff --git a/frontend/components/portal/portal-kiosk.tsx b/frontend/components/portal/portal-kiosk.tsx new file mode 100644 index 0000000..b08ef01 --- /dev/null +++ b/frontend/components/portal/portal-kiosk.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { + ArrowLeft, + CalendarCheck, + CalendarPlus, + CheckCircle2, + ChevronRight, + FlaskConical, + Loader2, +} from "lucide-react"; +import { type FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Input } from "@/components/ui/input"; +import { + bookPortalAppointment, + getPortalClinic, + lookupPortalResults, + type PortalBookingResult, + type PortalResults, +} from "@/lib/portal"; + +type Step = "choose" | "book" | "results"; + +const todayKey = () => new Date().toISOString().slice(0, 10); + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function PortalKiosk({ clinic }: { clinic: string }) { + const { t } = useTranslation(); + const [clinicName, setClinicName] = useState(null); + const [notFound, setNotFound] = useState(false); + const [step, setStep] = useState("choose"); + + useEffect(() => { + getPortalClinic(clinic) + .then((c) => setClinicName(c.name)) + .catch(() => setNotFound(true)); + }, [clinic]); + + if (notFound) { + return ( +
+ + + + + + {t("portal.notFoundTitle")} + {t("portal.notFoundBody")} + + +
+ ); + } + + return ( +
+
+ + {t("portal.kicker")} + +

+ {clinicName ?? "…"} +

+
+ + {step === "choose" ? ( + + ) : step === "book" ? ( + setStep("choose")} /> + ) : ( + setStep("choose")} /> + )} +
+ ); +} + +// Card-style radio: two large, touch-friendly choices. +function ChooseStep({ onPick }: { onPick: (step: Step) => void }) { + const { t } = useTranslation(); + const cards: { step: Step; icon: React.ReactNode; title: string; desc: string }[] = + [ + { + step: "book", + icon: , + title: t("portal.choose.bookTitle"), + desc: t("portal.choose.bookDesc"), + }, + { + step: "results", + icon: , + title: t("portal.choose.resultsTitle"), + desc: t("portal.choose.resultsDesc"), + }, + ]; + return ( +
+ {cards.map((c) => ( + + ))} +
+ ); +} + +function BackButton({ onBack }: { onBack: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + +function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [fileNumber, setFileNumber] = useState(""); + const [date, setDate] = useState(""); + const [time, setTime] = useState("09:00"); + const [type, setType] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(null); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + const result = await bookPortalAppointment(clinic, { + name: name.trim(), + fileNumber: fileNumber.trim(), + date, + time, + type: type.trim() || undefined, + }); + setDone(result); + } catch (err) { + setError( + err instanceof Error ? err.message : t("portal.book.errorGeneric"), + ); + } finally { + setBusy(false); + } + }; + + if (done) { + return ( +
+ + + + + + {t("portal.book.successTitle")} + + {t("portal.book.successBody", { + date: done.date, + time: done.time, + })} + + + + +
+ ); + } + + return ( +
+ +

{t("portal.book.title")}

+ + setName(e.target.value)} required value={name} /> + + + setFileNumber(e.target.value)} + required + value={fileNumber} + /> + +
+ + setDate(e.target.value)} + required + type="date" + value={date} + /> + + + setTime(e.target.value)} + required + type="time" + value={time} + /> + +
+ + setType(e.target.value)} + placeholder={t("portal.field.reasonPlaceholder")} + value={type} + /> + + {error ?

{error}

: null} + + + ); +} + +function ResultsStep({ clinic, onBack }: { clinic: string; onBack: () => void }) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [fileNumber, setFileNumber] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + setData( + await lookupPortalResults(clinic, fileNumber.trim(), name.trim()), + ); + } catch (err) { + setError( + err instanceof Error ? err.message : t("portal.results.errorGeneric"), + ); + } finally { + setBusy(false); + } + }; + + if (data) { + return ( +
+ +

+ {t("portal.results.greeting", { name: data.name })} +

+ +
+

+ {t("portal.results.upcoming")} +

+ {data.upcoming.length === 0 ? ( +

+ {t("portal.results.noUpcoming")} +

+ ) : ( +
    + {data.upcoming.map((a) => ( +
  • + + {a.date} · {a.time} + + + {a.type} · {a.provider} + +
  • + ))} +
+ )} +
+ +
+

+ {t("portal.results.resultsLabel")} +

+

+ {data.hasResults + ? t("portal.results.ready", { count: data.resultCount }) + : t("portal.results.none")} +

+ {data.hasResults ? ( +

+ {t("portal.results.askStaff")} +

+ ) : null} +
+ + +
+ ); + } + + return ( +
+ +

{t("portal.results.title")}

+

{t("portal.results.subtitle")}

+ + setName(e.target.value)} required value={name} /> + + + setFileNumber(e.target.value)} + required + value={fileNumber} + /> + + {error ?

{error}

: null} + + + ); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index bbd6db4..08d0ee5 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -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." + } } } diff --git a/frontend/lib/portal.ts b/frontend/lib/portal.ts new file mode 100644 index 0000000..318ab2a --- /dev/null +++ b/frontend/lib/portal.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 { + return portalFetch(`/${encodeURIComponent(clinic)}`); +} + +export function bookPortalAppointment( + clinic: string, + booking: PortalBooking, +): Promise { + return portalFetch( + `/${encodeURIComponent(clinic)}/appointments`, + { method: "POST", body: JSON.stringify(booking) }, + ); +} + +export function lookupPortalResults( + clinic: string, + fileNumber: string, + name: string, +): Promise { + const qs = new URLSearchParams({ fileNumber, name }).toString(); + return portalFetch( + `/${encodeURIComponent(clinic)}/results?${qs}`, + ); +} diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 03de428..2c3ef1c 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -28,6 +28,6 @@ export function proxy(request: NextRequest) { export const config = { // Run on all routes except the auth pages, Next internals and static assets. matcher: [ - "/((?!login|signup|verify-email|forgot-password|reset-password|onboarding|accept-invite|_next/static|_next/image|favicon.ico|temetro-logo.png|avatars).*)", + "/((?!login|signup|verify-email|forgot-password|reset-password|onboarding|accept-invite|portal|_next/static|_next/image|favicon.ico|temetro-logo.png|avatars).*)", ], };