Skip to content
4 changes: 2 additions & 2 deletions modules/vaos/app/models/vaos/v2/unified/base_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ module VAOS
module V2
module Unified
class BaseProvider
attr_accessor :id, :name, :address, :phone, :latitude, :longitude,
:provider_type, :schedulable_services, :distance_from_user
attr_accessor :id, :name, :facility_name, :address, :phone, :latitude, :longitude,
Comment thread
JunTaoLuo marked this conversation as resolved.
:provider_type, :distance_from_user

def initialize(attrs = {})
attrs.each { |key, value| send(:"#{key}=", value) if respond_to?(:"#{key}=") }
Expand Down
41 changes: 33 additions & 8 deletions modules/vaos/app/models/vaos/v2/unified/eps_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,53 @@ module V2
module Unified
class EpsProvider < BaseProvider
attr_accessor :provider_service_id, :network_id, :npi, :specialties,
:digital_booking_features
:digital_booking_features, :appointment_types

def initialize(attrs = {})
super
self.provider_type = 'community_care'
end

##
# First self-schedulable EPS appointment type id
#
# @return [String]
# @raise [Common::Exceptions::BackendServiceException] when types are missing or none self-schedulable
#
def first_self_schedulable_appointment_type_id!
Comment thread
JunTaoLuo marked this conversation as resolved.
if appointment_types.blank?
raise Common::Exceptions::BackendServiceException.new(
'PROVIDER_APPOINTMENT_TYPES_MISSING',
{},
502,
'Provider appointment types data is not available'
)
end

self_schedulable = appointment_types.select { |apt| apt[:is_self_schedulable] == true }
if self_schedulable.blank?
raise Common::Exceptions::BackendServiceException.new(
'PROVIDER_SELF_SCHEDULABLE_TYPES_MISSING',
{},
502,
'No self-schedulable appointment types available for this provider'
)
end

self_schedulable.first[:id]
end

Comment thread
JunTaoLuo marked this conversation as resolved.
# Builds an EpsProvider from an EPS provider service response (Hash or OpenStruct)
def self.from_eps_provider_service(provider)
provider = provider.to_h if provider.is_a?(OpenStruct)
location = provider[:location] || {}
practice_name = location[:name].presence || provider[:name]
Comment thread
JunTaoLuo marked this conversation as resolved.

new(
id: provider[:id],
provider_service_id: provider[:id],
name: provider[:name],
facility_name: practice_name,
address: parse_eps_address(location[:address]),
phone: extract_phone(provider),
latitude: location[:latitude],
Expand All @@ -29,7 +60,7 @@ def self.from_eps_provider_service(provider)
specialties: provider[:specialties] || [],
network_id: provider[:network_ids]&.first,
digital_booking_features: provider[:features],
schedulable_services: extract_specialties(provider[:specialties])
appointment_types: provider[:appointment_types] || []
)
end

Expand Down Expand Up @@ -62,12 +93,6 @@ def self.extract_npi(provider)

providers.first&.dig(:npi)
end

def self.extract_specialties(specialties)
return [] if specialties.blank?

specialties.map { |s| s[:name] }.compact
end
end
end
end
Expand Down
32 changes: 15 additions & 17 deletions modules/vaos/app/models/vaos/v2/unified/va_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,34 @@
module VAOS
module V2
module Unified
# Represents a single VA schedulable clinic (clinic IEN) at a Lighthouse facility.
# +id+ is the clinic IEN. +location_id+ is the parent facility +unique_id+ used with VAOS
# clinics and slots APIs.
class VAProvider < BaseProvider
attr_accessor :location_id, :facility_type, :scheduling_config
attr_accessor :location_id, :facility_type

def initialize(attrs = {})
super
self.provider_type = 'va'
end

# Builds a VAProvider from a Lighthouse Facility object
# (FacilitiesApi::V2::Lighthouse::Facility)
def self.from_lighthouse_facility(facility)
##
# @param facility [FacilitiesApi::V2::Lighthouse::Facility] parent facility (address, geo, phone)
# @param clinic [OpenStruct, Hash] VAOS clinic payload from SystemsService#get_facility_clinics
#
def self.from_facility_and_clinic(facility, clinic)
clinic = clinic.to_h if clinic.is_a?(OpenStruct)

new(
id: facility.unique_id,
id: clinic[:id],
location_id: facility.unique_id,
name: facility.name,
name: clinic[:service_name],
facility_name: facility.name,
Comment thread
JunTaoLuo marked this conversation as resolved.
address: parse_lighthouse_address(facility.address),
phone: facility.phone&.dig('main'),
latitude: facility.lat,
longitude: facility.long,
facility_type: facility.facility_type,
schedulable_services: parse_lighthouse_services(facility.services)
facility_type: facility.facility_type
)
end

Expand All @@ -42,15 +49,6 @@ def self.parse_lighthouse_address(address_hash)
zip: physical['zip']
}
end

def self.parse_lighthouse_services(services_hash)
return [] if services_hash.blank?

health = services_hash['health'] || services_hash[:health]
return [] if health.blank?

health.filter_map { |svc| svc['serviceId'] || svc['service_id'] || svc[:service_id] }
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ def serialize(providers, referral_npi: nil)
type: 'unified_provider',
attributes: {
name: provider.name,
facilityName: provider.facility_name,
providerType: provider.provider_type,
isReferralProvider: referral_provider?(provider, referral_npi),
address: serialize_address(provider.address),
phone: provider.phone,
latitude: provider.latitude,
longitude: provider.longitude,
distanceInMiles: provider.distance_from_user&.round(1),
schedulableServices: provider.schedulable_services || [],
sortOrder: index
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,15 @@ def initialize(current_user)
end

##
# Checks patient eligibility for a specific service type at a VA facility
# selected from the unified provider list. Maps the referral's category of care
# from Lighthouse format to VAOS format, then checks whether the patient is
# eligible for direct scheduling.
# Checks patient eligibility for a specific service type at a VA facility.
# Maps the referral's category of care from Lighthouse format to VAOS format,
# then checks whether the patient is eligible for direct scheduling.
#
# @param va_provider [VAOS::V2::Unified::VAProvider] selected VA facility from provider search
# @param facility_id [String] VA facility identifier (Lighthouse +unique_id+, same as VAOS location)
# @param category_of_care [String] Lighthouse service type from the referral (e.g., 'primaryCare')
# @return [Hash] with :facility_id, :vaos_service_type, and :direct_eligible
#
def check_eligibility(va_provider, category_of_care)
facility_id = va_provider.location_id
def check_eligibility(facility_id:, category_of_care:)
vaos_service_type = ServiceTypeMapper.to_vaos(category_of_care)

if vaos_service_type.nil?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ def initialize(current_user)
end

##
# Searches for both VA facilities and EPS CC providers near the user's address,
# filtered by the referral's category of care. Pins the referral's matched CC
# provider at the top, then sorts remaining results by distance.
# Searches for VA clinics (via Lighthouse facilities + VAOS clinics) and EPS CC providers
# near the user's address, filtered by the referral's category of care. Pins the referral's
# matched CC provider at the top, then sorts remaining results by distance.
#
# @param referral [Object] A CCRA referral object with category_of_care, provider NPI, etc.
# @param radius [Integer] Search radius in miles (default: 25)
Expand Down Expand Up @@ -69,9 +69,7 @@ def fetch_va_providers(user_address, referral, radius, lh_client:)
per_page: 50
)

providers = facilities.map { |f| VAOS::V2::Unified::VAProvider.from_lighthouse_facility(f) }
providers.each { |p| assign_distance(p, user_address) }
filter_va_by_category_of_care(providers, referral.category_of_care)
fetch_providers_for_facilities(facilities, referral, user_address)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the UX flow and guessing at how this service is to be used, we'll want to produce a list of VA providers which are clinics not facilities. There really isn't any use for raw VA facilities so I'm updating the logic here to:

  1. check eligibility at each facility
  2. fetch clinics at each eligible facility

rescue => e
Rails.logger.error("#{log_prefix}: VA facility search failed",
{
Expand All @@ -84,6 +82,19 @@ def fetch_va_providers(user_address, referral, radius, lh_client:)
[]
end

def fetch_providers_for_facilities(facilities, referral, user_address)
matching_facilities = filter_supported_facilities(facilities, referral.category_of_care)
clinical_service = ServiceTypeMapper.to_vaos(referral.category_of_care)

matching_facilities.flat_map do |facility|
fetch_clinics_for_facility(facility, clinical_service).map do |clinic|
provider = VAProvider.from_facility_and_clinic(facility, clinic)
assign_distance(provider, user_address)
provider
end
end
Comment thread
JunTaoLuo marked this conversation as resolved.
end

def fetch_eps_providers(user_address, referral, radius, eps_client:)
providers = eps_client.search_by_location(
latitude: user_address.latitude,
Expand Down Expand Up @@ -127,18 +138,45 @@ def assign_distance(provider, user_address)
)
end

def filter_va_by_category_of_care(providers, category_of_care)
return providers if category_of_care.blank?
def filter_supported_facilities(facilities, category_of_care)
return facilities if category_of_care.blank?

normalized = category_of_care.to_s.downcase.gsub(/[\s_-]+/, '')
vaos_service_type = ServiceTypeMapper.to_vaos(category_of_care)
return facilities if vaos_service_type.nil?

providers.select do |provider|
provider.schedulable_services.any? do |svc|
svc.to_s.downcase.gsub(/[\s_-]+/, '') == normalized
end
facilities.select do |facility|
eligibility_service.check_eligibility(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI I updated this section to use the eligibility checks instead since this is closer to the production behavior.

facility_id: facility.unique_id,
category_of_care:
)[:direct_eligible]
end
Comment thread
JunTaoLuo marked this conversation as resolved.
end

def fetch_clinics_for_facility(facility, clinical_service)
systems_service.get_facility_clinics(
location_id: facility.unique_id,
clinical_service:
)
rescue => e
Rails.logger.warn(
"#{log_prefix}: Clinic fetch failed for facility #{facility.unique_id}",
{
error_class: e.class.name,
clinical_service:,
user_uuid: @cached_user_uuid
}.compact
)
[]
end

def systems_service
@systems_service ||= VAOS::V2::SystemsService.new(current_user)
end

def eligibility_service
@eligibility_service ||= EligibilityService.new(current_user)
end

def combine_and_sort(va_providers, eps_providers, referral)
referral_provider, other_eps = partition_referral_provider(eps_providers, referral)

Expand Down
88 changes: 88 additions & 0 deletions modules/vaos/app/services/vaos/v2/unified/slots_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

module VAOS
module V2
module Unified
# Fetches appointment availability (slots) for unified scheduling: VA clinic providers via
# VAOS SystemsService, community care via EPS ProviderService. VA providers must include
# +location_id+ and +id+ (clinic IEN; see VAProvider).
class SlotsService
def initialize(user)
@user = user
end

##
# @param provider [BaseProvider] VAProvider or EpsProvider
# @param start_dt [String] ISO8601 start of search window (VA and EPS)
# @param end_dt [String] ISO8601 end of search window (VA and EPS)
# @param clinical_service [String, nil] VAOS clinical service (required for VA)
# @param appointment_id [String, nil] EPS draft appointment id (required for EPS)
# @return [Array<BaseSlot>]
#
# EPS +appointmentTypeId+ comes from +EpsProvider#first_self_schedulable_appointment_type_id!+
# (EPS +appointment_types+ on the provider).
#
def slots_for(provider:, start_dt:, end_dt:, clinical_service: nil, appointment_id: nil)
case provider
when VAProvider
Comment thread
PhilipDeFraties marked this conversation as resolved.
fetch_va_slots(provider, start_dt, end_dt, clinical_service)
when EpsProvider
fetch_eps_slots(provider, start_dt, end_dt, appointment_id)
else
raise ArgumentError, "Unsupported provider type: #{provider.class.name}"
end
end

private

attr_reader :user

def fetch_va_slots(provider, start_dt, end_dt, clinical_service)
raise Common::Exceptions::ParameterMissing, 'clinical_service' if clinical_service.blank?

if provider.location_id.blank? || provider.id.blank?
raise Common::Exceptions::UnprocessableEntity.new(
detail: 'VA provider requires location_id and id (clinic IEN) for slot lookup'
)
end

raw = systems_service.get_available_slots(
location_id: provider.location_id,
clinic_id: provider.id,
clinical_service:,
provider_id: nil,
start_dt:,
end_dt:
)
Array(raw).map { |slot| VASlot.from_vaos_slot(slot, location_id: provider.location_id) }
end

def fetch_eps_slots(provider, start_dt, end_dt, appointment_id)
Comment thread
JunTaoLuo marked this conversation as resolved.
raise Common::Exceptions::ParameterMissing, 'start_dt' if start_dt.blank?
raise Common::Exceptions::ParameterMissing, 'end_dt' if end_dt.blank?
raise Common::Exceptions::ParameterMissing, 'appointment_id' if appointment_id.blank?

appointment_type_id = provider.first_self_schedulable_appointment_type_id!
provider_id = provider.provider_service_id.presence || provider.id
opts = {
appointmentTypeId: appointment_type_id,
startOnOrAfter: start_dt,
startBefore: end_dt,
appointmentId: appointment_id
}
response = eps_provider_service.get_provider_slots(provider_id, opts)
slots = response&.slots
Array(slots).map { |slot| EpsSlot.from_eps_slot(slot) }
end

def systems_service
@systems_service ||= VAOS::V2::SystemsService.new(user)
end

def eps_provider_service
@eps_provider_service ||= Eps::ProviderService.new(user)
end
end
end
end
end
Loading
Loading