Kulcare Docs
DeveloperClinicalAppointments

Clinical::Appointment polymorphic appointable

Developer reference for the V1.5 vendor scheduling schema unblock. After this PR, Clinical::Appointment can hold either a clinic appointment (tenant + facility + practitioner + Clinical::Patient) or a vendor marketplace appointment (tenantless, vendor-bound, attendee = Identity::User).

Pairs with the polymorphic professional pattern which covers Connect::ProfessionalProfile and Clinical::Referral.

Why this exists

Vendors authored packages in V1 (PR #1306). V1 Phase 4 will let patients buy them. V1.5 closes the loop with scheduling: vendors need calendar slots; patients need to book sessions against those slots. The same Clinical::Appointment table that backs clinic appointments now backs vendor bookings via a polymorphic owner column — same playbook as Connect::ProfessionalProfile.professional (PR #1293) and Identity::ServicePackage.owner (PR #1306).

Decision rationale: see docs/decisions/2026-05-05-vendor-scheduling-appointment-polymorphism.md.

Schema

Two new columns plus relaxed NOT NULLs:

appointable_type VARCHAR NOT NULL  -- 'Identity::Practitioner' | 'Identity::Vendor'
appointable_id   UUID
attendee_user_id UUID  -- FK to identity.users; for vendor bookings only
tenant_id        UUID  -- now NULL-able (was NOT NULL)
facility_id      UUID  -- now NULL-able
patient_id       UUID  -- now NULL-able

Plus:

  • Composite index (appointable_type, appointable_id, start_time) for the picker query.
  • Partial index on attendee_user_id (where not null) for the patient-app "my bookings" query.
  • CHECK constraint pinning appointable_type to the allowed set.
  • RLS policy permits tenant_id IS NULL OR tenant_id = current_setting(...). WITH CHECK is tighter: tenantless writes must be vendor-typed.

Per-shape invariants

ColumnPractitioner apptVendor appt
appointable_typeIdentity::PractitionerIdentity::Vendor
appointable_idpractitioner.idvendor.id
tenant_idrequiredNULL
facility_idrequiredNULL
patient_idrequiredNULL
practitioner_id (legacy)mirrors appointable_id (auto-synced)NULL
attendee_user_idNULLrequired
slot_idoptionalNULL in V1.5
encounteroptionalNULL in V1.5

Validators enforce these via conditional presence: true / absence: true rules tied to practitioner_appointment? and vendor_appointment? predicates.

Default scope behavior

Mirrors Identity::ServicePackage's pattern (PR #1306, ADR 004):

default_scope do
  if Shared::Current.tenant.present?
    where(tenant_id: [nil, Shared::Current.tenant.id])
  else
    all
  end
end

The keyed-predicate form (not a raw SQL fragment) is deliberate so unscope(where: :tenant_id) works. CodeRabbit caught a bug in PR #1306 where the raw fragment broke unscope; this model's scope is the corrected shape.

unscoped_by_tenant is a public helper for callers who explicitly want to read across tenants (admin tooling, the patient-app for_attendee_user(id) scope which reads vendor appointments across the whole network).

Example: creating a vendor appointment

Clinical::Appointment.create!(
  appointable: vendor,            # Identity::Vendor
  attendee_user: user,             # Identity::User (the buyer)
  status: 'pending',
  start_time: 1.day.from_now,
  appointment_type: 'fixed'
)

Note: no tenant, facility, or patient — vendor appointments have none of those. Setting any clinic-context column on a vendor appointment fails the conditional validator.

Example: querying

# All appointments for a specific practitioner (legacy code keeps working)
Clinical::Appointment.for_practitioner(practitioner.id)

# All appointments for a specific vendor (new)
Clinical::Appointment.for_appointable(vendor)

# A user's vendor bookings (patient-app surface)
Clinical::Appointment.for_attendee_user(user.id)

What's not in V1.5

  • Slot generation for vendors. Clinical::Slot.appointment_id is practitioner-only. Vendor appointments leave slot_id NULL. Vendor schedule + slot generation lands when product confirms session model (1:1 timed vs class capacity vs delivery window).
  • Encounter for vendor appointments. Clinical::Encounter is tenant-scoped clinical documentation. Vendor wellness sessions don't produce encounters. The has_one :encounter association resolves to nil for vendor appointments.
  • Cypress / patient-app booking UI. Schema is ready; UI follows.
  • CMS schedule UI for vendor appointments. The Connect Schedule page exists but doesn't yet render for_appointable(vendor). UI extension is a follow-up.

Adding a third polymorphic appointable

If a third type ever needs to be a bookable resource (e.g. Identity::Staff):

  1. Implement on the new type: anything the existing schedule UI reads via appointable.<method> — at minimum display_name.
  2. Add the type string to Clinical::Appointment::ALLOWED_APPOINTABLE_TYPES AND the CHECK constraint via a migration (both sides need to update — comment in the constraint migration says so).
  3. Decide: does the new type need a clinic context (extends the practitioner-shape branch in validators) or a tenantless attendee context (extends the vendor branch)? Hybrid types would need a new branch.

Why preserve practitioner_id

The existing CMS schedule UI, queue page, video session, reminders, doctor analytics, and at least three reports query directly on practitioner_id. Rewriting them all to go through the polymorphic column was out of scope for this PR. Instead, a before_validation callback mirrors appointable_id to practitioner_id for practitioner appointments. Vendor appointments leave it NULL.

New code should prefer appointable over practitioner_id. The legacy column will be removed in a follow-up once all callers migrate.

RLS policy

USING (
  tenant_id IS NULL
  OR tenant_id = NULLIF(current_setting('tenant.id', true), '')::uuid
)
WITH CHECK (
  (tenant_id IS NULL AND appointable_type = 'Identity::Vendor')
  OR tenant_id = NULLIF(current_setting('tenant.id', true), '')::uuid
)

The setting key is tenant.id (legacy convention), not app.current_tenant_id like the newer tables. Preserving for backward compatibility — every appointment-touching service sets tenant.id and changing the convention is out of scope.

Spec coverage

backend/domains/clinical/spec/models/appointment_polymorphic_spec.rb covers:

  • Practitioner appointment validators + practitioner_id sync
  • Vendor appointment validators + attendee_user_id requirement
  • CHECK constraint defense-in-depth
  • Named scopes (for_appointable, for_attendee_user, legacy for_practitioner)
  • Backward compatibility: 41 existing appointment specs stay green.

References

  • Decision: docs/decisions/2026-05-05-vendor-scheduling-appointment-polymorphism.md
  • V1.5 issue: #1312
  • Polymorphic-owner precedents: PR #1293 (ProfessionalProfile), #1295 (Referral), #1306 (ServicePackage), #1316 (PatientPackageSubscription)
  • Pattern doc: polymorphic professional pattern

Status: documented Last Updated: 2026-05-05

On this page