Skip to main content

Immunizations Service — Data Model

Status: populated Owner: TBD Last updated: 2026-04-18 Companion: Service Template

1. TypeScript Domain Interfaces

// Immunization Record aggregate
export interface ImmunizationRecord {
id: string; // imm_ prefix
tenantId: string;
patientId: string;
encounterId?: string;
status: 'completed' | 'not-done' | 'entered-in-error';
statusReason?: CodeableConcept;
vaccineCode: CodeableConcept;
doseNumber: number;
seriesDoses?: number;
lotNumber?: string;
expiryDate?: string; // ISO date
administeredAt?: string; // ISO datetime; required for completed
recordedAt: string; // ISO datetime
site?: CodeableConcept;
route?: CodeableConcept;
doseQuantity?: Quantity;
performerId?: string;
facilityId: string;
notes?: string;
isHistorical: boolean;
source?: ImmunizationSource;
version: number;
createdAt: string;
updatedAt: string;
}

export interface ImmunizationForecast {
id: string; // fcst_ prefix
tenantId: string;
patientId: string;
calculatedAt: string;
recommendations: ForecastRecommendation[];
createdAt: string;
updatedAt: string;
}

export interface ForecastRecommendation {
vaccineCode: CodeableConcept;
series: string;
doseNumber: number;
seriesDoses: number;
forecastStatus: 'due' | 'overdue' | 'immune' | 'contraindicated' | 'complete' | 'not-yet-due';
dueDate?: string;
earliestDate?: string;
latestDate?: string;
}

export interface ContraindicationRecord {
id: string; // cind_ prefix
tenantId: string;
patientId: string;
vaccineCode: CodeableConcept;
reason: CodeableConcept;
recordedAt: string;
performerId: string;
active: boolean;
inactivatedAt?: string;
createdAt: string;
updatedAt: string;
}

export interface RegistrySyncJob {
id: string; // rsj_ prefix
tenantId: string;
status: 'pending' | 'in-progress' | 'completed' | 'failed';
immunizationIds: string[];
syncedAt?: string;
errorMessage?: string;
retryCount: number;
createdAt: string;
updatedAt: string;
}

export interface CodeableConcept {
system: string;
code: string;
display: string;
}

export interface Quantity {
value: number;
unit: string;
}

export interface ImmunizationSource {
type: 'paper_card' | 'external_emr' | 'national_registry';
reference?: string;
}

2. PostgreSQL Schema

immunizations table

CREATE TABLE immunizations.immunization_records (
id TEXT PRIMARY KEY, -- imm_
tenant_id TEXT NOT NULL,
patient_id TEXT NOT NULL,
encounter_id TEXT,
status TEXT NOT NULL CHECK (status IN ('completed', 'not-done', 'entered-in-error')),
status_reason_system TEXT,
status_reason_code TEXT,
status_reason_display TEXT,
vaccine_code_system TEXT NOT NULL,
vaccine_code TEXT NOT NULL,
vaccine_display TEXT NOT NULL,
dose_number INTEGER NOT NULL,
series_doses INTEGER,
lot_number TEXT,
expiry_date DATE,
administered_at TIMESTAMPTZ,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
site_system TEXT,
site_code TEXT,
route_system TEXT,
route_code TEXT,
dose_quantity_value NUMERIC(8,4),
dose_quantity_unit TEXT,
performer_id TEXT,
facility_id TEXT NOT NULL,
notes TEXT,
is_historical BOOLEAN NOT NULL DEFAULT FALSE,
source_type TEXT,
source_reference TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_imm_records_tenant_patient ON immunizations.immunization_records (tenant_id, patient_id);
CREATE INDEX idx_imm_records_vaccine_status ON immunizations.immunization_records (tenant_id, vaccine_code, status);
CREATE INDEX idx_imm_records_facility ON immunizations.immunization_records (tenant_id, facility_id);
CREATE INDEX idx_imm_records_administered_at ON immunizations.immunization_records (tenant_id, administered_at DESC);

forecasts table

CREATE TABLE immunizations.forecasts (
id TEXT PRIMARY KEY, -- fcst_
tenant_id TEXT NOT NULL,
patient_id TEXT NOT NULL,
recommendations JSONB NOT NULL DEFAULT '[]',
calculated_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, patient_id)
);

CREATE INDEX idx_forecasts_tenant_patient ON immunizations.forecasts (tenant_id, patient_id);

contraindications table

CREATE TABLE immunizations.contraindications (
id TEXT PRIMARY KEY, -- cind_
tenant_id TEXT NOT NULL,
patient_id TEXT NOT NULL,
vaccine_code_system TEXT NOT NULL,
vaccine_code TEXT NOT NULL,
reason_system TEXT NOT NULL,
reason_code TEXT NOT NULL,
reason_display TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
performer_id TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
inactivated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_contraindications_patient ON immunizations.contraindications (tenant_id, patient_id, active);

registry_sync_jobs table

CREATE TABLE immunizations.registry_sync_jobs (
id TEXT PRIMARY KEY, -- rsj_
tenant_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','in-progress','completed','failed')),
immunization_ids TEXT[] NOT NULL,
synced_at TIMESTAMPTZ,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_registry_sync_status ON immunizations.registry_sync_jobs (tenant_id, status);

outbox table

CREATE TABLE immunizations.outbox (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
subject TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','delivered','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delivered_at TIMESTAMPTZ
);

CREATE INDEX idx_outbox_pending ON immunizations.outbox (status, created_at) WHERE status = 'pending';

3. Row-Level Security Policies

ALTER TABLE immunizations.immunization_records ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON immunizations.immunization_records
USING (tenant_id = current_setting('app.tenant_id')::TEXT);

-- Apply equivalent policies to forecasts, contraindications, registry_sync_jobs, outbox

4. Materialized Views (Coverage Reporting)

CREATE MATERIALIZED VIEW immunizations.coverage_by_antigen AS
SELECT
tenant_id,
facility_id,
vaccine_code,
dose_number,
DATE_TRUNC('month', administered_at) AS period_month,
COUNT(DISTINCT patient_id) FILTER (WHERE status = 'completed') AS vaccinated_count
FROM immunizations.immunization_records
WHERE status = 'completed'
GROUP BY tenant_id, facility_id, vaccine_code, dose_number, period_month;

CREATE UNIQUE INDEX ON immunizations.coverage_by_antigen (tenant_id, facility_id, vaccine_code, dose_number, period_month);

Refreshed via scheduled job or on receipt of IMMUNIZATIONS.immunization.recorded event.