Kulcare Docs
DeveloperConnectProfessional

Polymorphic Professional Pattern

Connect::ProfessionalProfile and Clinical::Referral both use a polymorphic owner/receiver so a single record can belong to either an Identity::Practitioner or an Identity::Vendor. This page is the developer reference for the pattern: where it lives, what each polymorphic target must implement, and how to extend it (adding a new polymorphic target like staff or future business types).

Overview

Connect was originally built around practitioners — an Identity::Practitioner had one Connect profile, sent and received referrals, appeared in network search. Vendors (yoga teachers, nutritionists, labs, pharmacies) needed the same surface but should NEVER receive a UserMembership to a tenant. Polymorphic ownership lets vendors live in Connect as first-class peers of practitioners without ever entering the Tenant > Organization > Facility hierarchy.

Two polymorphic associations carry the pattern:

  • Connect::ProfessionalProfile.professional — Identity::Practitioner | Identity::Vendor | Identity::Staff. The Connect-side identity (slug, headline, visibility, accepting_referrals). A staff profile is a work profile that is never listed (see "Staff work profile").
  • Clinical::Referral.referee — Identity::Practitioner | Identity::Vendor. The receiving end of a clinical referral.

The sender of a referral (referring_practitioner) is still an Identity::User directly — only clinic doctors with active tenant memberships send referrals today. Polymorphic sender is a deliberate V2 follow-up.

Technical architecture

Schema

Both polymorphic columns use the standard Rails shape:

professional_type VARCHAR + professional_id UUID
referee_type      VARCHAR + referee_id      UUID

with composite unique indexes (professional_type, professional_id) and (referee_type, referee_id) where uniqueness applies. belongs_to declarations:

# Connect::ProfessionalProfile
belongs_to :professional, polymorphic: true
validates :professional_id, uniqueness: { scope: :professional_type }

# Clinical::Referral
belongs_to :referee, polymorphic: true, optional: true
ALLOWED_REFEREE_TYPES = %w[Identity::Practitioner Identity::Vendor].freeze
validates :referee_type, inclusion: { in: ALLOWED_REFEREE_TYPES }, allow_nil: true

Single-type alias for AR-friendly queries

ActiveRecord cannot .joins a polymorphic association. Where practitioner-only queries need to join (the Discover controller is the canonical example), ProfessionalProfile exposes a regular belongs_to :practitioner aliased onto the same column with inverse_of: false:

belongs_to :practitioner,
           foreign_key: :professional_id,
           class_name: 'Identity::Practitioner',
           optional: true,
           inverse_of: false

Always combine with where(professional_type: 'Identity::Practitioner') at the call site. Without that filter, the alias would silently return nil for vendor-typed rows (no practitioner exists with that UUID) — a real foot-gun.

The Connect contract

Each polymorphic target MUST implement four methods so the polymorphic association can be rendered, slugged, and authorized uniformly:

MethodPurposePractitionerVendor
connect_display_nameDisplay name on Connect surfacesdisplay_name_or_fulldisplay_name
connect_slug_sourceSource for the auto-generated profile slug"#{first_name}-#{last_name}"business_name.presence || name
connect_avatar_urlAvatar URL (nil-safe)avatar_urlnil for now
connect_can_receive_referrals?Receiver-eligibility gatestatus_active && profile.visibility_public? && profile.accepting_referralssame shape

Identity::Staff implements the same four methods: display_name_or_full, the full name for the slug, avatar_url, and false for referrals.

The blueprint reads professional.connect_display_name etc. — never practitioner.full_name or vendor.business_name — so adding a third polymorphic target (e.g. Identity::Staff) just means implementing the four methods.

Slug generation + collision retry

ProfessionalProfile.slug is globally unique across types — a vendor "Acme Lab" and a practitioner with surname "Acme" can collide. generate_slug (a before_validation :create callback) uses professional.connect_slug_source.parameterize.first(60) and counter-suffixes via SecureRandom.hex(3) if a collision is detected in the pre-check.

For genuinely concurrent inserts that slip past the pre-check, save and save! are wrapped in with_slug_collision_retry, which catches the slug-specific ActiveRecord::RecordNotUnique, regenerates the slug with a fresh suffix (stripping any prior suffix to keep slugs bounded), and retries up to twice before propagating.

Sender notification on referral state change

Clinical::Referral accept/decline/complete actions enqueue Clinical::ReferralStatusNotificationJob. The job sends push + optional email back to the original sender (referring_practitioner). It branches on referee_type (the string column, not case ... when Class) so the receiver display name comes out of the right polymorphic target without depending on either class being autoloaded at job-run time:

case referral.referee_type
when 'Identity::Practitioner' then referral.referee&.full_name.presence  || 'a practitioner'
when 'Identity::Vendor'       then referral.referee&.display_name.presence || 'a vendor'
end

Authorization

Owner-only writes go through professional_profile_policy.rb which branches on record.professional_type:

  • Identity::Practitioner → record.professional.user_id == user.id
  • Identity::Vendor → record.professional.user_id == user.id

Both targets store user_id on the underlying record, so the check is identical in shape — but routing through professional keeps the policy from coupling to either model directly.

Connect::BasePolicy#user_profile resolves the active profile by trying practitioner first, then vendor. Users who are both a practitioner AND a vendor (a graduating practitioner who also runs a yoga studio) currently silently default to practitioner — a known limitation tracked for vendor self-serve V1, where an ?as=vendor controller-level toggle will land.

Adding a third polymorphic target

To add Identity::Staff (or any future business type) as a Connect peer:

  1. Implement connect_display_name, connect_slug_source, connect_avatar_url, connect_can_receive_referrals? on the model.
  2. Add the type string to Clinical::Referral::ALLOWED_REFEREE_TYPES.
  3. Update Connect::BasePolicy#user_profile resolution order if the new type can stack with practitioner/vendor on a single user.
  4. Update the blueprint's polymorphic payload emission if the frontend distinguishes the new type visually (vendor chip vs practitioner specialty chip).
  5. No schema migration needed — the polymorphic columns already accept any (type, id) pair.

API reference

EndpointPolymorphic behavior
GET /connect/me/professional_profileAuto-creates profile against the user's owning record (practitioner or vendor); inferred from signed_up_as.
POST /clinical/referralsAccepts referee_type + referee_id. Validation: referee.connect_can_receive_referrals? must be true.
POST /clinical/referrals/:id/acceptEnqueues ReferralStatusNotificationJob to the sender; receiver-side display name resolved from polymorphic target.
GET /connect/me/referralsCross-tenant inbox; OR-matches referee_type/referee_id against current_user.practitioner AND current_user.vendor when both exist.
GET /connect/me/appointmentsAdds the user's own vendor bookings (appointable = current_user.vendor, no tenant) to the membership rows. Each row has kind (facility_appointment or vendor_booking) and, for a vendor booking, attendee: { id, name }. See the schedule guide.
GET /connect/me/clientsVendor only. The vendor's clients: distinct users from their bookings (not cancelled) and from paid purchases of their packages. Name only, no phone or email. A user with no vendor gets an empty list.
GET /connect/me/earningsVendor only. The vendor's verified marketplace orders, newest first, with the total paid. A user with no vendor gets an empty list and a null currency.
GET /connect/professional_profiles/me/public_previewOwner only. The public page (PublicProfileBlueprint :detailed), active packages and listing, for any visibility.
POST /connect/professional_profiles/:id/publish"Show in search" on. A vendor with no active payment account stays unlisted.
POST /connect/professional_profiles/:id/unpublish"Show in search" off. Stores private.
PATCH /connect/professional_profiles/:idOwner only. Headline, bio, links, accepting_referrals and open_to_opportunities. It does not accept visibility: publish and unpublish are the only way to change it. The bio goes to the Identity record (practitioner, vendor or staff), with the 5000-character limit of that record. See "One bio".
PATCH /connect/specializations/:idOwner only. Sets years_experience (0 to 70, or null) on one of the owner's specializations. It changes nothing else: the specialty and the primary flag change through remove and add. Another profile's row is a 404.

One bio

The Identity bio (identity.practitioners.bio, identity.vendors.bio, identity.staffs.bio) is the only bio. Patients, colleagues and the owner read the same text.

  • Connect::ProfessionalProfile#bio reads the bio of its professional. ProfessionalProfileBlueprint and PublicProfileBlueprint send it under the key bio, as before.
  • A profile write with bio writes the professional's bio. The professional's validation applies (5000 characters), and an error stops the whole save.
  • The connect.professional_profiles.bio column is in ignored_columns. The application does not read or write it. Release 2 drops it (plan 2026-10-08, unit B5).
  • The migration CopyConnectBioIntoIdentityBio copied each Connect bio into an empty Identity bio and kept every Connect bio in connect.professional_profile_bio_archive. Run bin/rails connect:reindex_copied_bios once after the deploy, because the SQL copy does not reindex Typesense. bin/rails connect:bio_data_checks prints the two counts of the deploy gate and changes nothing.

Staff work profile

A staff member (Identity::Staff) has a work profile in Connect (plan 2026-10-08, section 1). It is a Connect::ProfessionalProfile with professional_type = 'Identity::Staff'.

  • The canonical row. The profile hangs off Identity::Staff.canonical_for(user), the oldest staff row of the user. Ownership checks use every staff row of the user. A user with a practitioner or a vendor record gets no staff profile (Connect::StaffProfiles).
  • The fields. identity.staffs holds bio (5000 characters), languages (at most 20), qualifications (at most 20, each { code?, display, institution?, year? }) and experience_years (0 to 80). The owner edits them through GET and PATCH /identity/my/staff.
  • Never listed. The listed and visible scopes take practitioners and vendors only, so the index and search never return a staff profile. The model keeps a staff profile unlisted with accepting_referrals off. publish and unpublish answer 422 STAFF_PROFILE_NOT_LISTED. The connection policy refuses a request to, or an accept of, a staff profile.
  • current_profile does not change. Connect::BaseController#current_profile and Connect::BasePolicy#user_profile stay practitioner, then vendor. current_staff_profile serves only GET /me, the share-link endpoints and the experience and education entry endpoints. A staff-only user still gets the profile-less connections, specializations and dashboard.
  • Work history. Connect::StaffWorkHistory reads every staff role of the user in every practice through Identity::StaffServices::CrossTenantRoles, one tenant at a time inside RlsContext.with. Each row is source: 'kulcare', verified: true. Self-added jobs are the profile's experience_entries.
  • Who can read it. The owner gets the staff_own view. An admin or a colleague at a shared current practice gets staff_detailed (Connect::StaffColleague: in one tenant, both have an active membership, the staff member has an active staff role, and the viewer has an active staff or practitioner role or an owner or admin membership). A former employer and anyone else get a 404, not a 403. No staff view carries permissions, identifiers, a phone or an email.
  • Share link. Connect::ProfileShareLink stores only the SHA-256 digest of the token. A new link revokes the old one. Unknown, revoked and expired tokens get the same 404. The read is signed in only, counts each view, and allows 30 reads in 10 minutes.
  • Colleagues at a facility. Connect::FacilityPeople lists the active staff roles at one facility for a colleague in its practice, and answers 404 for anyone else. It makes no profile: slug is null without one.
EndpointBehavior
GET /identity/my/staffThe owner's StaffSelf view, or 404 with no staff row.
PATCH /identity/my/staffWrites the work profile, display_name and avatar_url. Not staff_type, identifiers or permissions.
GET /connect/professional_profiles/meFor a staff-only user, makes the staff profile on the first read and answers staff_own.
GET /connect/professional_profiles/:slugStaff target: staff_own for the owner, staff_detailed for an admin or colleague, 404 for anyone else.
GET, POST, DELETE /connect/professional_profiles/me/share_linkThe link state; a new link (expires_in_days 7, 30, 90 or null, default 30) whose url carries the token once; revoke.
GET /connect/shared_profiles/:tokenSigned in. staff_detailed with meta.shared, or the one 404.
GET /connect/facilities/:facility_id/peopleThe colleagues at the facility, paged, or 404.

Visibility and public pages

A profile has one of four visibility values. Each value has a different meaning for a vendor and for a practitioner.

visibilityVendorPractitioner
unlistedThe start state. The page waits for the payment account. The public endpoint returns 404.Not public for another reason.
publicLive. /vendors/:slug in the patient app opens.Listed in Connect search for colleagues.
privateThe vendor turned off "Show in search".The practitioner turned off "Show in search".
connections_onlyCounts as search off.Counts as search off.

Patient search for a practitioner reads the facility roles. It does not read visibility. The patient page is /doctor/:id, served by GET /identity/practitioners/:id/public_profile.

A vendor listing goes live with payments (plan A2)

Billing::Marketplace::ApplyEventService calls Connect::PublishVendorListing when a linked account becomes active. The service changes only an unlisted profile. It sets visibility: 'public' and accepting_referrals: true. It does not change a private profile, which is the vendor's own choice. It also does not change a delisted vendor, which only staff can put back.

A profile that fails a validation (for example, a legacy website_url) stays unlisted. The service logs the error and returns a failure with the outcome invalid_profile. It does not raise, so the payment account still becomes active and the webhook does not retry.

The service locks the profile row and reads it again before the write. A vendor who turns "Show in search" off after the first read keeps that choice. The outcome is then search_off. ApplyEventService runs the listing write in a savepoint. An unexpected error rolls back only the listing write, and Rails.error.report records it. The payment account stays active.

POST /connect/professional_profiles/:id/publish keeps a vendor with no active payment account unlisted. The listing then goes live on activation.

Backfill for vendors that were already active. The task is a dry run unless you set CONFIRM=1:

bundle exec rake connect:publish_active_vendors            # report only
CONFIRM=1 bundle exec rake connect:publish_active_vendors  # write

The report groups vendor ids by outcome. One bad row does not stop the run: an invalid profile shows as invalid_profile, and an unexpected error as error.

Listing status

ProfessionalProfile#listing_status returns { live:, reason: }. The :own view sends it as listing. The reasons, in the order the code checks them: delisted, search_off, payments_inactive, vendor_inactive, unlisted. The profile page, the Visibility panel and the dashboard Listing tile all show this one reason.

Previews

  • Vendor: GET /connect/professional_profiles/me/public_preview, owner only, for any visibility. It returns { profile, packages, listing }. profile uses PublicProfileBlueprint :detailed, the same as the public page. packages are the active packages.

  • Practitioner: the patient page payload, GET /identity/practitioners/:id/public_profile.

  • Colleague view: /connect/people/:slug on GET /connect/professional_profiles/:slug. The page shows the :detailed fields only, so the owner sees what a colleague sees. A colleague who may not see the profile gets 403 (private, unlisted, connections only and not connected) or 404 (delisted, unknown slug). The page shows "This profile is not available" for both. When the owner's profile is not public, the owner preview says that colleagues cannot open the page, and gives the reason.

    The practitioner record in the :detailed view has no contact field: no phone, email, identifiers, HPR ID, user or schedules. It has the name, the photo, the languages, the qualifications (code, specialty, issuer and year only) and experience_years. The owner's :own view has these fields and the full record, phone included. Each experience entry carries its location and description (1000 characters or fewer). twitter_handle is a handle without the "@": 1 to 15 letters, numbers or underscores.

Referral parties (referring_practitioner, referee) and inbox parties carry profile_slug, so a name can link to /connect/people/:slug. The value is nil for a party with no profile. The profile endpoint still enforces visibility.

Onboarding switches

The vendor Listing step (answers.listing.searchable) and the practitioner Confirm page (confirm.public_visibility) store the choice on the onboarding document. When onboarding completes for the first time, useApplyProfileSearchChoice calls publish or unpublish if a profile exists.

After that, the profile is the record. The user can change Show in search on My profile, so the stored answer can be older than the profile. A return visit to Confirm shows the profile's current state, and it changes the profile only when the user moves the switch. A finished vendor who completes Review again does not change the profile.

Common gotchas

  • Eager loading. Always include the polymorphic association explicitly (.includes(:referee, :professional)) — Rails can't guess the target class from the join alone.
  • Typesense indexing. Vendors don't have a Typesense collection yet. enqueue_professional_typesense_resync is gated on professional_type == 'Identity::Practitioner'; skip vendors.
  • Single-type alias. Reading profile.practitioner for a vendor row returns nil — always pair with the type filter.

References

  • backend/domains/connect/models/professional_profile.rb — polymorphic owner + slug retry
  • backend/domains/clinical/models/referral.rb — polymorphic referee + ALLOWED_REFEREE_TYPES
  • backend/domains/connect/blueprints/professional_profile_blueprint.rb — uniform delegation through professional.connect_*
  • backend/domains/connect/policies/base_policy.rb — practitioner-then-vendor profile resolution
  • backend/domains/clinical/jobs/referral_status_notification_job.rb — string-based polymorphic dispatch
  • Original ProfessionalProfile polymorphic refactor: PR #1293
  • Polymorphic referee + onboarding paths: PR #1295

Sibling polymorphic-owner patterns

The same delegation playbook applies to other models that grew polymorphic owners after this:

  • Identity::ServicePackage (PR #1306, V1 vendor packages) — polymorphic on owner: Identity::Tenant | Identity::Vendor. Different from the ProfessionalProfile case in one important way: ServicePackage had to remove Shared::Concerns::TenantScoped because vendor-owned rows have NULL tenant_id. See polymorphic ServicePackage owner guide and ADR 004.
  • The four-method Connect contract (this doc) is a model-side delegation pattern; the ServicePackage case is a controller/policy-side branching pattern. They solve adjacent problems: "how do I render the polymorphic target" vs "how do I authorize / scope queries on the polymorphic owner".

Demand-side: CMS referral creation

The sender side (clinic doctor in CMS chooses a recipient + creates the referral) is documented separately in Referrals: CMS create flow + recipient picker. Specifically the GET /clinical/referrals/recipients picker endpoint that resolves practitioners + vendors against a single endpoint with one shared eligibility gate.


Status: documented Last Updated: 2026-05-05

On this page