Files
clinicpro/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md
T
hamedandClaude Opus 5 85985b04a0 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>
2026-08-06 16:06:29 +03:30

2.3 KiB

Resource bookings are guarded by occupancy, not by the doctor

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 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

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.