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.
|
||||
|
||||
@@ -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
@@ -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` به هیچ حوزهای اشاره نمیکند |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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}
|
||||
```
|
||||
Reference in New Issue
Block a user