mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +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:
@@ -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)`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 <main className="min-h-dvh w-full overflow-y-auto bg-background">{children}</main>;
|
||||
}
|
||||
@@ -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 <PortalKiosk clinic={clinic ?? ""} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<label className="flex flex-col gap-1.5 text-left">
|
||||
<span className="font-medium text-foreground text-sm">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalKiosk({ clinic }: { clinic: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [clinicName, setClinicName] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [step, setStep] = useState<Step>("choose");
|
||||
|
||||
useEffect(() => {
|
||||
getPortalClinic(clinic)
|
||||
.then((c) => setClinicName(c.name))
|
||||
.catch(() => setNotFound(true));
|
||||
}, [clinic]);
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center p-6">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CalendarCheck />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("portal.notFoundTitle")}</EmptyTitle>
|
||||
<EmptyDescription>{t("portal.notFoundBody")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col items-center justify-center gap-8 px-6 py-12">
|
||||
<header className="flex flex-col items-center gap-2 text-center">
|
||||
<span className="font-medium text-muted-foreground text-sm uppercase tracking-wide">
|
||||
{t("portal.kicker")}
|
||||
</span>
|
||||
<h1 className="font-semibold text-3xl tracking-tight sm:text-4xl">
|
||||
{clinicName ?? "…"}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{step === "choose" ? (
|
||||
<ChooseStep onPick={setStep} />
|
||||
) : step === "book" ? (
|
||||
<BookStep clinic={clinic} onBack={() => setStep("choose")} />
|
||||
) : (
|
||||
<ResultsStep clinic={clinic} onBack={() => setStep("choose")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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: <CalendarPlus className="size-7" />,
|
||||
title: t("portal.choose.bookTitle"),
|
||||
desc: t("portal.choose.bookDesc"),
|
||||
},
|
||||
{
|
||||
step: "results",
|
||||
icon: <FlaskConical className="size-7" />,
|
||||
title: t("portal.choose.resultsTitle"),
|
||||
desc: t("portal.choose.resultsDesc"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2">
|
||||
{cards.map((c) => (
|
||||
<button
|
||||
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-left transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
key={c.step}
|
||||
onClick={() => onPick(c.step)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
{c.icon}
|
||||
</span>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-1 font-semibold text-lg text-foreground">
|
||||
{c.title}
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">{c.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackButton({ onBack }: { onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 self-start text-muted-foreground text-sm transition-colors hover:text-foreground"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t("portal.back")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [done, setDone] = useState<PortalBookingResult | null>(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 (
|
||||
<div className="flex w-full flex-col items-center gap-6">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("portal.book.successTitle")}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t("portal.book.successBody", {
|
||||
date: done.date,
|
||||
time: done.time,
|
||||
})}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
<Button onClick={onBack} variant="outline">
|
||||
{t("portal.done")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
|
||||
<BackButton onBack={onBack} />
|
||||
<h2 className="font-semibold text-xl">{t("portal.book.title")}</h2>
|
||||
<Field label={t("portal.field.name")}>
|
||||
<Input onChange={(e) => setName(e.target.value)} required value={name} />
|
||||
</Field>
|
||||
<Field label={t("portal.field.fileNumber")}>
|
||||
<Input
|
||||
onChange={(e) => setFileNumber(e.target.value)}
|
||||
required
|
||||
value={fileNumber}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("portal.field.date")}>
|
||||
<Input
|
||||
min={todayKey()}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
type="date"
|
||||
value={date}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.field.time")}>
|
||||
<Input
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
required
|
||||
type="time"
|
||||
value={time}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("portal.field.reason")}>
|
||||
<Input
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder={t("portal.field.reasonPlaceholder")}
|
||||
value={type}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
<Button disabled={busy} size="lg" type="submit">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{t("portal.book.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [data, setData] = useState<PortalResults | null>(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 (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<BackButton onBack={onBack} />
|
||||
<h2 className="font-semibold text-xl">
|
||||
{t("portal.results.greeting", { name: data.name })}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-2xl border bg-card/40 p-4">
|
||||
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("portal.results.upcoming")}
|
||||
</p>
|
||||
{data.upcoming.length === 0 ? (
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("portal.results.noUpcoming")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{data.upcoming.map((a) => (
|
||||
<li
|
||||
className="flex items-center justify-between gap-3 rounded-xl border bg-card px-3 py-2"
|
||||
key={`${a.date}-${a.time}`}
|
||||
>
|
||||
<span className="font-medium text-foreground text-sm tabular-nums">
|
||||
{a.date} · {a.time}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-sm">
|
||||
{a.type} · {a.provider}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card/40 p-4">
|
||||
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("portal.results.resultsLabel")}
|
||||
</p>
|
||||
<p className="mt-2 text-foreground text-sm">
|
||||
{data.hasResults
|
||||
? t("portal.results.ready", { count: data.resultCount })
|
||||
: t("portal.results.none")}
|
||||
</p>
|
||||
{data.hasResults ? (
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{t("portal.results.askStaff")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setData(null)} variant="outline">
|
||||
{t("portal.results.lookupAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
|
||||
<BackButton onBack={onBack} />
|
||||
<h2 className="font-semibold text-xl">{t("portal.results.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("portal.results.subtitle")}</p>
|
||||
<Field label={t("portal.field.name")}>
|
||||
<Input onChange={(e) => setName(e.target.value)} required value={name} />
|
||||
</Field>
|
||||
<Field label={t("portal.field.fileNumber")}>
|
||||
<Input
|
||||
onChange={(e) => setFileNumber(e.target.value)}
|
||||
required
|
||||
value={fileNumber}
|
||||
/>
|
||||
</Field>
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
<Button disabled={busy} size="lg" type="submit">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{t("portal.results.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user