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 moreIdentity::VendorSchedulerows:days[]+start_time/end_time(UTC HH:MM; thetimezonecolumn is a display hint only) +slot_duration+is_active. - Slots are computed, not persisted — there is no
vendor_slotstable.Clinical::Vendors::AvailabilityServiceexpands activeone_on_oneschedules 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
| Verb | Path | Auth | Purpose |
|---|---|---|---|
| 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= | public | Computed bookable slots. Gated on a public vendor profile. |
| POST | /connect/vendors/:vendor_id/appointments | buyer | Book 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
- Tenant-less RLS. Vendor appointments have
tenant_id = NULL. Thetenant_isolation_appointmentspolicy carries atenant_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 usesunscoped_by_tenant, neverRlsContext.with. A positive RLS-enforced test pins this (a baretenant_id = GUCpolicy would hide the rows and overbook — the #1671 class of bug). - 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. - Booking is grid-anchored.
Clinical::Appointment.book_vendor_slotvalidatesstart_timeagainstAvailabilityService(the same computation the patient app renders) and derivesend_timefrom the matched slot. An off-grid / past / off-schedule time is refused (SlotUnavailableError→ 422); an already-booked slot isSlotTakenError→ 409. The partial unique index is the race-proof backstop for the concurrent case. - 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::VendorSchedulesControllerblocks these with 409SCHEDULE_HAS_BOOKINGS; togglingis_active(pause) is always allowed.Clinical::Appointment.upcoming_vendor_bookings(vendor)is the shared predicate. - 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.rbbackend/domains/clinical/test/models/appointment_book_vendor_slot_test.rbbackend/domains/connect/test/requests/vendor_appointments_test.rbbackend/domains/identity/test/requests/vendor_schedules_test.rbbackend/domains/identity/test/policies/vendor_schedule_policy_test.rb