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.
+1
View File
@@ -81,6 +81,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb
| [auth.md](auth.md) | Authentication — OTP, Login, JWT | 8 |
| [doctor.md](doctor.md) | Doctor profile & addresses | 11 |
| [clinic.md](clinic.md) | Clinics | 7 |
| [practice-domain.md](practice-domain.md) | Practice domains — a clinic's field of practice | 3 |
| [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 |
| [resource.md](resource.md) | Resources, types, skills, pools | 16 |
| [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 |
+12 -2
View File
@@ -193,10 +193,19 @@ Update a clinic.
| `uuid` | string (UUID) | Clinic UUID |
### Request Body
Same fields as POST — all optional.
Same fields as POST — all optional — plus:
| Field | Type | Description |
|-------|------|-------------|
| `practice_domain_uuid` | string (UUID) \| `""` \| `null` | حوزهٔ فعالیت کلینیک. رشتهٔ خالی یا `null` یعنی «پاک کن»؛ نبودنِ کلید یعنی «دست نزن». uuid ناشناس ۴۲۲ می‌گیرد، نه رد شدن بی‌صدا. ← [practice-domain.md](./practice-domain.md) |
### Response `200`
Updated clinic object (same structure as GET).
Updated clinic object (same structure as GET). Carries `practice_domain` — the full domain object, or
`null` when unset:
```json
{"uuid":"8c7bfd18-9159-11f1-b98b-f28fd8aa5db5","code":"beauty","name":"کلینیک زیبایی","sort_order":0,"active":true}
```
### Errors
| Code | HTTP | Description |
@@ -204,6 +213,7 @@ Updated clinic object (same structure as GET).
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
| `ERR_VALIDATION_002` | 422 | `practice_domain_uuid` به هیچ حوزه‌ای اشاره نمی‌کند |
---
+157
View File
@@ -0,0 +1,157 @@
# Practice Domain API
> **Prefix:** `/api/v1/practice-domains`, `/api/v1/practice-domain`
A practice domain is the field a clinic operates in — beauty, dentistry, orthopaedics. It is a
configuration key, not a marketing label: treatment workflows bind to its `code`, so the code is
immutable once created. It is deliberately **not** `Specialty`, which stays a descriptive label for
the public booking site.
The table is global (registered in `GlobalTables::ENTITIES`); a clinic points at zero or one of its
rows through `clinics.practice_domain_id`, which is nullable. `NULL` means "not configured" and keeps
today's behaviour — it is never an error.
---
## GET `/api/v1/practice-domains`
List domains, ordered by `sort_order` then `name`.
**Permission:** `IS_AUTHENTICATED_FULLY`. Callers without `ROLE_ADMIN` see only `active` rows, so a
clinic manager cannot pick a domain the platform has retired. `ROLE_ADMIN` sees inactive ones too, to
be able to switch them back on.
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "8c7bfd18-9159-11f1-b98b-f28fd8aa5db5",
"code": "beauty",
"name": "کلینیک زیبایی",
"sort_order": 0,
"active": true
}
]
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_AUTH_001 | 401 | بدون توکن |
---
## POST `/api/v1/practice-domains`
Create a domain.
**Permission:** `ROLE_ADMIN` (platform admin only). A clinic manager creating its own domain would
produce a domain with no workflow behind it, and the misconfiguration would stay invisible until the
first protocol-driven booking.
### Request Body (`application/json`)
| Field | Type | Required | توضیح |
|---|---|---|---|
| `code` | string | ✅ | `^[a-z0-9_]{1,40}$`، یکتا در کل جدول، بعد از ساخت تغییرناپذیر |
| `name` | string | ✅ | نام نمایشی فارسی |
| `sort_order` | int | ❌ | پیش‌فرض `0` |
```json
{ "code": "dental", "name": "دندانپزشکی", "sort_order": 2 }
```
### Response `201`
```json
{
"success": true,
"data": {
"uuid": "af5063de-753f-444a-ba95-05ec2ffe13be",
"code": "dental",
"name": "دندانپزشکی",
"sort_order": 2,
"active": true
}
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_VALIDATION_001 | 422 | بدنهٔ نامعتبر، یا کد خارج از الگو، یا کد تکراری (field: `code`) |
| ERR_VALIDATION_002 | 422 | `name` خالی است (field: `name`) |
| ERR_FORBIDDEN_001 | 403 | کاربر `ROLE_ADMIN` نیست |
| ERR_AUTH_001 | 401 | بدون توکن |
Real 422 for a duplicate code:
```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"حوزه فعالیتی با این کد از قبل وجود دارد","field":"code"}]}
```
---
## PATCH `/api/v1/practice-domain/{uuid}`
Update a domain's display fields.
**Permission:** `ROLE_ADMIN`.
**`code` is ignored if sent.** Workflow implementations are resolved by code, so renaming it would
silently detach a live clinic from its workflow.
### Request Body (`application/json`)
| Field | Type | توضیح |
|---|---|---|
| `name` | string | فقط اگر ناتهی باشد اعمال می‌شود |
| `sort_order` | int | |
| `active` | bool | |
### Response `200`
```json
{
"success": true,
"data": {
"uuid": "af5063de-753f-444a-ba95-05ec2ffe13be",
"code": "dental",
"name": "دندان‌پزشکی",
"sort_order": 5,
"active": true
}
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_VALIDATION_001 | 422 | بدنهٔ درخواست نامعتبر است |
| ERR_VALIDATION_002 | 404 | حوزه فعالیت یافت نشد |
| ERR_FORBIDDEN_001 | 403 | کاربر `ROLE_ADMIN` نیست |
---
## Assigning a domain to a clinic
There is no dedicated endpoint. The existing `PATCH /api/v1/clinic/{uuid}` accepts one more key —
see [clinic.md](./clinic.md).
| Body | اثر |
|---|---|
| `"practice_domain_uuid": "<uuid>"` | حوزه ست می‌شود |
| `"practice_domain_uuid": ""` یا `null` | حوزه پاک می‌شود |
| کلید اصلاً نباشد | حوزهٔ فعلی دست‌نخورده می‌ماند |
An unknown uuid is rejected rather than ignored, because a silently dropped selection would only
surface at the first protocol-driven booking:
```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"حوزه فعالیت یافت نشد","field":"practice_domain_uuid"}]}
```
`GET /api/v1/clinic/{uuid}` returns the current value under `data.data.practice_domain`, `null` when
unset:
```json
{"uuid":"8c7bfd18-9159-11f1-b98b-f28fd8aa5db5","code":"beauty","name":"کلینیک زیبایی","sort_order":0,"active":true}
```