ADR-003: EMR Abstraction Layer¶
Architecture Decision Record for Multi-EMR Support
Status: Accepted Date: January 14, 2026 Epic: 1.2 Implementation: Complete (Phase 1 + Phase 2)
Context¶
VitaraVox currently has tight coupling to OSCAR EMR through:
- Hardcoded endpoint paths (/ws/rs/*, /demographics)
- OSCAR-specific field names (demographicNo, providerNo)
- OAuth 1.0a credential structure in database schema
- Availability detection assumptions (empty demographicNo = available)
- Single-char appointment type codes
To support multiple EMR systems (Oscar Universal, Oscar Pro, Telus Health, Juno), we need an abstraction layer.
Decision¶
Implement the EMR Adapter Pattern with a unified interface that all EMR integrations must implement.
Architecture Overview (Phase 2 Complete)¶
┌─────────────────────────────────────────────────────────────────────────────────┐
│ VOICE AGENT (vapiEndpoints.js) │
│ │
│ search_patient find_appointment book_appointment get_providers │
└─────────────────────────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ EMR SERVICE LAYER (Phase 2) │
│ services/EmrService.js │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ FEATURE FLAGS │ │
│ │ ┌────────────────────┐ ┌────────────────────────────────────────┐ │ │
│ │ │ USE_EMR_ADAPTER │ │ EMR_ADAPTER_CLINICS │ │ │
│ │ │ (global toggle) │ │ (per-clinic rollout) │ │ │
│ │ └────────────────────┘ └────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Legacy Fallback │◄────OR───│ EmrAdapterFactory │ │
│ │ oscarRestService │ │ (Clinic Config → Adapter) │ │
│ │ (if flag=false) │ │ │ │
│ └────────────────────┘ └────────────────┬───────────────────┘ │
│ │ │
└────────────────────────────────────────────────────┼─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ EMR ADAPTER INTERFACE (Phase 1) │
│ adapters/IEmrAdapter.js │
│ │
│ IEmrAdapter { │
│ searchPatient(query) getAvailability(providerId, date) │
│ getPatient(id) getAppointments(filter) │
│ createPatient(data) createAppointment(data) │
│ getProviders() updateAppointment(id, data) │
│ getProvider(id) cancelAppointment(id) │
│ healthCheck() getCircuitStatus() │
│ } │
└─────────────────────────────────────┬────────────────────────────────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ OscarUniversalAdapter│ │ OscarProAdapter │ │ TelusHealthAdapter │
│ ✅ IMPLEMENTED │ │ 📋 PLANNED │ │ 📋 PLANNED │
│ │ │ │ │ │
│ - REST Bridge │ │ - OAuth 1.0a │ │ - FHIR R4 │
│ - API Key auth │ │ - Direct SOAP/REST │ │ - OAuth 2.0 │
│ - Circuit breaker │ │ - SOAP for schedule│ │ - Native schedule │
│ - 5-min cache │ │ │ │ │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
│ │ │
│ TRANSFORMERS │
│ ┌────────────────────────────────┐ │
│ │ PatientTransformer │ │
│ │ ProviderTransformer │ │
│ │ AppointmentTransformer │ │
│ │ (EMR ↔ Canonical format) │ │
│ └────────────────────────────────┘ │
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ OSCAR Dev Instance │ │ OSCAR Pro Instance │ │ Telus Health EMR │
│ (REST-SOAP Bridge) │ │ (Native API) │ │ (FHIR API) │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
EMR Adapter Interface¶
TypeScript Interface Definition¶
// adapters/IEmrAdapter.ts
export interface PatientSearchQuery {
phone?: string;
lastName?: string;
firstName?: string;
healthCardNumber?: string;
dateOfBirth?: string;
}
export interface Patient {
id: string; // EMR-agnostic ID (was demographicNo)
firstName: string;
lastName: string;
dateOfBirth: string; // YYYY-MM-DD
gender?: 'M' | 'F' | 'O';
phone?: string;
email?: string;
address?: {
street: string;
city: string;
province: string;
postalCode: string;
country: string;
};
healthCardNumber?: string;
healthCardProvince?: string;
}
export interface Provider {
id: string; // EMR-agnostic ID (was providerNo)
firstName: string;
lastName: string;
displayName: string; // "Dr. Sarah Anderson"
specialty?: string;
acceptingNewPatients: boolean;
}
export interface TimeSlot {
startTime: string; // ISO 8601: 2026-01-14T09:00:00
endTime: string; // ISO 8601: 2026-01-14T09:15:00
duration: number; // Minutes
status: 'available' | 'booked' | 'blocked';
appointmentType?: string; // EMR-agnostic type code
description?: string; // Human-readable type name
}
export interface Availability {
providerId: string;
date: string; // YYYY-MM-DD
holiday: boolean;
timeSlots: TimeSlot[];
}
export interface Appointment {
id: string; // EMR-agnostic ID (was appointmentNo)
patientId: string;
providerId: string;
startTime: string; // ISO 8601
endTime: string; // ISO 8601
duration: number;
status: 'scheduled' | 'confirmed' | 'cancelled' | 'completed' | 'no_show';
reason?: string;
notes?: string;
appointmentType?: string;
}
export interface AppointmentCreateRequest {
patientId: string;
providerId: string;
startTime: string; // ISO 8601
duration: number;
reason?: string;
appointmentType?: string;
notes?: string;
}
export interface EmrHealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
latencyMs?: number;
services?: Record<string, 'connected' | 'disconnected'>;
version?: string;
}
export interface IEmrAdapter {
// Metadata
readonly emrType: string; // 'oscar-universal' | 'oscar-pro' | 'telus-health'
readonly version: string;
// Patient Operations
searchPatient(query: PatientSearchQuery): Promise<Patient[]>;
getPatient(id: string): Promise<Patient | null>;
createPatient(data: Omit<Patient, 'id'>): Promise<Patient>;
// Provider Operations
getProviders(): Promise<Provider[]>;
getProvider(id: string): Promise<Provider | null>;
// Schedule Operations
getAvailability(providerId: string, date: string): Promise<Availability>;
getAppointmentTypes(): Promise<{ code: string; name: string; duration: number }[]>;
// Appointment Operations
getAppointments(filter: {
patientId?: string;
providerId?: string;
startDate?: string;
endDate?: string;
}): Promise<Appointment[]>;
createAppointment(data: AppointmentCreateRequest): Promise<Appointment>;
updateAppointment(id: string, data: Partial<Appointment>): Promise<Appointment>;
cancelAppointment(id: string): Promise<boolean>;
// Health Check
healthCheck(): Promise<EmrHealthStatus>;
}
Impact to Current Architecture¶
Files Requiring Modification¶
| File | Current State | Required Changes | Risk |
|---|---|---|---|
| vapiEndpoints.js | Direct oscarRestService calls | Use adapter factory, generic field names | High - 14 endpoints |
| oscarRestService.js | REST Bridge client | Refactor into OscarUniversalAdapter | Medium |
| oscarService.js | Legacy OAuth client | Refactor into OscarProAdapter | Medium |
| clinicRouter.js | Loads OSCAR credentials | Load EMR type + credentials | Medium |
| vitaraDb.js | OSCAR-specific schema | Add emr_type column | Low |
| utils.js | OSCAR-specific formatting | EMR-agnostic formatters | Low |
Database Schema Changes¶
-- Add EMR type to clinics table
ALTER TABLE clinics ADD COLUMN emr_type VARCHAR(50) DEFAULT 'oscar-universal';
ALTER TABLE clinics ADD COLUMN emr_config JSONB DEFAULT '{}';
-- emr_config structure varies by type:
-- oscar-universal: { "bridgeUrl": "...", "apiKey": "..." }
-- oscar-pro: { "baseUrl": "...", "oauthConsumerKey": "...", ... }
-- telus-health: { "fhirEndpoint": "...", "clientId": "...", "clientSecret": "..." }
New Files to Create¶
/opt/vitara-platform/voice-agent/
├── adapters/
│ ├── IEmrAdapter.js # Interface definition
│ ├── EmrAdapterFactory.js # Factory to create adapters
│ ├── OscarUniversalAdapter.js # Current REST Bridge
│ ├── OscarProAdapter.js # OAuth 1.0a direct
│ └── BaseEmrAdapter.js # Common functionality
├── transformers/
│ ├── PatientTransformer.js # EMR → Canonical patient
│ ├── AppointmentTransformer.js
│ └── ProviderTransformer.js
└── __tests__/
├── adapters/
│ ├── OscarUniversalAdapter.test.js
│ └── EmrAdapterFactory.test.js
└── transformers/
└── PatientTransformer.test.js
Implementation Phases¶
Phase 1: Interface & Factory (Week 1)¶
- Create
IEmrAdapterinterface - Create
EmrAdapterFactory - Create
BaseEmrAdapterwith common functionality - Wrap current
oscarRestService.jsasOscarUniversalAdapter
Phase 2: Refactor Voice Agent (Week 2)¶
- Update
clinicRouter.jsto load EMR type - Refactor
vapiEndpoints.jsto use adapter factory - Update database schema
- Update field names throughout
Phase 3: Testing & Documentation (Week 3)¶
- Unit tests for all adapters
- Integration tests with mock EMR
- E2E tests with real OSCAR instance
- Documentation updates
Test Plan¶
Unit Tests¶
1. EmrAdapterFactory Tests¶
// __tests__/adapters/EmrAdapterFactory.test.js
describe('EmrAdapterFactory', () => {
describe('createAdapter()', () => {
it('should create OscarUniversalAdapter for oscar-universal type', () => {
const config = { emrType: 'oscar-universal', bridgeUrl: '...', apiKey: '...' };
const adapter = EmrAdapterFactory.createAdapter(config);
expect(adapter.emrType).toBe('oscar-universal');
});
it('should create OscarProAdapter for oscar-pro type', () => {
const config = { emrType: 'oscar-pro', baseUrl: '...', oauth: {...} };
const adapter = EmrAdapterFactory.createAdapter(config);
expect(adapter.emrType).toBe('oscar-pro');
});
it('should throw for unknown EMR type', () => {
const config = { emrType: 'unknown-emr' };
expect(() => EmrAdapterFactory.createAdapter(config)).toThrow('Unsupported EMR type');
});
it('should validate required config fields', () => {
const config = { emrType: 'oscar-universal' }; // Missing bridgeUrl
expect(() => EmrAdapterFactory.createAdapter(config)).toThrow('bridgeUrl is required');
});
});
});
2. OscarUniversalAdapter Tests¶
// __tests__/adapters/OscarUniversalAdapter.test.js
describe('OscarUniversalAdapter', () => {
let adapter;
let mockAxios;
beforeEach(() => {
mockAxios = { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() };
adapter = new OscarUniversalAdapter({
bridgeUrl: 'http://localhost:3000/api/v1',
apiKey: 'test-key'
});
adapter.client = mockAxios;
});
describe('searchPatient()', () => {
it('should search by phone number', async () => {
mockAxios.get.mockResolvedValue({
data: { success: true, data: [{ demographicNo: 78, firstName: 'John', lastName: 'Doe' }] }
});
const result = await adapter.searchPatient({ phone: '6045551234' });
expect(mockAxios.get).toHaveBeenCalledWith('/demographics/search', {
params: { phone: '6045551234' }
});
expect(result).toHaveLength(1);
expect(result[0].id).toBe('78'); // Transformed from demographicNo
});
it('should search by name', async () => {
mockAxios.get.mockResolvedValue({
data: { success: true, data: [{ demographicNo: 79, firstName: 'Jane', lastName: 'Smith' }] }
});
const result = await adapter.searchPatient({ lastName: 'Smith', firstName: 'Jane' });
expect(mockAxios.get).toHaveBeenCalledWith('/demographics/search', {
params: { name: 'Smith', firstName: 'Jane' }
});
});
it('should return empty array when no patients found', async () => {
mockAxios.get.mockResolvedValue({ data: { success: true, data: [] } });
const result = await adapter.searchPatient({ phone: '0000000000' });
expect(result).toEqual([]);
});
it('should handle API errors gracefully', async () => {
mockAxios.get.mockRejectedValue(new Error('Connection refused'));
await expect(adapter.searchPatient({ phone: '6045551234' }))
.rejects.toThrow('EMR connection failed');
});
});
describe('getPatient()', () => {
it('should retrieve patient by ID', async () => {
mockAxios.get.mockResolvedValue({
data: { success: true, data: { demographicNo: 78, firstName: 'John', lastName: 'Doe' } }
});
const result = await adapter.getPatient('78');
expect(mockAxios.get).toHaveBeenCalledWith('/demographics/78');
expect(result.id).toBe('78');
});
it('should return null for non-existent patient', async () => {
mockAxios.get.mockResolvedValue({ data: { success: false, error: 'Not found' } });
const result = await adapter.getPatient('999999');
expect(result).toBeNull();
});
});
describe('createPatient()', () => {
it('should create patient with admission record', async () => {
mockAxios.post.mockResolvedValue({
data: { success: true, data: { demographicNo: 80, admissionId: 72, programId: 10034 } }
});
const patientData = {
firstName: 'New',
lastName: 'Patient',
dateOfBirth: '1990-05-15',
phone: '6045559999'
};
const result = await adapter.createPatient(patientData);
expect(mockAxios.post).toHaveBeenCalledWith('/demographics', expect.objectContaining({
firstName: 'New',
lastName: 'Patient'
}));
expect(result.id).toBe('80');
});
it('should handle duplicate patient error', async () => {
mockAxios.post.mockResolvedValue({
data: { success: false, error: 'Duplicate patient', status: 409 }
});
await expect(adapter.createPatient({ firstName: 'Dup', lastName: 'Patient' }))
.rejects.toThrow('Patient already exists');
});
});
describe('getAvailability()', () => {
it('should return available time slots', async () => {
mockAxios.get.mockResolvedValue({
data: {
success: true,
data: {
providerId: '100',
date: '2026-01-15',
holiday: false,
timeSlots: [
{ startTime: '09:00', endTime: '09:15', status: 'available', code: 'B' },
{ startTime: '09:15', endTime: '09:30', status: 'available', code: 'B' }
]
}
}
});
const result = await adapter.getAvailability('100', '2026-01-15');
expect(result.timeSlots).toHaveLength(2);
expect(result.timeSlots[0].status).toBe('available');
});
it('should handle no schedule configured', async () => {
mockAxios.get.mockResolvedValue({
data: { success: true, data: { providerId: '129', date: '2026-01-15', timeSlots: [] } }
});
const result = await adapter.getAvailability('129', '2026-01-15');
expect(result.timeSlots).toEqual([]);
});
});
describe('createAppointment()', () => {
it('should create appointment and return confirmation', async () => {
mockAxios.post.mockResolvedValue({
data: { success: true, data: { appointmentNo: 99001 } }
});
const apptData = {
patientId: '78',
providerId: '100',
startTime: '2026-01-15T09:00:00',
duration: 15,
reason: 'Follow-up'
};
const result = await adapter.createAppointment(apptData);
expect(result.id).toBe('99001');
});
it('should handle slot already booked', async () => {
mockAxios.post.mockResolvedValue({
data: { success: false, error: 'Slot unavailable', status: 409 }
});
await expect(adapter.createAppointment({
patientId: '78',
providerId: '100',
startTime: '2026-01-15T09:00:00',
duration: 15
})).rejects.toThrow('Time slot is no longer available');
});
});
describe('cancelAppointment()', () => {
it('should cancel appointment successfully', async () => {
mockAxios.delete.mockResolvedValue({ data: { success: true } });
const result = await adapter.cancelAppointment('99001');
expect(mockAxios.delete).toHaveBeenCalledWith('/appointments/99001');
expect(result).toBe(true);
});
});
describe('healthCheck()', () => {
it('should return healthy status when all services connected', async () => {
mockAxios.get.mockResolvedValue({
data: {
status: 'healthy',
services: { soap: 'connected', database: 'connected' }
}
});
const result = await adapter.healthCheck();
expect(result.status).toBe('healthy');
});
it('should return degraded status when service down', async () => {
mockAxios.get.mockResolvedValue({
data: {
status: 'degraded',
services: { soap: 'disconnected', database: 'connected' }
}
});
const result = await adapter.healthCheck();
expect(result.status).toBe('degraded');
});
});
});
3. Patient Transformer Tests¶
// __tests__/transformers/PatientTransformer.test.js
describe('PatientTransformer', () => {
describe('fromOscarUniversal()', () => {
it('should transform OSCAR response to canonical Patient', () => {
const oscarPatient = {
demographicNo: 78,
firstName: 'John',
lastName: 'Doe',
dateOfBirth: '1985-03-15',
sex: 'M',
phone: '6045551234',
email: 'john@example.com',
address: '123 Main St',
city: 'Vancouver',
province: 'BC',
postal: 'V6B1A1',
hin: '9876543210'
};
const result = PatientTransformer.fromOscarUniversal(oscarPatient);
expect(result).toEqual({
id: '78',
firstName: 'John',
lastName: 'Doe',
dateOfBirth: '1985-03-15',
gender: 'M',
phone: '6045551234',
email: 'john@example.com',
address: {
street: '123 Main St',
city: 'Vancouver',
province: 'BC',
postalCode: 'V6B1A1',
country: 'CA'
},
healthCardNumber: '9876543210',
healthCardProvince: 'BC'
});
});
it('should handle missing optional fields', () => {
const oscarPatient = {
demographicNo: 79,
firstName: 'Jane',
lastName: 'Smith'
};
const result = PatientTransformer.fromOscarUniversal(oscarPatient);
expect(result.id).toBe('79');
expect(result.phone).toBeUndefined();
expect(result.address).toBeUndefined();
});
});
describe('toOscarUniversal()', () => {
it('should transform canonical Patient to OSCAR format', () => {
const patient = {
firstName: 'New',
lastName: 'Patient',
dateOfBirth: '1990-05-20',
gender: 'F',
phone: '6045559999',
address: {
street: '456 Oak Ave',
city: 'Victoria',
province: 'BC',
postalCode: 'V8W2A1',
country: 'CA'
}
};
const result = PatientTransformer.toOscarUniversal(patient);
expect(result).toEqual({
firstName: 'New',
lastName: 'Patient',
dateOfBirth: '1990-05-20',
sex: 'F',
phone: '6045559999',
address: '456 Oak Ave',
city: 'Victoria',
province: 'BC',
postal: 'V8W2A1'
});
});
});
});
Integration Tests¶
// __tests__/integration/EmrAdapter.integration.test.js
describe('EMR Adapter Integration Tests', () => {
let adapter;
beforeAll(() => {
// Use test REST Bridge instance
adapter = EmrAdapterFactory.createAdapter({
emrType: 'oscar-universal',
bridgeUrl: process.env.TEST_OSCAR_BRIDGE_URL || 'http://15.222.50.48:3000/api/v1',
apiKey: process.env.TEST_OSCAR_API_KEY
});
});
describe('Patient Workflow', () => {
it('should search for existing patient by phone', async () => {
const patients = await adapter.searchPatient({ phone: '2367770690' });
expect(patients.length).toBeGreaterThan(0);
expect(patients[0]).toHaveProperty('id');
expect(patients[0]).toHaveProperty('firstName');
});
it('should get patient details by ID', async () => {
const patient = await adapter.getPatient('78');
expect(patient).not.toBeNull();
expect(patient.firstName).toBe('Charles');
expect(patient.lastName).toBe('Don');
});
});
describe('Provider Workflow', () => {
it('should list all active providers', async () => {
const providers = await adapter.getProviders();
expect(providers.length).toBeGreaterThan(0);
expect(providers[0]).toHaveProperty('id');
expect(providers[0]).toHaveProperty('displayName');
});
});
describe('Appointment Workflow', () => {
it('should get provider availability', async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = tomorrow.toISOString().split('T')[0];
const availability = await adapter.getAvailability('100', dateStr);
expect(availability).toHaveProperty('providerId', '100');
expect(availability).toHaveProperty('timeSlots');
});
it('should complete full booking cycle', async () => {
// 1. Get availability
const availability = await adapter.getAvailability('100', '2026-01-20');
const availableSlot = availability.timeSlots.find(s => s.status === 'available');
if (!availableSlot) {
console.log('No available slots for booking test');
return;
}
// 2. Create appointment
const appointment = await adapter.createAppointment({
patientId: '78',
providerId: '100',
startTime: `2026-01-20T${availableSlot.startTime}:00`,
duration: 15,
reason: 'Integration Test'
});
expect(appointment).toHaveProperty('id');
// 3. Cancel appointment (cleanup)
const cancelled = await adapter.cancelAppointment(appointment.id);
expect(cancelled).toBe(true);
});
});
});
End-to-End Tests¶
// __tests__/e2e/VoiceAgentE2E.test.js
describe('Voice Agent E2E Tests', () => {
const baseUrl = process.env.VOICE_AGENT_URL || 'https://api-dev.vitaravox.ca:9443';
const bearerToken = process.env.VAPI_BEARER_TOKEN;
describe('Patient Search Flow', () => {
it('should find patient by phone through voice endpoint', async () => {
const response = await fetch(`${baseUrl}/api/vapi/search-patient-by-phone`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${bearerToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: {
toolCalls: [{
function: {
arguments: { phone: '2367770690' }
}
}]
}
})
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.results[0].result).toContain('found');
});
it('should return not found for unknown phone', async () => {
const response = await fetch(`${baseUrl}/api/vapi/search-patient-by-phone`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${bearerToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: {
toolCalls: [{
function: {
arguments: { phone: '0000000000' }
}
}]
}
})
});
const data = await response.json();
expect(data.results[0].result).toContain('not found');
});
});
describe('Appointment Booking Flow', () => {
it('should find earliest appointment for provider', async () => {
const response = await fetch(`${baseUrl}/api/vapi/find-earliest`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${bearerToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: {
toolCalls: [{
function: {
arguments: { providerId: '100' }
}
}]
}
})
});
const data = await response.json();
expect(response.status).toBe(200);
// Should return either appointment or "no availability"
});
});
describe('Multi-Clinic Routing', () => {
it('should route to correct clinic based on phone', async () => {
// This tests that the adapter factory loads the right adapter for each clinic
const response = await fetch(`${baseUrl}/api/vapi/get-clinic-info`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${bearerToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: {
call: {
customer: { number: '+16045550001' }
},
toolCalls: [{
function: { arguments: {} }
}]
}
})
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.results[0].result).toContain('clinic');
});
});
});
Test Coverage Requirements¶
| Category | Target Coverage | Metrics |
|---|---|---|
| Unit Tests | 90% | Lines, branches, functions |
| Integration Tests | 80% | API endpoints |
| E2E Tests | 100% of happy paths | Voice workflows |
Test Pyramid¶
▲
/│\
/ │ \ E2E Tests (10%)
/ │ \ - Full voice workflows
/───┼───\ - Multi-clinic routing
/ │ \
/ │ \ Integration Tests (30%)
/ │ \ - Adapter + Real EMR
/───────┼───────\ - Database operations
/ │ \
/ │ \ Unit Tests (60%)
/ │ \ - Adapters
/───────────┼───────────\- Transformers
/ │ \- Factory
──────────────┴──────────────
Rollout Strategy¶
Feature Flags¶
// config/featureFlags.js
module.exports = {
USE_EMR_ADAPTER: process.env.USE_EMR_ADAPTER === 'true' || false,
EMR_ADAPTER_CLINICS: process.env.EMR_ADAPTER_CLINICS?.split(',') || []
};
Gradual Rollout¶
- Alpha (Week 1): Single test clinic with oscar-universal adapter
- Beta (Week 2): 3 clinics, both OSCAR types
- GA (Week 3): All clinics migrated to adapter pattern
Rollback Plan¶
# Immediate rollback: Set feature flag to false
export USE_EMR_ADAPTER=false
docker restart vitara-voice-agent
# Full rollback: Revert to previous container version
docker pull vitara-voice-agent:v1.3.2
docker-compose up -d
Success Criteria¶
| Metric | Target | Measurement |
|---|---|---|
| Test Coverage | >85% | Jest coverage report |
| API Latency | <500ms p95 | Health endpoint metrics |
| Error Rate | <1% | Log analysis |
| Booking Success | >95% | Call outcome tracking |
| Zero Downtime | 100% | During migration |