Patient Portal Service — Application Logic
Status: populated Owner: TBD Last updated: 2026-04-18 Companion: Service Template · 03 platform-services · 02 DDD
1. Commands
| Command | Handler | FR Ref | Notes |
|---|---|---|---|
RegisterPortalAccount | PortalAccountCommandHandler | FR-PORTAL-001 | Links patientId → Keycloak sub; triggers email verification |
ActivatePortalAccount | PortalAccountCommandHandler | FR-PORTAL-001 | Moves pending_verification → active after email/phone confirmation |
SuspendPortalAccount | PortalAccountCommandHandler | FR-PORTAL-009 | Admin-triggered; fires portal.account.suspended.v1 |
ReinstatePortalAccount | PortalAccountCommandHandler | FR-PORTAL-009 | Admin re-activation |
RequestAccountDeletion | PortalAccountCommandHandler | FR-PORTAL-010 | Schedules close after data-retention check |
GrantProxyDelegation | ProxyDelegationCommandHandler | FR-PORTAL-006 | Creates scoped ProxyDelegation; publishes event |
RevokeProxyDelegation | ProxyDelegationCommandHandler | FR-PORTAL-006 | Moves delegation active → revoked |
SubmitDemographicsUpdate | DemographicsUpdateCommandHandler | FR-PORTAL-005 | Creates DemographicsUpdateRequest; routes to registration-service queue |
SubmitAppointmentRequest | AppointmentRequestCommandHandler | FR-PORTAL-003 | Forwards to scheduling-service; records portal access event |
RequestFHIRExport | ExportCommandHandler | FR-PORTAL-011 | Dispatches async bundle export; publishes portal.export.requested.v1 |
2. Queries
| Query | Handler | FR Ref | Cache | Notes |
|---|---|---|---|---|
GetPortalAccount | PortalAccountQueryHandler | FR-PORTAL-001 | None | Returns account status, MFA state, preferences |
GetPatientSummary | PatientSummaryQueryHandler | FR-PORTAL-002 | Redis 60s | Aggregates Patient + active conditions (patient-chart-service) |
GetChartSection | ChartSectionQueryHandler | FR-PORTAL-002 | Redis 30s | Returns FHIR bundle for section (allergies, meds, vitals, immunizations, problems) |
GetLabResults | LabResultsQueryHandler | FR-PORTAL-007 | Redis 30s | Returns released Observation resources from laboratory-service |
GetRadiologyResults | RadiologyQueryHandler | FR-PORTAL-007 | Redis 30s | Returns released DiagnosticReport resources from radiology-service |
GetAppointments | AppointmentQueryHandler | FR-PORTAL-003 | Redis 30s | Returns upcoming + past Appointment resources from scheduling-service |
GetMedications | MedicationQueryHandler | FR-PORTAL-004 | Redis 60s | Returns MedicationRequest resources from medication-service |
GetCoverageAndEOB | BillingQueryHandler | FR-PORTAL-008 | Redis 120s | Returns Coverage + ExplanationOfBenefit from claims-service |
GetImmunizations | ImmunizationQueryHandler | FR-PORTAL-002 | Redis 120s | Returns Immunization resources from immunizations-service |
GetProxyDelegations | ProxyDelegationQueryHandler | FR-PORTAL-006 | None | Returns active and historical delegations for account |
GetAccessLog | AccessLogQueryHandler | FR-PORTAL-009 | None | Returns immutable PortalAccessEvent records |
3. Ports (Hexagonal Architecture)
| Port | Direction | Adapter |
|---|---|---|
IPortalAccountRepository | Out | Drizzle ORM → PostgreSQL |
IProxyDelegationRepository | Out | Drizzle ORM → PostgreSQL |
IDemographicsRequestRepository | Out | Drizzle ORM → PostgreSQL |
IAccessEventRepository | Out | Drizzle ORM → PostgreSQL (append-only) |
IRegistrationServiceClient | Out | HTTP adapter → registration-service |
ISchedulingServiceClient | Out | HTTP adapter → scheduling-service |
ILaboratoryServiceClient | Out | HTTP adapter → laboratory-service |
IRadiologyServiceClient | Out | HTTP adapter → radiology-service |
IClaimsServiceClient | Out | HTTP adapter → claims-service |
IMedicationServiceClient | Out | HTTP adapter → medication-service |
IImmunizationsServiceClient | Out | HTTP adapter → immunizations-service |
IPatientChartServiceClient | Out | HTTP adapter → patient-chart-service |
IAIGatewayClient | Out | HTTP adapter → ai-gateway-service (Tier A only) |
IPortalCache | Out | Redis (ioredis) |
IEventPublisher | Out | NATS JetStream via @ghasi/nats-client |
IPushNotificationPort | Out | FCM/APNs adapter (mobile push) |
4. Key Orchestration Flows
4.1 Patient Login + MFA Validation
4.2 Lab Result View with Release Policy Check
4.3 Appointment Request Submission
5. Policy Filters
All BFF query handlers apply the following policy checks before forwarding upstream requests:
| Check | What it does |
|---|---|
| Scope enforcement | JWT must contain the relevant SMART scope (e.g., patient/Observation.read) |
| Subject binding | sub claim must match the portal account's identityProviderSubject |
| Proxy scope check | If acting as proxy, ProxyDelegation.scope must include the requested resource type |
| Result release policy | Lab/radiology results are fetched with ?releasePolicy=patient-visible; unreleased results are never returned to the patient |
| MFA enforcement | Requests from accounts with mfaEnabled=true must have acr claim ≥ configured threshold |
| Account status check | Suspended or closed accounts return 403 ACCOUNT_NOT_ACTIVE |
6. Outbox Pattern
All domain events (portal.*) are written atomically to the outbox table within the same Postgres transaction as the state mutation, then relayed to NATS JetStream by the outbox relay worker. This guarantees at-least-once delivery without dual-write failure risk.
7. Error Handling
| Scenario | Response |
|---|---|
| Upstream service unavailable (circuit open) | 503 UPSTREAM_UNAVAILABLE with retryAfterSeconds |
| Upstream returns stale data beyond TTL | Return cached version + X-Portal-Cache-Stale: true header |
| FHIR resource not found upstream | 404 RESOURCE_NOT_FOUND (do not leak internal error detail) |
| Scope check fail | 403 INSUFFICIENT_SCOPE |
| MFA not met | 403 MFA_REQUIRED |
| Module not licensed | 403 MODULE_NOT_LICENSED |
| Account suspended | 403 ACCOUNT_NOT_ACTIVE |