08-portals-and-i18nwave: W5filled14 citations

Patient Portal — Self-Service Health Records

Audiences: clinical-buyer, developer, investor

Patient Portal — Self-Service Health Records

The patient-facing surface: dashboard, appointments, medical record, secure messaging, documents, and video visits — gated to PATIENT_ONLY role 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/, the PATIENT_ONLY role 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

diagram
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:

typescript
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

LayerChoiceWhy
FrameworkNext.js ^16.1.6 App Router, React ^19.2.4Server-first; awaited params; suspense for streaming.
RBAC gaterequireRoleAccess('PATIENT_ONLY', locale)Reusable Server Component helper; throws redirect on mismatch.
NavigationPatientNavigation (custom, sectioned)Patients see grouped sections (Health, Appointments, Documents, Account) — semantically different from clinician flat nav.
BrandroleBadge: HeartIcon + variant: 'success'Consistent with the clinical-care semantic palette.
i18n namespacepatient (+ 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

  1. Patient logs in via /<locale>/auth/login → Auth.js v5 issues JWT cookie.
  2. Browser hits /<locale>/portal/patientproxy.ts sees session cookie, lets through.
  3. page.tsx (ddx-web/src/app/[locale]/portal/patient/page.tsx:13-18) redirects to dashboard/.
  4. portal/patient/layout.tsx runs createRoleLayout(PATIENT_CONFIG, PatientNavigation): requireRoleAccess('PATIENT_ONLY', locale) returns the patient user, organization is fetched, <PortalShell> composes the chrome.
  5. 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.
  6. Server Action writes (booking, cancellation, message send) round-trip through the BFF; the backend re-validates PATIENT_ONLY permissions 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 to PatientDashboardClient.
  • ddx-web/src/app/[locale]/portal/patient/appointments/page.tsx:14-29 — server-side role gate, getTranslations('patient.appointments'), delegate to AppointmentsListClient.
  • ddx-web/src/lib/portal/role-configs.ts:77-87PATIENT_CONFIG definition.
  • ddx-web/src/lib/api/rbac-config.ts:38PATIENT: PATIENT_PERMISSIONS registration.
  • ddx-web/src/lib/auth/protection.ts:38-46requireAuth(locale) helper backing requireRoleAccess.

Operational Notes

  • PATIENT_ONLY gate every page — copy-pasting a doctor page into portal/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 generic PortalNavigation. If you add a new patient surface, register its nav entry in the usePatientNavigation hook (and add the patient.* 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 in en/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.