Patient Portal — Self-Service Health Records
Audiences: clinical-buyer, developer, investor
The patient-facing surface: dashboard, appointments, medical record, secure messaging, documents, and video visits — gated to
PATIENT_ONLYrole and rendered server-first.
Business Purpose
The patient portal is how the clinic earns the patient's repeat trust between visits. Without it, every prescription refill, every lab result, every appointment reschedule is a phone call to the receptionist — expensive for the clinic and friction for the patient. With it, the patient self-serves: views upcoming appointments, downloads a prior lab PDF, books a follow-up, messages their doctor, joins a video visit, and reads their own medical record (allergies, conditions, medications, immunizations, vitals).
It is also a differentiator for the clinical buyer: a clinic that can hand a patient "here is your portal login" closes the loop on continuity of care, which feeds the clinic's quality metrics and patient retention. For investors, the portal is the surface that turns one-visit revenue into a longitudinal relationship.
Audiences
- Investor: shows the clinic-to-patient digital channel — recurring engagement, lower call-center load, and the foundation for any patient-facing growth (vaccinations, screening campaigns, telehealth upsell).
- Clinical buyer (doctor/nurse/receptionist): the receptionist no longer answers
"what time was my appointment?"; the doctor's messaging inbox is searchable on the
patient side; documents the clinic shares appear in the patient's
documents/route. - Developer/partner: explains the route layout under
portal/patient/, thePATIENT_ONLYrole gate, and which routes are Server Components vs Client Components. - Internal (ops/support): which page redirects to dashboard, how the patient nav is generated, and where to look when a self-service feature returns 403.
Architecture
Routes
portal/patient/ ├── layout.tsx → createRoleLayout(PATIENT_CONFIG, PatientNavigation) ├── page.tsx → redirect → /<locale>/portal/patient/dashboard ├── dashboard/ → KPI grid + upcoming appointments + recent docs ├── appointments/ → list + reschedule + cancel ├── visits/ → past visit history ├── messages/, messenger/ → secure clinic messaging ├── documents/ → patient-shared documents (PDF download) ├── medications/ → current Rx list ├── conditions/, allergies/ → problem list, allergies ├── labs/, immunizations/ → results + vaccination record ├── health-data/ → vitals + biometric data ├── insurance/ → coverage + cards ├── emergency-contacts/ → next-of-kin ├── treatment-plan/ → active plan summary ├── assessments/ → patient-completed forms (PROMs) ├── forms/ → fillable forms inbox ├── flowagent/ → conversational intake/follow-up agent ├── assistant/ → patient-facing TUCAN assistant entry ├── video/ → telehealth visit join ├── profile/, settings/ → identity + preferences └── help/ → patient help center
Layout composition
ddx-web/src/app/[locale]/portal/patient/layout.tsx:14 is a one-line factory call:
export default createRoleLayout(PATIENT_CONFIG, PatientNavigation);
PATIENT_CONFIG (ddx-web/src/lib/portal/role-configs.ts:77-87) declares:
roleKey: 'patient', authRequirement: 'PATIENT_ONLY', role badge with HeartIcon
(success variant), and brand name key portals.patient. Unlike doctor/receptionist,
the patient portal does not set needsBackendStatus — patient surfaces don't show
backend-degradation banners by default.
PatientNavigation is a custom navigation component (not the generic
PortalNavigation factory) using the usePatientNavigation hook for grouped sections.
See the layout comment at ddx-web/src/app/[locale]/portal/patient/layout.tsx:8.
Role gate
Every page calls requireRoleAccess('PATIENT_ONLY', locale) near the top of its
Server Component. Example: ddx-web/src/app/[locale]/portal/patient/appointments/page.tsx:20,
ddx-web/src/app/[locale]/portal/patient/dashboard/page.tsx:19. A non-patient session
is redirected to /unauthorized.
Server-first
The pages themselves are Server Components: params is awaited (Next.js 16), data is
fetched via backendFetch/backendGet from @/lib/api/backend-client, and the rich
client surface is delegated to a *Client.tsx component (e.g. AppointmentsListClient,
PatientDashboardClient). No browser-side calls cross over to ddx-api directly — the
Trusted Gateway pattern via the BFF is mandatory.
Tech Stack & Choices
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js ^16.1.6 App Router, React ^19.2.4 | Server-first; awaited params; suspense for streaming. |
| RBAC gate | requireRoleAccess('PATIENT_ONLY', locale) | Reusable Server Component helper; throws redirect on mismatch. |
| Navigation | PatientNavigation (custom, sectioned) | Patients see grouped sections (Health, Appointments, Documents, Account) — semantically different from clinician flat nav. |
| Brand | roleBadge: HeartIcon + variant: 'success' | Consistent with the clinical-care semantic palette. |
| i18n namespace | patient (+ patient.appointments, patient.dashboard sub-namespaces) | One namespace per major surface; no key collisions with doctor. |
Rejected: a separate patient SPA (would duplicate auth + i18n + design system), React Native shell sharing the web (timeline mismatch), or per-feature route groups (patient surfaces are flat and consistent — grouping would only add nesting).
Data Flow
- Patient logs in via
/<locale>/auth/login→ Auth.js v5 issues JWT cookie. - Browser hits
/<locale>/portal/patient→proxy.tssees session cookie, lets through. page.tsx(ddx-web/src/app/[locale]/portal/patient/page.tsx:13-18) redirects todashboard/.portal/patient/layout.tsxrunscreateRoleLayout(PATIENT_CONFIG, PatientNavigation):requireRoleAccess('PATIENT_ONLY', locale)returns the patient user, organization is fetched,<PortalShell>composes the chrome.dashboard/page.tsx(ddx-web/src/app/[locale]/portal/patient/dashboard/page.tsx:17-25) awaits the same RBAC gate, fetches translations, derives a friendly name, and renders<PatientDashboardClient locale={locale} userName={...}>— which then performs the client-side queries for upcoming appointments, recent labs, and document inbox.- Server Action writes (booking, cancellation, message send) round-trip through the BFF;
the backend re-validates
PATIENT_ONLYpermissions and tenant scope authoritatively (see[[multi-portal-architecture]]).
Implicated Code
ddx-web/src/app/[locale]/portal/patient/layout.tsx:1-14— one-line role layout factory call.ddx-web/src/app/[locale]/portal/patient/page.tsx:13-18— root redirect to dashboard.ddx-web/src/app/[locale]/portal/patient/dashboard/page.tsx:17-25— server-side role gate + delegation toPatientDashboardClient.ddx-web/src/app/[locale]/portal/patient/appointments/page.tsx:14-29— server-side role gate,getTranslations('patient.appointments'), delegate toAppointmentsListClient.ddx-web/src/lib/portal/role-configs.ts:77-87—PATIENT_CONFIGdefinition.ddx-web/src/lib/api/rbac-config.ts:38—PATIENT: PATIENT_PERMISSIONSregistration.ddx-web/src/lib/auth/protection.ts:38-46—requireAuth(locale)helper backingrequireRoleAccess.
Operational Notes
PATIENT_ONLYgate every page — copy-pasting a doctor page intoportal/patient/and forgetting to swap the auth requirement is the canonical regression. The role string is exact-match; there is no inheritance.- No
(with-nav)/group — unlike the doctor portal, the patient portal renders every route under one chrome. There is no chrome-free sibling, so no route group is needed. - Custom
PatientNavigation— not the genericPortalNavigation. If you add a new patient surface, register its nav entry in theusePatientNavigationhook (and add thepatient.*label keys to all three locales). - Translation namespace fragments — patient pages frequently use sub-namespaces
(
patient.appointments,patient.dashboard). Adding a new sub-namespace requires matching keys inen/patient.json,de/patient.json,fr/patient.json. - Server Action writes — never write directly from the client to ddx-api. Use the BFF (Server Action or BFF route handler); the patient JWT carries claims the backend re-validates.
Related Topics
- Multi-Portal Architecture — eleven-portal shell rules that this layout inherits.
- i18n — Multi-Lingual Interface —
patient.*namespace resolution. - Doctor Portal — clinician counterpart; same patient identity, very different write capabilities.
- Medical Cards — the patient's own card view (W2 wiring).