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-ablePlus:
- 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_typeto 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
| Column | Practitioner appt | Vendor appt |
|---|---|---|
appointable_type | Identity::Practitioner | Identity::Vendor |
appointable_id | practitioner.id | vendor.id |
tenant_id | required | NULL |
facility_id | required | NULL |
patient_id | required | NULL |
practitioner_id (legacy) | mirrors appointable_id (auto-synced) | NULL |
attendee_user_id | NULL | required |
slot_id | optional | NULL in V1.5 |
encounter | optional | NULL 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
endThe 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_idis practitioner-only. Vendor appointments leaveslot_idNULL. Vendor schedule + slot generation lands when product confirms session model (1:1 timed vs class capacity vs delivery window). - Encounter for vendor appointments.
Clinical::Encounteris tenant-scoped clinical documentation. Vendor wellness sessions don't produce encounters. Thehas_one :encounterassociation 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):
- Implement on the new type: anything the existing schedule UI
reads via
appointable.<method>— at minimumdisplay_name. - Add the type string to
Clinical::Appointment::ALLOWED_APPOINTABLE_TYPESAND the CHECK constraint via a migration (both sides need to update — comment in the constraint migration says so). - 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_idsync - Vendor appointment validators +
attendee_user_idrequirement - CHECK constraint defense-in-depth
- Named scopes (
for_appointable,for_attendee_user, legacyfor_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