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:
hamed
2026-08-06 16:06:29 +03:30
co-authored by Claude Opus 5
parent 1d43475724
commit 85985b04a0
13 changed files with 736 additions and 23 deletions
@@ -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.