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