feat(practice-domain): add practice domains and let a clinic select one
A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.
Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.
Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,35 @@
|
||||
# Resource-backed appointments are guarded by occupancy, not the doctor slot key
|
||||
# Resource bookings are guarded by occupancy, not by the doctor
|
||||
|
||||
An appointment's `active_slot_key` is `doctor_id:slot_start` under a unique index, which assumes the
|
||||
doctor is the thing being occupied. Once a doctor supervises several devices that assumption breaks:
|
||||
the second booking in the same hour on a different device is rejected. Resource occupancy already
|
||||
guards those bookings at the database level via `uniq_bucket_resource_seat (resource_id, bucket_at,
|
||||
seat)`, so an appointment that carries a resource leaves `active_slot_key` null and lets occupancy be
|
||||
the sole authority; only resourceless legacy bookings keep the doctor key.
|
||||
Booking a resource is independent of the doctor's calendar: a laser device has its own availability
|
||||
and the doctor attached to it only supervises. The booking code does not reflect that.
|
||||
`bookAtomically` takes a pessimistic lock on the doctor and rejects any interval that overlaps
|
||||
another booking of the same doctor, ignoring which resource was chosen, so a clinic whose devices
|
||||
share one supervising doctor cannot run two of them at once. In the current database every tenant is
|
||||
in that position — clinic 2's six resources all point at doctor 6, clinic 3's three at doctor 9.
|
||||
|
||||
The fix is to make resource occupancy the guard for resource bookings and stop deriving their
|
||||
protection from the doctor. Concretely: the panel booking path writes `ResourceOccupancy` rows the
|
||||
way the hold-based engine already does, cancellation releases them, `active_slot_key` stays null
|
||||
whenever an appointment carries a resource, and doctor-level locking applies only to bookings with no
|
||||
resource. The appointment's branch is then taken from the resource's own address rather than from a
|
||||
matching slot in the doctor's weekly schedule.
|
||||
|
||||
## Considered Options
|
||||
|
||||
Rekeying on the resource (`r{resource_id}:{slot_start}`) was rejected because it silently defeats
|
||||
`ClinicResource.capacity`: a room seating three would reject its second patient, and the unique index
|
||||
knows nothing about seats, buffers, or setup and cleanup time.
|
||||
Rekeying `active_slot_key` on the resource (`r{resource_id}:{slot_start}`) was rejected because it
|
||||
silently defeats `ClinicResource.capacity`: a room seating three would reject its second patient, and
|
||||
a unique index knows nothing about seats, buffers, or setup and cleanup time.
|
||||
|
||||
Teaching `isSlotTaken` about resources was rejected as a half-measure. It leaves two booking paths
|
||||
storing occupancy in two different places — `appointments.resource_id` for the panel,
|
||||
`resource_occupancy` for the engine — which `ResourceBookingSlotService::busyIntervals` already has
|
||||
to union by hand. Every later fix would then have to be written twice.
|
||||
|
||||
## Consequences
|
||||
|
||||
Any booking path that omits the resource falls back to the doctor key. Those paths have to be found
|
||||
and made resource-aware, or they end up with weaker protection than they have today.
|
||||
The panel path gains a database-level guard it never had: today its only resource check is an
|
||||
application-level `isFree()` call with no constraint behind it, so two concurrent requests can both
|
||||
pass it. Unifying on occupancy also lets `busyIntervals` stop reading two sources.
|
||||
|
||||
Any booking path that omits the resource keeps the doctor key and the doctor lock. Those paths must
|
||||
be enumerated when this lands, so none of them silently ends up with weaker protection.
|
||||
|
||||
Reference in New Issue
Block a user