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 UUIDwith 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: trueSingle-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: falseAlways 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:
| Method | Purpose | Practitioner | Vendor |
|---|---|---|---|
connect_display_name | Display name on Connect surfaces | display_name_or_full | display_name |
connect_slug_source | Source for the auto-generated profile slug | "#{first_name}-#{last_name}" | business_name.presence || name |
connect_avatar_url | Avatar URL (nil-safe) | avatar_url | nil for now |
connect_can_receive_referrals? | Receiver-eligibility gate | status_active && profile.visibility_public? && profile.accepting_referrals | same 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'
endAuthorization
Owner-only writes go through professional_profile_policy.rb which
branches on record.professional_type:
Identity::Practitioner→record.professional.user_id == user.idIdentity::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:
- Implement
connect_display_name,connect_slug_source,connect_avatar_url,connect_can_receive_referrals?on the model. - Add the type string to
Clinical::Referral::ALLOWED_REFEREE_TYPES. - Update
Connect::BasePolicy#user_profileresolution order if the new type can stack with practitioner/vendor on a single user. - Update the blueprint's polymorphic payload emission if the frontend distinguishes the new type visually (vendor chip vs practitioner specialty chip).
- No schema migration needed — the polymorphic columns already accept any (type, id) pair.
API reference
| Endpoint | Polymorphic behavior |
|---|---|
GET /connect/me/professional_profile | Auto-creates profile against the user's owning record (practitioner or vendor); inferred from signed_up_as. |
POST /clinical/referrals | Accepts referee_type + referee_id. Validation: referee.connect_can_receive_referrals? must be true. |
POST /clinical/referrals/:id/accept | Enqueues ReferralStatusNotificationJob to the sender; receiver-side display name resolved from polymorphic target. |
GET /connect/me/referrals | Cross-tenant inbox; OR-matches referee_type/referee_id against current_user.practitioner AND current_user.vendor when both exist. |
GET /connect/me/appointments | Adds 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/clients | Vendor 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/earnings | Vendor 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_preview | Owner 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/:id | Owner 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/:id | Owner 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#bioreads the bio of its professional.ProfessionalProfileBlueprintandPublicProfileBlueprintsend it under the keybio, as before.- A profile write with
biowrites the professional's bio. The professional's validation applies (5000 characters), and an error stops the whole save. - The
connect.professional_profiles.biocolumn is inignored_columns. The application does not read or write it. Release 2 drops it (plan 2026-10-08, unit B5). - The migration
CopyConnectBioIntoIdentityBiocopied each Connect bio into an empty Identity bio and kept every Connect bio inconnect.professional_profile_bio_archive. Runbin/rails connect:reindex_copied_biosonce after the deploy, because the SQL copy does not reindex Typesense.bin/rails connect:bio_data_checksprints 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.staffsholdsbio(5000 characters),languages(at most 20),qualifications(at most 20, each{ code?, display, institution?, year? }) andexperience_years(0 to 80). The owner edits them throughGETandPATCH /identity/my/staff. - Never listed. The
listedandvisiblescopes take practitioners and vendors only, so the index and search never return a staff profile. The model keeps a staff profileunlistedwithaccepting_referralsoff.publishandunpublishanswer 422STAFF_PROFILE_NOT_LISTED. The connection policy refuses a request to, or an accept of, a staff profile. current_profiledoes not change.Connect::BaseController#current_profileandConnect::BasePolicy#user_profilestay practitioner, then vendor.current_staff_profileserves onlyGET /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::StaffWorkHistoryreads every staff role of the user in every practice throughIdentity::StaffServices::CrossTenantRoles, one tenant at a time insideRlsContext.with. Each row issource: 'kulcare',verified: true. Self-added jobs are the profile'sexperience_entries. - Who can read it. The owner gets the
staff_ownview. An admin or a colleague at a shared current practice getsstaff_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::ProfileShareLinkstores 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::FacilityPeoplelists the active staff roles at one facility for a colleague in its practice, and answers 404 for anyone else. It makes no profile:slugis null without one.
| Endpoint | Behavior |
|---|---|
GET /identity/my/staff | The owner's StaffSelf view, or 404 with no staff row. |
PATCH /identity/my/staff | Writes the work profile, display_name and avatar_url. Not staff_type, identifiers or permissions. |
GET /connect/professional_profiles/me | For a staff-only user, makes the staff profile on the first read and answers staff_own. |
GET /connect/professional_profiles/:slug | Staff 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_link | The 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/:token | Signed in. staff_detailed with meta.shared, or the one 404. |
GET /connect/facilities/:facility_id/people | The 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.
visibility | Vendor | Practitioner |
|---|---|---|
unlisted | The start state. The page waits for the payment account. The public endpoint returns 404. | Not public for another reason. |
public | Live. /vendors/:slug in the patient app opens. | Listed in Connect search for colleagues. |
private | The vendor turned off "Show in search". | The practitioner turned off "Show in search". |
connections_only | Counts 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 # writeThe 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 }.profileusesPublicProfileBlueprint :detailed, the same as the public page.packagesare the active packages. -
Practitioner: the patient page payload,
GET /identity/practitioners/:id/public_profile. -
Colleague view:
/connect/people/:slugonGET /connect/professional_profiles/:slug. The page shows the:detailedfields 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 notpublic, the owner preview says that colleagues cannot open the page, and gives the reason.The practitioner record in the
:detailedview 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) andexperience_years. The owner's:ownview has these fields and the full record, phone included. Each experience entry carries itslocationanddescription(1000 characters or fewer).twitter_handleis 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_resyncis gated onprofessional_type == 'Identity::Practitioner'; skip vendors. - Single-type alias. Reading
profile.practitionerfor a vendor row returns nil — always pair with the type filter.
References
backend/domains/connect/models/professional_profile.rb— polymorphic owner + slug retrybackend/domains/clinical/models/referral.rb— polymorphic referee + ALLOWED_REFEREE_TYPESbackend/domains/connect/blueprints/professional_profile_blueprint.rb— uniform delegation throughprofessional.connect_*backend/domains/connect/policies/base_policy.rb— practitioner-then-vendor profile resolutionbackend/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 onowner: Identity::Tenant | Identity::Vendor. Different from the ProfessionalProfile case in one important way: ServicePackage had to removeShared::Concerns::TenantScopedbecause vendor-owned rows have NULLtenant_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