Skip to main content

Patient Portal Service — Data Model

Status: populated Owner: TBD Last updated: 2026-04-18 Companion: Service Template · 03 platform-services · 02 DDD

1. ID Prefix Convention

PrefixEntity
pact_PortalAccount
pdel_ProxyDelegation
demreq_DemographicsUpdateRequest
paev_PortalAccessEvent
rxreq_MedicationRefillRequest
apptreq_AppointmentRequest
expjob_ExportJob

All IDs are ULIDs with the prefix prepended, e.g., pact_01JXXXXXXXXXXXXXXXXXXXXXXX.


2. TypeScript Interfaces

// PortalAccount
export interface PortalAccount {
id: PortalAccountId; // pact_*
tenantId: TenantId;
patientId: PatientId;
identityProviderSubject: string; // Keycloak sub
status: AccountStatus; // active | suspended | pending_verification | closed
mfaEnabled: boolean;
preferredLanguage: string | null; // BCP 47
lastLoginAt: Date | null;
createdAt: Date;
updatedAt: Date;
}

// ProxyDelegation
export interface ProxyDelegation {
id: ProxyDelegationId; // pdel_*
tenantId: TenantId;
grantorPatientId: PatientId;
proxyPortalAccountId: PortalAccountId;
relationshipType: ProxyRelationship;
scope: DelegationScope[];
validFrom: Date;
validTo: Date | null;
status: DelegationStatus; // active | revoked | expired
revokedAt: Date | null;
createdAt: Date;
}

// DemographicsUpdateRequest
export interface DemographicsUpdateRequest {
id: DemographicsRequestId; // demreq_*
tenantId: TenantId;
patientId: PatientId;
portalAccountId: PortalAccountId;
requestedChanges: Record<string, unknown>; // JsonB
status: RequestStatus; // pending | approved | rejected | cancelled
requestedAt: Date;
reviewedBy: StaffId | null;
reviewedAt: Date | null;
rejectionReason: string | null;
}

// PortalAccessEvent (append-only)
export interface PortalAccessEvent {
id: AccessEventId; // paev_*
tenantId: TenantId;
portalAccountId: PortalAccountId;
patientId: PatientId;
actingAsProxy: boolean;
proxyDelegationId: ProxyDelegationId | null;
eventType: PortalEventType;
resourceType: string | null;
resourceId: string | null;
ipHash: string | null;
occurredAt: Date;
}

// ExportJob
export interface ExportJob {
id: ExportJobId; // expjob_*
tenantId: TenantId;
portalAccountId: PortalAccountId;
patientId: PatientId;
status: ExportJobStatus; // pending | in_progress | complete | failed
requestedAt: Date;
completedAt: Date | null;
downloadUrl: string | null;
expiresAt: Date | null;
errorDetail: string | null;
}

3. PostgreSQL Schema

-- PortalAccount
CREATE TABLE portal_accounts (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
patient_id TEXT NOT NULL,
idp_subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_verification',
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
preferred_lang TEXT,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_portal_accounts_tenant_patient
ON portal_accounts(tenant_id, patient_id);
CREATE UNIQUE INDEX uq_portal_accounts_idp_subject
ON portal_accounts(idp_subject);
ALTER TABLE portal_accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON portal_accounts
USING (tenant_id = current_setting('app.tenant_id'));

-- ProxyDelegation
CREATE TABLE proxy_delegations (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
grantor_patient_id TEXT NOT NULL,
proxy_portal_account_id TEXT NOT NULL REFERENCES portal_accounts(id),
relationship_type TEXT NOT NULL,
scope TEXT[] NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE,
status TEXT NOT NULL DEFAULT 'active',
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_proxy_delegations_grantor
ON proxy_delegations(tenant_id, grantor_patient_id);
CREATE INDEX idx_proxy_delegations_proxy_account
ON proxy_delegations(tenant_id, proxy_portal_account_id);
ALTER TABLE proxy_delegations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON proxy_delegations
USING (tenant_id = current_setting('app.tenant_id'));

-- DemographicsUpdateRequest
CREATE TABLE demographics_update_requests (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
patient_id TEXT NOT NULL,
portal_account_id TEXT NOT NULL REFERENCES portal_accounts(id),
requested_changes JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
rejection_reason TEXT
);
CREATE INDEX idx_demreq_tenant_patient
ON demographics_update_requests(tenant_id, patient_id);
ALTER TABLE demographics_update_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON demographics_update_requests
USING (tenant_id = current_setting('app.tenant_id'));

-- PortalAccessEvent (append-only; partitioned by month)
CREATE TABLE portal_access_events (
id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
portal_account_id TEXT NOT NULL,
patient_id TEXT NOT NULL,
acting_as_proxy BOOLEAN NOT NULL DEFAULT FALSE,
proxy_delegation_id TEXT,
event_type TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
ip_hash TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (occurred_at);
CREATE INDEX idx_pae_tenant_account
ON portal_access_events(tenant_id, portal_account_id, occurred_at DESC);
ALTER TABLE portal_access_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON portal_access_events
USING (tenant_id = current_setting('app.tenant_id'));
-- Monthly partitions managed by pg_partman

-- ExportJob
CREATE TABLE export_jobs (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
portal_account_id TEXT NOT NULL REFERENCES portal_accounts(id),
patient_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
download_url TEXT,
expires_at TIMESTAMPTZ,
error_detail TEXT
);
CREATE INDEX idx_export_jobs_account
ON export_jobs(tenant_id, portal_account_id);
ALTER TABLE export_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON export_jobs
USING (tenant_id = current_setting('app.tenant_id'));

-- Outbox (transactional outbox)
CREATE TABLE outbox (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
subject TEXT NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_unpublished
ON outbox(published, created_at) WHERE published = FALSE;

4. Indexes Summary

TableIndexPurpose
portal_accountsuq_portal_accounts_tenant_patientUnique patient per tenant
portal_accountsuq_portal_accounts_idp_subjectKeycloak sub lookup
proxy_delegationsidx_proxy_delegations_grantorList delegations for grantor
proxy_delegationsidx_proxy_delegations_proxy_accountList delegations for proxy
portal_access_eventsidx_pae_tenant_accountAccess log pagination
export_jobsidx_export_jobs_accountPoll export status
outboxidx_outbox_unpublishedRelay worker polling

5. Data Residency

portal_accounts, proxy_delegations, and portal_access_events contain PHI. PostgreSQL is deployed within the tenant's designated data-residency region (Afghanistan MoPH: AFG region). Cross-region replication is disabled for PHI tables. export_jobs download URLs point to tenant-region object storage only.