Referrals: CMS create flow + recipient picker
Developer reference for the CMS-side referral creation surface
(POST /clinical/referrals + the unified
GET /clinical/referrals/recipients picker). Pairs with the
polymorphic professional pattern which
covers how Clinical::Referral.referee accommodates both
Identity::Practitioner and Identity::Vendor.
Why this page exists
Clinical::Referral itself is an old model — it predates Connect and
predates vendor self-serve. Its polymorphic referee was added in PR
#1295. PR #1314 layers on the demand-side surface: a CMS UI that
lets clinic doctors create referrals, with a picker that resolves
both practitioners and vendors against a single endpoint. This doc
captures that endpoint's contract and the rationale for shape choices.
Endpoint: GET /clinical/referrals
Tenant-scoped list of referrals sent by the current clinic. Used by
/cms/referrals list page.
Response enhancements (PR #1544)
The ReferralBlueprint now includes:
patient—{ id, name, phone }of the referred patientnotes— free-text notes attached to the referralaccepted_at,completed_at— timestamps set on status transition
These fields were added to support richer CMS list UI with full patient info and status transition timestamps.
Endpoint: GET /connect/me/referrals
Cross-tenant inbox for Connect users. The PersonalReferralBlueprint
also now includes:
patient—{ id, name }(phone excluded for cross-tenant PHI protection). Lookup usesRlsContext.with(referral.tenant_id)to bypass RLS since the Connect scope runs without tenant context.notes,accepted_at,completed_at
Endpoint: GET /clinical/referrals/recipients
Unified search across both polymorphic referee types. Backed by
Clinical::Referrals::RecipientSearchService.
Request
GET /clinical/referrals/recipients?q=anita&type=practitioner&specialty_id=<uuid>&page=1&per_page=20| Param | Optional? | Notes |
|---|---|---|
q | yes | Free-text name search. Powered by Connect::ProfessionalProfile.search_by_name (matches Practitioner.first_name/last_name and Vendor.name/business_name). |
type | yes | practitioner or vendor. Omit for both. |
service_category | yes | Vendor-only. One of Identity::Vendor's service categories (wellness, fitness, education, support, nutrition). Ignored when type=practitioner. |
specialty_id | yes | Practitioner-only. Filters to profiles with this primary specialty. Ignored when type=vendor. |
page, per_page | yes | per_page clamped to 1–50, default 20. |
Response
{
"data": [
{
"id": "uuid-of-the-practitioner-or-vendor",
"type": "practitioner",
"name": "Dr. Anita Bose",
"slug": "anita-bose",
"avatar_url": null,
"specialty": "Cardiology",
"service_category": null,
"facility_name": "Apollo Bangalore",
"city": "Bangalore"
},
{
"id": "...",
"type": "vendor",
"name": "Surya Yoga",
"slug": "surya-yoga",
"avatar_url": null,
"specialty": null,
"service_category": "wellness",
"facility_name": null,
"city": null
}
],
"meta": {
"request_id": "...",
"pagination": { "total_entries": 2, "total_pages": 1, "current_page": 1, "per_page": 20 }
}
}type is the display discriminator — the frontend maps it to
ReferralRefereeType (Identity::Practitioner /
Identity::Vendor) when submitting the create request. See
REFEREE_TYPE_MAP in
frontend/apps/cms/src/features/referrals/pages/ReferralNewPage.tsx.
Eligibility (the same gate the create endpoint enforces)
A recipient is eligible when its underlying record's
connect_can_receive_referrals? predicate would return true:
- Practitioners:
professional_profile.visibility_public? && profile.accepting_referrals - Vendors: same, plus
vendor.status_active?
The picker enforces this in SQL, not in Ruby — both branches join
Connect::ProfessionalProfile filtered to visibility: 'public' and
accepting_referrals: true. Vendor branch additionally inner-joins
identity.vendors and filters status = 'active'. This keeps the
picker results identical to the model-side
referee_must_accept_referrals validator on
Clinical::Referral.create.
# Vendor branch (recipient_search_service.rb)
Connect::ProfessionalProfile
.joins('INNER JOIN identity.vendors ON identity.vendors.id = connect.professional_profiles.professional_id')
.where(professional_type: 'Identity::Vendor',
visibility: 'public',
accepting_referrals: true)
.where('identity.vendors.status = ?', 'active')If you ever add a third polymorphic referee type, the picker needs a
parallel branch that mirrors that type's
connect_can_receive_referrals? predicate in SQL.
Authorization
The endpoint is gated by authorize Clinical::Referral, :create? —
i.e. callers must be tenant members with one of manage_patients,
manage_referrals, or create_referrals. The same gate the actual
create endpoint enforces. Result rows themselves are intrinsically
safe (only publicly listed profiles surface) but the gate prevents
role-less users from enumerating the network.
Cross-tenant by design: the search spans every public Connect profile, regardless of which tenant the caller is signed into.
Pagination strategy
The service uses per-type SQL pagination + Ruby merge:
- SQL
COUNT(*)per type →total_entriesis accurate even with filtering applied. - SQL
LIMIT (offset + per_page)per type — fetch enough rows to satisfy the page after merge. Worst-case in-memory rows are bounded by2 * (offset + per_page)regardless of total system population. - Merge + sort by name in Ruby; slice for the requested page.
Caveat: when type isn't specified, deep pagination across both
types is approximate (a name that should sort to page 2 may appear
+/- a few rows). The picker UX is typeahead, so this is fine. Use
type=practitioner or type=vendor for strict ordering inside one
section.
Self-referral prevention
Defense-in-depth to prevent a practitioner from referring to themselves:
Layer 1 — Model validation (Clinical::Referral):
referee_cannot_be_self checks if the referee Practitioner's
user_id matches the sender's referring_practitioner_id. Catches
direct API abuse (curl, etc.).
Layer 2 — Service (RecipientSearchService):
Passes current_user to exclude the sender's own practitioner from
recipient picker results. Prevents confusing UX of seeing yourself
in the dropdown.
Security hardening (PR #1544)
:statusremoved fromreferral_params— status transitions must go through dedicated endpoints (/accept,/decline,/complete,/cancel) which enforce policy and set timestamps. PATCH cannot bypass.:patient_idblocked on update — patient can only be set at create time; update actions strip it to prevent patient-swapping.tenant_member?added toupdate?policy — consistent with all other policy methods.- Explicit
authorizeonindex— was the only action missing it. RequireAccesson CMS routes —/cms/referralsand/cms/referrals/newnow gated like all other CMS routes.
Orphan handling
Both branches walk
Connect::ProfessionalProfile.{practitioner,professional} to
hydrate the underlying record. If the polymorphic FK points at a
missing record (mid-deletion race, half-applied migration), we
Rails.logger.warn with the profile id + reason, then
next nil from filter_map. The intent is "loud about data
integrity, but don't 500 the picker." Watch the logs for these
warnings — they indicate a deletion path that needs cascade or
soft-delete sync.
Frontend integration
Patient picker (CMS)
The PatientPicker component provides a searchable dropdown for patient
selection on the create form:
import PatientPicker from '../components/PatientPicker'
// In ReferralNewPage
<PatientPicker
value={watch('patient_id')}
onChange={(id) => setValue('patient_id', id)}
/>Stores selection in local state so it persists after search results clear.
When arriving from a patient detail page (?patient_id=), the picker
initialises pre-selected.
Recipient picker (CMS)
import { useReferralRecipients, type ReferralRecipient } from '@kulcare/shared-services'
const { data, isLoading, isError, error, refetch } = useReferralRecipients({
q: debouncedQuery,
type: tab === 'all' ? undefined : tab,
per_page: 25,
})The picker (frontend/apps/cms/src/features/referrals/components/RefereePicker.tsx)
debounces q at 250ms, surfaces error state with a retry button, and
maps the selected recipient's type → ReferralRefereeType for the
create payload.
const REFEREE_TYPE_MAP: Record<ReferralRecipient['type'], ReferralRefereeType> = {
practitioner: 'Identity::Practitioner',
vendor: 'Identity::Vendor',
}ReferralCreateRequest.referee_id and referee_type are required
fields — there's no fallback to the legacy
referred_to_practitioner_id shape.
Referral provenance (#2462)
A referral that turns into a registration keeps a link to the registration. The decision is "link at first registration": the link is written when the referred patient first registers at the referee's tenant, not when the referee accepts.
Data
| Column | Table and tenant | Meaning |
|---|---|---|
source_referral_id, source_referral_tenant_id | clinical.patient_facilities, referee's tenant | The referral that sent the patient. Both set or both null (chk_cl_pf_source_referral_pair). |
patient_registered_at | clinical.referrals, sender's tenant | The time the referred patient first registered with the referee. |
The link is a composite foreign key, fk_cl_pf_source_referral:
(source_referral_tenant_id, source_referral_id) refers to
clinical.referrals (tenant_id, id) with ON DELETE SET NULL. A deleted
referral clears both columns. A foreign key check and its delete action bypass
row security in PostgreSQL, so the reference across two tenants works under
FORCE RLS. The link stores the referral's tenant because the referee cannot
find a referral in another tenant from its id alone.
Write: Clinical::ReferralSourceLinkJob
Two triggers enqueue the job after commit:
Clinical::PatientFacilityafter_create_commit: the patient's first registration in the tenant is created. A later registration enqueues nothing.Clinical::Patientafter_update_commit:identity_patient_idis set. CMS links the global identity after the registration commits, so this second trigger is necessary. Code that setsidentity_patient_idwithupdate_columnsskips that callback, so it must callpatient.enqueue_referral_source_linkitself.FindOrCreateService,ResolveOrProvisionForBookingServiceandPatient::AppointmentOrders::CreateServicedo this.
The job calls Clinical::Referrals::SourceLinker, which does these steps:
- Under the referee's tenant, it reads the patient and its first registration.
It stops when the patient has no
identity_patient_id, or when a registration of the patient in this tenant already has a link. - It finds the practitioners with an active role in this tenant and a Connect
profile. For each one, it reads the referrals addressed to that practitioner
under
RlsContext.with_referee. It keeps the referrals that areacceptedorcompleted, that come from another tenant, and that were accepted in the 90 days before the registration. - For each sender tenant, newest first, under
RlsContext.with(sender_tenant_id), it keeps the referrals whose patient is the same person. It stops when no older tenant can give a newer match. - It links the most recently accepted referral and stamps
patient_registered_atin the sender's tenant. Both writes are guarded UPDATEs, so a retry or a second job is a no-op.
Rules to know:
- Only the first registration in the tenant gets the link. A patient who was registered before the referral was accepted is not linked, and nothing runs the link again when the referral is accepted later.
- The same person means the same
identity_patient_id, the same first name (case and spaces ignored), and no conflict in birth date. CMS finds the identity by phone number, and one phone can belong to a household. - When the referral names
referred_to_facility_id, that facility must belong to the registering tenant. - Vendor referees have no tenant, so they never get a link.
- The lookup has no caps. It runs in a background job, and a cap could drop the one referral that matches.
- The CSV patient import writes registrations with
insert_all, which skips callbacks, so imported patients are not linked. - The job does not rescue errors. The queue retries a failed run.
Read
- Referee: the detailed patient view (
GET /clinical/patients/:id) hasreferral_source:referral_id,referring_practitioner_name,referring_facility_name,referred_at.Clinical::Referrals::SourceSummaryreads the referral under the sender's tenant and returns names and a date only. It never returns the reason, the notes or the sender's patient record. CMS shows it as "Referred by" in the patient header (ReferredByBadge). - Sender:
patient_registered_atis onReferralBlueprint :detailed(GET /clinical/referrals) and onConnect::PersonalReferralBlueprint(GET /connect/me/referrals). The sender reads it from its own tenant. The CMS referral card shows "patient registered", and the Connect sent row shows a "Registered" tag.
The sender learns only that its own referral turned into a registration, and when. It cannot read the referee's patient list.
Files
| Path | What it does |
|---|---|
backend/domains/clinical/controllers/referral_recipients_controller.rb | Pundit gate + service dispatch |
backend/domains/clinical/services/referrals/recipient_search_service.rb | SQL queries + merge + pagination |
backend/domains/clinical/blueprints/referral_recipient_blueprint.rb | Hash-extractor blueprint (no PII) |
backend/domains/clinical/spec/requests/referral_recipients_spec.rb | 10 examples covering eligibility + filters + auth |
backend/domains/clinical/routes.rb | Mounts under resources :referrals do collection ... end |
frontend/apps/cms/src/features/referrals/pages/ReferralsListPage.tsx | Sent-referrals list with patient info + timestamps |
frontend/apps/cms/src/features/referrals/pages/ReferralNewPage.tsx | Create form |
frontend/apps/cms/src/features/referrals/components/PatientPicker.tsx | Searchable patient picker |
frontend/apps/cms/src/features/referrals/components/RefereePicker.tsx | Unified recipient picker |
frontend/packages/shared-services/src/domains/clinical/types.ts | ReferralRecipient, ReferralRefereeType, ReferralRecipientListParams; Referral type with patient, notes, timestamps |
frontend/packages/shared-services/src/domains/connect/types.ts | PersonalReferral type with patient, notes, timestamps, patient_registered_at |
backend/domains/clinical/services/referrals/source_linker.rb | Writes the referral provenance link (#2462) |
backend/domains/clinical/services/referrals/source_summary.rb | "Referred by" for the referee: names and a date only |
backend/domains/clinical/jobs/referral_source_link_job.rb | Runs the link after a registration or an identity link |
frontend/apps/cms/src/features/patientDetail/components/ReferredByBadge.tsx | "Referred by" in the CMS patient header |
Related
- Polymorphic professional pattern — how the polymorphic referee is structured and the four-method Connect contract
- Polymorphic referee migration: PR #1295
- CMS create flow + picker: PR #1314
- Patient info, PatientPicker, security hardening, self-referral prevention: PR #1544
- Referral provenance: #2462
Status: documented Last Updated: 2026-10-14