Kulcare Docs
DeveloperConnectReferrals

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 patient
  • notes — free-text notes attached to the referral
  • accepted_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 uses RlsContext.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
ParamOptional?Notes
qyesFree-text name search. Powered by Connect::ProfessionalProfile.search_by_name (matches Practitioner.first_name/last_name and Vendor.name/business_name).
typeyespractitioner or vendor. Omit for both.
service_categoryyesVendor-only. One of Identity::Vendor's service categories (wellness, fitness, education, support, nutrition). Ignored when type=practitioner.
specialty_idyesPractitioner-only. Filters to profiles with this primary specialty. Ignored when type=vendor.
page, per_pageyesper_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:

  1. SQL COUNT(*) per type → total_entries is accurate even with filtering applied.
  2. SQL LIMIT (offset + per_page) per type — fetch enough rows to satisfy the page after merge. Worst-case in-memory rows are bounded by 2 * (offset + per_page) regardless of total system population.
  3. 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)

  • :status removed from referral_params — status transitions must go through dedicated endpoints (/accept, /decline, /complete, /cancel) which enforce policy and set timestamps. PATCH cannot bypass.
  • :patient_id blocked on update — patient can only be set at create time; update actions strip it to prevent patient-swapping.
  • tenant_member? added to update? policy — consistent with all other policy methods.
  • Explicit authorize on index — was the only action missing it.
  • RequireAccess on CMS routes — /cms/referrals and /cms/referrals/new now 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

ColumnTable and tenantMeaning
source_referral_id, source_referral_tenant_idclinical.patient_facilities, referee's tenantThe referral that sent the patient. Both set or both null (chk_cl_pf_source_referral_pair).
patient_registered_atclinical.referrals, sender's tenantThe 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::PatientFacility after_create_commit: the patient's first registration in the tenant is created. A later registration enqueues nothing.
  • Clinical::Patient after_update_commit: identity_patient_id is set. CMS links the global identity after the registration commits, so this second trigger is necessary. Code that sets identity_patient_id with update_columns skips that callback, so it must call patient.enqueue_referral_source_link itself. FindOrCreateService, ResolveOrProvisionForBookingService and Patient::AppointmentOrders::CreateService do this.

The job calls Clinical::Referrals::SourceLinker, which does these steps:

  1. 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.
  2. 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 are accepted or completed, that come from another tenant, and that were accepted in the 90 days before the registration.
  3. 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.
  4. It links the most recently accepted referral and stamps patient_registered_at in 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) has referral_source: referral_id, referring_practitioner_name, referring_facility_name, referred_at. Clinical::Referrals::SourceSummary reads 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_at is on ReferralBlueprint :detailed (GET /clinical/referrals) and on Connect::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

PathWhat it does
backend/domains/clinical/controllers/referral_recipients_controller.rbPundit gate + service dispatch
backend/domains/clinical/services/referrals/recipient_search_service.rbSQL queries + merge + pagination
backend/domains/clinical/blueprints/referral_recipient_blueprint.rbHash-extractor blueprint (no PII)
backend/domains/clinical/spec/requests/referral_recipients_spec.rb10 examples covering eligibility + filters + auth
backend/domains/clinical/routes.rbMounts under resources :referrals do collection ... end
frontend/apps/cms/src/features/referrals/pages/ReferralsListPage.tsxSent-referrals list with patient info + timestamps
frontend/apps/cms/src/features/referrals/pages/ReferralNewPage.tsxCreate form
frontend/apps/cms/src/features/referrals/components/PatientPicker.tsxSearchable patient picker
frontend/apps/cms/src/features/referrals/components/RefereePicker.tsxUnified recipient picker
frontend/packages/shared-services/src/domains/clinical/types.tsReferralRecipient, ReferralRefereeType, ReferralRecipientListParams; Referral type with patient, notes, timestamps
frontend/packages/shared-services/src/domains/connect/types.tsPersonalReferral type with patient, notes, timestamps, patient_registered_at
backend/domains/clinical/services/referrals/source_linker.rbWrites 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.rbRuns 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
  • 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

On this page