Kulcare Docs
DeveloperClinicalAppointments

Vendor 1:1 scheduling (V1.5, oneonone)

Developer reference for the vendor availability + booking flow (#1312). Pairs with polymorphic appointable (the schema) — this covers the functional layer: computed availability, the booking path, the endpoints, and the guards.

Scope: V1.5 ships one_on_one only. VendorSchedule.schedule_type reserves the class / delivery_window seam but those are unwired. Sessions are free — paid booking is deferred (see the paid-booking-seam decision).

Shape

  • A vendor (Identity::Vendor, tenantless) owns one or more Identity::VendorSchedule rows: days[] + start_time/end_time (UTC HH:MM; the timezone column is a display hint only) + slot_duration + is_active.
  • Slots are computed, not persisted — there is no vendor_slots table. Clinical::Vendors::AvailabilityService expands active one_on_one schedules into discrete slots over a date range and subtracts occupied vendor appointments.
  • A booking is a tenant-less Clinical::Appointment (appointable = vendor, attendee_user = buyer, tenant_id/facility_id/ patient_id = NULL, status = booked).

Endpoints

VerbPathAuthPurpose
GET/POST/PATCH/DELETE/identity/vendor_schedules[/:id]vendor (self-edit)Vendor availability CRUD — the connect editor's Save target. schedule_type forced one_on_one.
GET/connect/public/vendors/:vendor_id/availability?from=&to=publicComputed bookable slots. Gated on a public vendor profile.
POST/connect/vendors/:vendor_id/appointmentsbuyerBook a slot ({ appointment: { start_time } }).

Frontend: the connect editor is frontend/apps/connect/src/pages/VendorSchedulePage.tsx (uses @kulcare/shared-services useVendorSchedules / useCreate|Update|DeleteVendorSchedule); the patient surface is patient/src/features/marketplace/components/VendorAvailabilitySection.tsx (uses useVendorAvailability / useBookVendorAppointment).

Load-bearing invariants

  1. Tenant-less RLS. Vendor appointments have tenant_id = NULL. The tenant_isolation_appointments policy carries a tenant_id IS NULL OR … read carve-out (#1319), so the availability subtraction sees the vendor's own bookings with no tenant GUC set — the service uses unscoped_by_tenant, never RlsContext.with. A positive RLS-enforced test pins this (a bare tenant_id = GUC policy would hide the rows and overbook — the #1671 class of bug).
  2. Occupancy is one set. Clinical::Appointment::NON_OCCUPYING_STATUSES (cancelled/noshow/fulfilled) is the single source of truth shared by the partial unique index predicate (idx_uniq_active_vendor_appointment_slot), the availability subtraction, the model dedupe, and the schedule guard. Cancelling frees the slot.
  3. Booking is grid-anchored. Clinical::Appointment.book_vendor_slot validates start_time against AvailabilityService (the same computation the patient app renders) and derives end_time from the matched slot. An off-grid / past / off-schedule time is refused (SlotUnavailableError → 422); an already-booked slot is SlotTakenError → 409. The partial unique index is the race-proof backstop for the concurrent case.
  4. Grid edits are locked while booked. Changing a schedule's days/window/slot_duration (or creating/deleting a schedule) while the vendor has upcoming bookings would re-align the slot grid under them (occupancy matches by exact start_time), freeing an overlapping slot → double-book. Identity::VendorSchedulesController blocks these with 409 SCHEDULE_HAS_BOOKINGS; toggling is_active (pause) is always allowed. Clinical::Appointment.upcoming_vendor_bookings(vendor) is the shared predicate.
  5. Anti-griefing cap. A buyer may hold at most MAX_ACTIVE_VENDOR_BOOKINGS_PER_VENDOR (10) upcoming sessions with one vendor (BookingLimitError → 422). Best-effort under concurrency (no DB constraint) — the free endpoint has no request-rate throttle (rack-attack is deferred infra).

Timezone

start_time/end_time are stored as UTC HH:MM; all slot math is UTC. The connect editor converts the vendor's local wall-clock to UTC on save and back on load (current-offset, DST-naive — same discipline as the practitioner editor). The patient app renders slots in the buyer's local time.

Tests

  • backend/domains/clinical/test/services/vendors/availability_service_test.rb
  • backend/domains/clinical/test/models/appointment_book_vendor_slot_test.rb
  • backend/domains/connect/test/requests/vendor_appointments_test.rb
  • backend/domains/identity/test/requests/vendor_schedules_test.rb
  • backend/domains/identity/test/policies/vendor_schedule_policy_test.rb

On this page