Skip to main content

Communication Service — Data Model

Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · NAMING

1. ID prefixes

PrefixEntity
thr_MessageThread
msg_Message
rcp_ReadReceipt
att_Attachment
nti_NotificationIntent
dsp_DispatchRecord
vcs_VirtualSession
vcp_VirtualParticipant
tpl_NotificationTemplate

2. TypeScript domain interfaces

export type ThreadId = Branded<string, 'ThreadId'>;
export type MessageId = Branded<string, 'MessageId'>;
export type SessionId = Branded<string, 'SessionId'>;
export type Channel = 'push' | 'sms' | 'email' | 'in_app';
export type Urgency = 'routine' | 'urgent' | 'emergent';

export interface MessageThread {
id: ThreadId;
tenantId: TenantId;
nodeId?: string;
patientId?: PatientId;
encounterId?: EncounterId;
participants: ReadonlyArray<Participant>;
subject?: string;
status: 'active' | 'escalated' | 'archived';
createdAt: Date;
updatedAt: Date;
}

export interface Message {
id: MessageId;
threadId: ThreadId;
senderId: UserId;
body: string;
urgency: Urgency;
sentAt: Date;
attachmentIds: ReadonlyArray<AttachmentId>;
status: 'sent' | 'retracted';
}

export interface NotificationIntent {
id: NotificationIntentId;
tenantId: TenantId;
correlationId: string;
category: string;
channel: Channel;
recipientRef: string; // opaque (user id hash, device token ref, etc.)
templateKey: string;
variables: Record<string, string>;
submittedAt: Date;
status: 'queued' | 'dispatching' | 'dispatched' | 'delivered' | 'failed' | 'undeliverable';
}

export interface DispatchRecord {
id: DispatchRecordId;
intentId: NotificationIntentId;
tenantId: TenantId;
attemptNumber: number;
providerName: string;
providerMessageId?: string;
outcome: 'dispatched' | 'delivered' | 'failed' | 'undeliverable';
errorCode?: string;
dispatchedAt: Date;
settledAt?: Date;
}

export interface VirtualSession {
id: SessionId;
tenantId: TenantId;
appointmentId?: string;
patientId: PatientId;
providerIds: ReadonlyArray<UserId>;
roomRef: string;
state: 'scheduled' | 'waiting_room' | 'active' | 'ended' | 'cancelled' | 'failed';
startedAt?: Date;
endedAt?: Date;
fallbackThreadId?: ThreadId;
}

3. PostgreSQL schema

Schema: communication. All tables have RLS enabled by tenant_id via standard app.current_tenant setting.

-- threads
CREATE TABLE communication.message_threads (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
node_id TEXT,
patient_id TEXT,
encounter_id TEXT,
subject TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_message_threads_tenant_patient
ON communication.message_threads (tenant_id, patient_id);
CREATE INDEX ix_message_threads_tenant_status_updated
ON communication.message_threads (tenant_id, status, updated_at DESC);
ALTER TABLE communication.message_threads ENABLE ROW LEVEL SECURITY;
CREATE POLICY message_threads_tenant_isolation ON communication.message_threads
USING (tenant_id = current_setting('app.current_tenant'));

-- thread participants
CREATE TABLE communication.thread_participants (
thread_id TEXT NOT NULL REFERENCES communication.message_threads(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
muted_until TIMESTAMPTZ,
PRIMARY KEY (thread_id, user_id)
);
CREATE INDEX ix_thread_participants_user ON communication.thread_participants (tenant_id, user_id);
ALTER TABLE communication.thread_participants ENABLE ROW LEVEL SECURITY;
CREATE POLICY thread_participants_tenant_isolation ON communication.thread_participants
USING (tenant_id = current_setting('app.current_tenant'));

-- messages
CREATE TABLE communication.messages (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES communication.message_threads(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
body TEXT NOT NULL,
urgency TEXT NOT NULL DEFAULT 'routine',
status TEXT NOT NULL DEFAULT 'sent',
sent_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_messages_thread_sent ON communication.messages (thread_id, sent_at DESC);
ALTER TABLE communication.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY messages_tenant_isolation ON communication.messages
USING (tenant_id = current_setting('app.current_tenant'));

-- read receipts
CREATE TABLE communication.message_read_receipts (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
);
ALTER TABLE communication.message_read_receipts ENABLE ROW LEVEL SECURITY;
CREATE POLICY mrr_tenant_isolation ON communication.message_read_receipts
USING (tenant_id = current_setting('app.current_tenant'));

-- attachments
CREATE TABLE communication.message_attachments (
id TEXT PRIMARY KEY,
message_id TEXT REFERENCES communication.messages(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
file_ref TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
hash TEXT NOT NULL,
scan_status TEXT NOT NULL DEFAULT 'pending', -- pending|clean|infected
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE communication.message_attachments ENABLE ROW LEVEL SECURITY;
CREATE POLICY attachments_tenant_isolation ON communication.message_attachments
USING (tenant_id = current_setting('app.current_tenant'));

-- notifications
CREATE TABLE communication.notification_intents (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
correlation_id TEXT NOT NULL,
category TEXT NOT NULL,
channel TEXT NOT NULL,
recipient_ref TEXT NOT NULL,
template_key TEXT NOT NULL,
variables JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'queued',
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, correlation_id, channel, recipient_ref)
);
CREATE INDEX ix_intents_tenant_status_submitted
ON communication.notification_intents (tenant_id, status, submitted_at DESC);
ALTER TABLE communication.notification_intents ENABLE ROW LEVEL SECURITY;
CREATE POLICY intents_tenant_isolation ON communication.notification_intents
USING (tenant_id = current_setting('app.current_tenant'));

CREATE TABLE communication.dispatch_records (
id TEXT PRIMARY KEY,
intent_id TEXT NOT NULL REFERENCES communication.notification_intents(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
attempt_number INT NOT NULL,
provider_name TEXT NOT NULL,
provider_message_id TEXT,
outcome TEXT NOT NULL,
error_code TEXT,
dispatched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
settled_at TIMESTAMPTZ
);
CREATE INDEX ix_dispatch_intent ON communication.dispatch_records (intent_id);
ALTER TABLE communication.dispatch_records ENABLE ROW LEVEL SECURITY;
CREATE POLICY dispatch_tenant_isolation ON communication.dispatch_records
USING (tenant_id = current_setting('app.current_tenant'));

-- virtual sessions
CREATE TABLE communication.virtual_sessions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
appointment_id TEXT,
patient_id TEXT NOT NULL,
room_ref TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'scheduled',
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
fallback_thread_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ux_virtual_sessions_tenant_appt
ON communication.virtual_sessions (tenant_id, appointment_id)
WHERE appointment_id IS NOT NULL;
ALTER TABLE communication.virtual_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY vs_tenant_isolation ON communication.virtual_sessions
USING (tenant_id = current_setting('app.current_tenant'));

CREATE TABLE communication.virtual_participants (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES communication.virtual_sessions(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
user_id TEXT,
display_name TEXT,
role TEXT NOT NULL, -- provider|patient|observer
admit_state TEXT NOT NULL DEFAULT 'waiting',
admitted_at TIMESTAMPTZ
);
ALTER TABLE communication.virtual_participants ENABLE ROW LEVEL SECURITY;
CREATE POLICY vp_tenant_isolation ON communication.virtual_participants
USING (tenant_id = current_setting('app.current_tenant'));

-- outbox / inbox (platform-standard)
CREATE TABLE communication.outbox (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
subject TEXT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX ix_outbox_unpublished ON communication.outbox (published_at) WHERE published_at IS NULL;

CREATE TABLE communication.inbox (
event_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
subject TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (event_id, tenant_id)
);

-- idempotency
CREATE TABLE communication.idempotency_keys (
tenant_id TEXT NOT NULL,
key TEXT NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant_id, key)
);

4. Notable constraints

  • thread_participants enforces a participant appears at most once per thread.
  • notification_intents uniqueness (tenant_id, correlation_id, channel, recipient_ref) enforces intent idempotency.
  • virtual_sessions uniqueness (tenant_id, appointment_id) prevents duplicate session creation from re-delivered scheduling events.
  • message_attachments.scan_status must be clean before the attachment is attached to a message (enforced in application layer).

5. Data classification

TableClassEncryption
messages.bodyPHI (high)At-rest (pg TDE or column-level pgcrypto); TLS in transit
message_attachments.file_refPHI (high)Blob at-rest encryption + short-lived signed URLs
notification_intents.variablesPII (medium)At-rest; must never include PHI
dispatch_records.provider_message_idOperationalPlain
virtual_sessions.room_refOperationalPlain (no PHI in ref)