Skip to main content

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.

TicketDeliversStatus
M15-001Module skeleton, patient JWT, bootstrap sign-in, session endpointdone
M15-002Portal account linkage, invite / self-registrationdone
M15-003My record (self-scoped reads via Feign)done
M15-004Appointment self-servicedone
M15-005Consents and access transparencydone
M15-006Secure messagingdone
M15-007Milestone gatedone

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.

MigrationPurpose
001-create-patientportal-schema.yamlSchema placeholder
002-create-portal-account.yamlportal_account — credential store keyed by person_id (unique)
003-create-portal-invite.yamlportal_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 per patient-portal.account.invite-token-ttl.

Key rules & invariants

  • Domain module must not import core packages (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; never sessionFacilityId.
  • Mutual rejection: LocalJwtAuthenticationFilter rejects patient tokens on staff paths; PatientJwtAuthenticationFilter rejects 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).

EndpointAuthPurpose
POST /auth/sign-inPublicUsername/password (or bootstrap when enabled)
POST /auth/activatePublicRedeem clinic invite + set password
POST /auth/registerPublicStart self-registration (identifier match)
POST /auth/verifyPublicComplete self-registration with OTP
GET /sessionPatient JWTCurrent session metadata
GET /recordPatient JWTSelf-scoped my-record aggregate (results, meds, allergies, conditions, documents)
GET /record/appointmentsPatient JWTList my appointments
GET /record/appointments/availabilityPatient JWTBookable slots
POST /record/appointmentsPatient JWTBook an appointment
POST /record/appointments/{id}/reschedulePatient JWTReschedule my appointment
POST /record/appointments/{id}/cancelPatient JWTCancel my appointment
GET /record/appointments/remindersPatient JWTAppointment reminder preferences
PUT /record/appointments/remindersPatient JWTUpdate reminder opt-in
GET /record/consentsPatient JWTList my consents, grants, and visibility
POST /record/consentsPatient JWTGrant a consent
POST /record/consents/{id}/revokePatient JWTRevoke my consent
GET /record/access-logPatient JWTPaged "who accessed my record" log
GET /record/messages/threadsPatient JWTList my message threads
POST /record/messages/threadsPatient JWTStart a message thread
GET /record/messages/threads/{id}Patient JWTLoad thread with messages
POST /record/messages/threads/{id}/messagesPatient JWTSend a message
POST /record/messages/threads/{id}/readPatient JWTMark staff messages read
POST /staff/invitesStaff JWT + patientportal.invite.writeIssue clinic invite

Configuration & feature flags

Property / envPurpose
patient-portal.jwt.secret / PATIENT_PORTAL_JWT_SECRETHS256 signing key (≥ 32 bytes when Keycloak disabled)
patient-portal.jwt.access-token-ttl / PATIENT_PORTAL_JWT_TTLAccess token lifetime (default PT30M)
patient-portal.bootstrap.enabled / PATIENT_PORTAL_BOOTSTRAP_ENABLEDEnable bootstrap sign-in (default false)
patient-portal.bootstrap.shared-secret / PATIENT_PORTAL_BOOTSTRAP_SECRETBootstrap password (required when enabled)
patient-portal.account.invite-token-ttl / PATIENT_PORTAL_INVITE_TOKEN_TTLInvite link lifetime (default P7D)
patient-portal.account.verification-code-ttl / PATIENT_PORTAL_VERIFICATION_CODE_TTLOTP lifetime (default PT15M)
patient-portal.account.verification-channel / PATIENT_PORTAL_VERIFICATION_CHANNELDefault dispatch channel (SMS or EMAIL)
patient-portal.account.activation-base-url / PATIENT_PORTAL_ACTIVATION_BASE_URLFallback activation link base
patient-portal.account.allowed-origins / PATIENT_PORTAL_ALLOWED_ORIGINSAllow-listed browser origins for link building
patient-portal.messaging.response-expectationCopy shown on every messaging surface
patient-portal.messaging.messages-pathFrontend deep-link path for new-message notifications

Defaults ship in patientportal/src/main/resources/uhp-module-defaults.yml (CONF-002).

  • 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 CurrentPatientResolverno patient or person id appears in the path or query.

SectionSourceUnavailable when
AllergiesClinicalClient.getPatientSummaryPanelPanel fails or times out (3s budget)
MedicationsMedicationClient.searchMedicationOrders (ACTIVE)Feign failure
ConditionsClinicalClient.searchConditionsFeign failure
ResultsLabResultReadClient (orders → RELEASED results)Feign failure
DocumentsDocumentClient.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 CurrentPatientResolverno patient or person id appears in the path or query.

OperationDelegates toUnavailable when
List / availabilitySchedulingClientFeign failure → schedulingAvailable: false
Book / reschedule / cancelSchedulingCommandClientEngine down → 400 with clear message; slot taken → 409
Reminder opt-inNotificationSubscriptionCommandClientUses 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 CurrentPatientResolverno patient or person id appears in the path or query, and the access log cannot be filtered by clinician.

OperationDelegates toUnavailable when
List consents / grants / visibilityConsentRegistryClient, PersonAccessGrantClient, PersonVisibilityScopeClientFeign failure → identityAccessAvailable: false
Grant / revoke consentConsentRegistryCommandClientEngine down → 400 with clear message
Access log pagePatientAccessAuditClient → core /self endpointFeign 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).

OperationOwnershipNotes
List / load threadsmessage_thread, message, message_read_receiptAppend-only messages and read receipts
Create thread / sendModule schemaRequires staffUserAccountId on create; patient participant verified server-side
Mark readAppend-only receiptsPatient 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.