Patient portal — How it works
Overview
The patientportal module is the platform's patient-facing domain plug-in (com.zhenus.uhp.api.patientportal).
It owns portal-local state (accounts, future messaging and preferences) and exposes REST under
/api/v1/patient-portal/*. Clinical reads and writes delegate to platform engines over Feign — the portal is
another caller, scoped to exactly one authenticated person. Design: MILESTONE15_PLAN.md.
| Ticket | Delivers | Status |
|---|---|---|
| M15-001 | Module skeleton, patient JWT, bootstrap sign-in, session endpoint | done |
| M15-002 | Portal account linkage, invite / self-registration | done |
| M15-003 | My record (self-scoped reads via Feign) | done |
| M15-004 | Appointment self-service | done |
| M15-005 | Consents and access transparency | done |
| M15-006 | Secure messaging | done |
| M15-007 | Milestone gate | done |
Data model & ownership
The module owns the patientportal schema, created by patientportal.db.changelog-master.yaml and
registered in app's platform.db.changelog-master.yaml.
| Migration | Purpose |
|---|---|
001-create-patientportal-schema.yaml | Schema placeholder |
002-create-portal-account.yaml | portal_account — credential store keyed by person_id (unique) |
003-create-portal-invite.yaml | portal_invite — single-use clinic activation tokens (hashed) |
portal_account
- No PHI on the row — only
person_id, username, password hash, status, verification metadata, lockout counters. - One account ↔ one person, enforced by unique index on
person_id. - Username unique within
(tenant_id, username).
portal_invite
- Token stored as SHA-256 hex digest only.
- Single-use via
consumed_at; expires perpatient-portal.account.invite-token-ttl.
Key rules & invariants
- Domain module must not import
corepackages (ArchUnit in the patientportal module). - A patient session is a distinct principal type — not a staff session with a blank facility.
- Patient JWT claims:
subjectType: PATIENT,personId,portalAccountId; neversessionFacilityId. - Mutual rejection:
LocalJwtAuthenticationFilterrejects patient tokens on staff paths;PatientJwtAuthenticationFilterrejects staff tokens on portal paths. - Bootstrap sign-in (
patient-portal.bootstrap.*) is dev/UAT only. Production uses portal accounts. - Self-registration sends verification codes only to phone/email already on the person record.
- Verification codes and invite tokens never appear in logs (ListAppender test).
API
REST surface under /api/v1/patient-portal. See the generated API Reference (patient-portal group).
| Endpoint | Auth | Purpose |
|---|---|---|
POST /auth/sign-in | Public | Username/password (or bootstrap when enabled) |
POST /auth/activate | Public | Redeem clinic invite + set password |
POST /auth/register | Public | Start self-registration (identifier match) |
POST /auth/verify | Public | Complete self-registration with OTP |
GET /session | Patient JWT | Current session metadata |
GET /record | Patient JWT | Self-scoped my-record aggregate (results, meds, allergies, conditions, documents) |
GET /record/appointments | Patient JWT | List my appointments |
GET /record/appointments/availability | Patient JWT | Bookable slots |
POST /record/appointments | Patient JWT | Book an appointment |
POST /record/appointments/{id}/reschedule | Patient JWT | Reschedule my appointment |
POST /record/appointments/{id}/cancel | Patient JWT | Cancel my appointment |
GET /record/appointments/reminders | Patient JWT | Appointment reminder preferences |
PUT /record/appointments/reminders | Patient JWT | Update reminder opt-in |
GET /record/consents | Patient JWT | List my consents, grants, and visibility |
POST /record/consents | Patient JWT | Grant a consent |
POST /record/consents/{id}/revoke | Patient JWT | Revoke my consent |
GET /record/access-log | Patient JWT | Paged "who accessed my record" log |
GET /record/messages/threads | Patient JWT | List my message threads |
POST /record/messages/threads | Patient JWT | Start a message thread |
GET /record/messages/threads/{id} | Patient JWT | Load thread with messages |
POST /record/messages/threads/{id}/messages | Patient JWT | Send a message |
POST /record/messages/threads/{id}/read | Patient JWT | Mark staff messages read |
POST /staff/invites | Staff JWT + patientportal.invite.write | Issue clinic invite |
Configuration & feature flags
| Property / env | Purpose |
|---|---|
patient-portal.jwt.secret / PATIENT_PORTAL_JWT_SECRET | HS256 signing key (≥ 32 bytes when Keycloak disabled) |
patient-portal.jwt.access-token-ttl / PATIENT_PORTAL_JWT_TTL | Access token lifetime (default PT30M) |
patient-portal.bootstrap.enabled / PATIENT_PORTAL_BOOTSTRAP_ENABLED | Enable bootstrap sign-in (default false) |
patient-portal.bootstrap.shared-secret / PATIENT_PORTAL_BOOTSTRAP_SECRET | Bootstrap password (required when enabled) |
patient-portal.account.invite-token-ttl / PATIENT_PORTAL_INVITE_TOKEN_TTL | Invite link lifetime (default P7D) |
patient-portal.account.verification-code-ttl / PATIENT_PORTAL_VERIFICATION_CODE_TTL | OTP lifetime (default PT15M) |
patient-portal.account.verification-channel / PATIENT_PORTAL_VERIFICATION_CHANNEL | Default dispatch channel (SMS or EMAIL) |
patient-portal.account.activation-base-url / PATIENT_PORTAL_ACTIVATION_BASE_URL | Fallback activation link base |
patient-portal.account.allowed-origins / PATIENT_PORTAL_ALLOWED_ORIGINS | Allow-listed browser origins for link building |
patient-portal.messaging.response-expectation | Copy shown on every messaging surface |
patient-portal.messaging.messages-path | Frontend deep-link path for new-message notifications |
Defaults ship in patientportal/src/main/resources/uhp-module-defaults.yml (CONF-002).
Related features
- demographic — person identity, identifier search, on-file contacts
- notification — invite and verification dispatch via
NotificationCommandClient - identity-access — consent and access grants (M15-005)
- clinical — my record reads (M15-003):
ClinicalClient,MedicationClient,LabResultReadClient,DocumentClient
My record (M15-003)
Self-scoped read aggregation at GET /api/v1/patient-portal/record. The person id is derived from
CurrentPatientResolver — no patient or person id appears in the path or query.
| Section | Source | Unavailable when |
|---|---|---|
| Allergies | ClinicalClient.getPatientSummaryPanel | Panel fails or times out (3s budget) |
| Medications | MedicationClient.searchMedicationOrders (ACTIVE) | Feign failure |
| Conditions | ClinicalClient.searchConditions | Feign failure |
| Results | LabResultReadClient (orders → RELEASED results) | Feign failure |
| Documents | DocumentClient.listForPatient (metadata only) | Feign failure |
Access guard. RecordAccessGuard re-checks the portal account is ACTIVE (401 when deactivated or
person mismatch) and writes an audit row attributable to portalAccountId before any Feign fan-out.
Feign auth. Patient JWTs cannot call platform engines directly. FeignContextPropagationConfig
substitutes the module service identity when the caller is a PatientPrincipal.
Allergy states. AllergyStatus is always set when the summary panel loads: RECORDED,
NONE_KNOWN, or NOT_ASKED. Allergy rows are returned only when status is RECORDED.
Permission / route. patientportal.record.read · frontend route key patient-portal.record.
Appointments (M15-004)
Self-scoped appointment self-service under /api/v1/patient-portal/record/appointments. The person id is
derived from CurrentPatientResolver — no patient or person id appears in the path or query.
| Operation | Delegates to | Unavailable when |
|---|---|---|
| List / availability | SchedulingClient | Feign failure → schedulingAvailable: false |
| Book / reschedule / cancel | SchedulingCommandClient | Engine down → 400 with clear message; slot taken → 409 |
| Reminder opt-in | NotificationSubscriptionCommandClient | Uses existing notification_subscription rows |
Ownership. Cancel and reschedule load the appointment and refuse when patientId does not match the
authenticated person (403).
Permission / route. patientportal.appointment.read / patientportal.appointment.write · frontend route
key patient-portal.appointments.
Consents and access transparency (M15-005)
Self-scoped consent management and access log under /api/v1/patient-portal/record/consents and
/record/access-log. The person id is derived from CurrentPatientResolver — no patient or person id
appears in the path or query, and the access log cannot be filtered by clinician.
| Operation | Delegates to | Unavailable when |
|---|---|---|
| List consents / grants / visibility | ConsentRegistryClient, PersonAccessGrantClient, PersonVisibilityScopeClient | Feign failure → identityAccessAvailable: false |
| Grant / revoke consent | ConsentRegistryCommandClient | Engine down → 400 with clear message |
| Access log page | PatientAccessAuditClient → core /self endpoint | Feign failure → empty page |
Consent storage. Consent rows live in identity-access (consent_registry); the portal holds no
consent state of its own.
Access log contract. Core exposes GET /api/v1/access-control/patient-access-audit-events/self
(paged, header-scoped). Rows are PatientAccessAuditPortalEntryDto: role label, facility name, time,
event type, and allowed flag — never staff user account ids. Break-glass events appear with
eventType: BREAK_GLASS and role label "Emergency access".
Feign auth. FeignContextPropagationConfig sets X-Patient-Person-Id from the PatientPrincipal
when substituting the module service identity.
Permissions / routes. patientportal.consent.read / patientportal.consent.write /
patientportal.access-log.read · frontend route keys patient-portal.consents and
patient-portal.access-log.
Secure messaging (M15-006)
Self-scoped patient↔care-team messaging under /api/v1/patient-portal/record/messages. Message content
stays in the patientportal schema; delivery notifications use generic copy only (no clinical text in
notification metadata).
| Operation | Ownership | Notes |
|---|---|---|
| List / load threads | message_thread, message, message_read_receipt | Append-only messages and read receipts |
| Create thread / send | Module schema | Requires staffUserAccountId on create; patient participant verified server-side |
| Mark read | Append-only receipts | Patient marks staff messages read |
Response expectation. Every thread DTO includes responseExpectation from
patient-portal.messaging.response-expectation — the frontend must display it on list and detail surfaces.
Permissions / routes. patientportal.message.read / patientportal.message.write · frontend route key
patient-portal.messages.
Why it is this way
Portal credentials live in patientportal; clinical truth stays in core. The module never imports
core — every clinical read and write goes over Feign with a patient-scoped service identity (D11/D12).
Self-scoped reads never accept person or patient id in the path. The authenticated portal session derives the person once; passing an id in the URL would invite cross-patient probing.
Enrolment in a public-health programme is a separate disclosure decision (PH-001). Portal consents
(M15-005) cover identity-access grants; programme enrolment shares records with an implementing tenant
via the publichealth module — not through this portal.
Traps
⚠ Do not import core. ArchUnit enforces the Feign-only boundary.
⚠ Do not reuse staff JWTs for patients. The subjectType discriminator exists so cross-principal bugs
are testable on every build.
⚠ Bootstrap sign-in is not production auth. Disable bootstrap before exposing a patient portal publicly.
⚠ Never log verification codes or raw invite tokens.