Files
clinicpro/docs/api/appointment-settings.md
T
hamedandClaude Opus 4.8 fe6383314e fix(security): enforce ownership on 4 IDOR read/bind endpoints (M2-M5)
- M2 GET /insurance/{id}: was unguarded; now owner-or-admin (403 otherwise) —
  stops reading another doctor's negotiated price by id enumeration.
- M3 GET /clinic-pro/doctor-address/{id}: add the same owner/admin check the
  sibling PATCH/DELETE already had.
- M4 POST/PATCH /service-item: staff_uuid must belong to the caller's tenant
  (entity_type/entity_id) → 422; stops binding another tenant's staff.
- M5 appointment-settings list endpoints (date-override/holidays/
  available-locations): add the per-doctor ownership check the sibling
  single-record endpoints already enforce.

Regressions (6 negative cases fail without the fixes):
DoctorInsuranceOwnershipTest, DoctorAddressOwnershipTest,
ServiceItemStaffOwnershipTest, AppointmentSettingsListOwnershipTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:17:52 +03:30

627 lines
16 KiB
Markdown

# Appointment Settings API
> **Prefix:** `/api/v1/appointment-settings`
> **Permission:** All write endpoints require `AUTH` — must be the doctor owner or `ROLE_ADMIN`
Doctors configure their availability via three resources: **weekly schedule**, **date overrides**, and **holidays**.
---
## Weekly Schedule
Each doctor has **one** weekly schedule (upsert). The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions` array.
### Day Index Convention
| Index | Day (EN) | Day (FA) |
|-------|----------|----------|
| `"0"` | Saturday | شنبه |
| `"1"` | Sunday | یکشنبه |
| `"2"` | Monday | دوشنبه |
| `"3"` | Tuesday | سه‌شنبه |
| `"4"` | Wednesday | چهارشنبه |
| `"5"` | Thursday | پنجشنبه |
| `"6"` | Friday | جمعه |
---
### POST `/api/v1/appointment-settings/weekly-schedule`
Create or update the weekly schedule for a doctor (upsert).
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود.
### Request Body (`application/json`)
```json
{
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"schedule": {
"0": {
"sessions": [
{
"active": true,
"location_id": 1973,
"start_time": "09:00",
"end_time": "13:00",
"duration_per_patient": 20,
"has_rest": true,
"rest_interval": 60,
"time_to_rest": 10,
"patient_limit": null
},
{
"active": true,
"location_id": 1973,
"start_time": "15:00",
"end_time": "18:00",
"duration_per_patient": 20,
"has_rest": false,
"rest_interval": 0,
"time_to_rest": 0,
"patient_limit": 5
}
]
},
"1": { "sessions": [] },
"2": { "sessions": [] },
"3": { "sessions": [] },
"4": { "sessions": [] },
"5": { "sessions": [] },
"6": { "sessions": [] }
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `schedule` | object | ✅ | Keys `"0"` through `"6"` (day indices) |
| `schedule.{n}.sessions` | array | ✅ | Array of session config objects |
| `meta` | object | ❌ | Online-booking settings (see below) |
**Online-booking `meta` object:**
```json
{
"meta": {
"online_booking_enabled": true,
"booking_window_value": 2,
"booking_window_unit": "month"
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `online_booking_enabled` | boolean | ❌ | `false` = no online booking; the public slot/month endpoints return no availability |
| `booking_window_value` | integer | ❌ | How far ahead patients may book (≥ 1) |
| `booking_window_unit` | string | ❌ | `"week"` or `"month"` |
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month" }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
**Session Config Object:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `active` | boolean | ✅ | Whether this session is active |
| `location_id` | integer\|null | ❌ | Doctor address/location ID |
| `start_time` | string | ✅ | Session start `"HH:MM"` |
| `end_time` | string | ✅ | Session end `"HH:MM"` |
| `duration_per_patient` | integer | ✅ | Minutes per appointment slot |
| `has_rest` | boolean | ❌ | Whether to insert rest breaks |
| `rest_interval` | integer | ❌ | Work minutes before taking a rest break |
| `time_to_rest` | integer | ❌ | Duration of each rest break (minutes) |
| `patient_limit` | integer\|null | ❌ | Max patients per session (`null` = unlimited) |
> Multiple sessions per day are supported (e.g., morning + afternoon). Sessions are sorted by `start_time` and overlapping ones are skipped.
### Response `201`
```json
{
"success": true,
"data": {
"data": {
"uuid": "sched-uuid-...",
"doctor_uuid": "550e8400-...",
"schedule": {
"0": { "sessions": [ { "active": true, "start_time": "09:00", ... } ] },
"1": { "sessions": [] },
"2": { "sessions": [] },
"3": { "sessions": [] },
"4": { "sessions": [] },
"5": { "sessions": [] },
"6": { "sessions": [] }
},
"meta": {
"online_booking_enabled": true,
"booking_window_value": 1,
"booking_window_unit": "month"
},
"created_at": 1717000000,
"updated_at": 1717000000
}
}
}
```
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
> This is an **upsert** — if a schedule already exists for the doctor, it is overwritten.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_VALIDATION_002` | 404 | Doctor not found |
| `ERR_AUTH_006` | 403 | Not the doctor owner |
---
### GET `/api/v1/appointment-settings/weekly-schedule/{uuid}`
Get weekly schedule. `{uuid}` can be either the **schedule UUID** or the **doctor UUID** — the controller tries both.
**Permission:** `AUTH` (class-level `IS_AUTHENTICATED_FULLY`)
### Response `200`
Same structure as POST response.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_VALIDATION_002` | 404 | Schedule not found |
---
### PATCH `/api/v1/appointment-settings/weekly-schedule/{uuid}`
Update weekly schedule. `{uuid}` can be schedule UUID or doctor UUID.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
### Request Body
```json
{
"schedule": {
"0": {
"sessions": [
{
"active": true,
"location_id": 1973,
"start_time": "10:00",
"end_time": "14:00",
"duration_per_patient": 30,
"has_rest": false,
"rest_interval": 0,
"time_to_rest": 0,
"patient_limit": null
}
]
}
}
}
```
> Replaces the entire `schedule` object if provided.
### Response `200`
Updated schedule object (same structure as POST).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner |
| `ERR_VALIDATION_002` | 404 | Schedule not found |
---
### DELETE `/api/v1/booking-setting/{uuid}`
Delete a weekly schedule.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
> Note: route is `/booking-setting/`, not `/appointment-settings/`
### Response `200`
```json
{ "success": true, "data": { "message": "برنامه هفتگی با موفقیت حذف شد" } }
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner |
| `ERR_VALIDATION_002` | 404 | Schedule not found |
---
## Date Overrides
Override a specific date — mark it inactive (day off) or give it custom sessions.
### GET `/api/v1/appointment-settings/date-override/list/{doctorUuid}`
Get all date overrides for a doctor.
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
{
"success": true,
"data": {
"data": [
{
"uuid": "...",
"doctor_uuid": "...",
"date": 1718476800,
"active": false,
"reason": "تعطیل خاص",
"custom_slots": [],
"created_at": 1717000000
}
]
}
}
```
> `date` is a Unix timestamp. `active: false` = entire day blocked. `active: true` with `custom_slots` = custom session schedule.
---
### POST `/api/v1/appointment-settings/date-override`
Create a date override.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
### Request Body
```json
{
"doctor_uuid": "550e8400-...",
"date": "2024-06-20",
"active": false,
"reason": "تعطیل رسمی",
"custom_slots": null
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `date` | string | ✅ | Date in `Y-m-d` format (e.g. `"2024-06-20"`) |
| `active` | boolean | ❌ | `false` = full day off (default); `true` = use custom_slots |
| `reason` | string | ❌ | Reason for override |
| `custom_slots` | array\|null | ❌ | Custom sessions (same SessionConfig format as weekly schedule, see below) |
**`custom_slots` format when `active: true`:**
```json
{
"custom_slots": [
{
"start_time": "14:00",
"end_time": "18:00",
"duration_per_patient": 20,
"location_id": 1973,
"has_rest": false,
"rest_interval": 0,
"time_to_rest": 0,
"patient_limit": null
}
]
}
```
**Legacy format (backward compatible):**
```json
{
"custom_slots": [
{ "start": "14:00", "end": "18:00", "duration": 20 }
]
}
```
### Response `201`
```json
{
"success": true,
"data": {
"data": {
"uuid": "override-uuid-...",
"doctor_uuid": "...",
"date": 1718476800,
"active": false,
"reason": "تعطیل رسمی",
"custom_slots": [],
"created_at": 1717000000
}
}
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner |
| `ERR_VALIDATION_002` | 404 | Doctor not found |
| `ERR_VALIDATION_001` | 422 | Invalid date format |
---
### GET `/api/v1/appointment-settings/date-override/{uuid}`
Get a single date override.
**Permission:** `AUTH` (class-level)
### Response `200`
Override object (same structure as above).
---
### PATCH `/api/v1/appointment-settings/date-override/{uuid}`
Update a date override.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
### Request Body (all optional)
```json
{
"active": true,
"reason": "جبران مرخصی",
"custom_slots": [
{
"start_time": "14:00",
"end_time": "18:00",
"duration_per_patient": 20,
"location_id": 1973,
"has_rest": false,
"rest_interval": 0,
"time_to_rest": 0,
"patient_limit": null
}
],
"date": "2024-06-21"
}
```
### Response `200`
Updated override object.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner |
| `ERR_VALIDATION_002` | 404 | Override not found |
---
### DELETE `/api/v1/appointment-settings/date-override/{uuid}`
Delete a date override.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
### Response `200`
```json
{ "success": true, "data": { "message": "Override با موفقیت حذف شد" } }
```
---
## Holidays
Mark a date range as holiday — all slots blocked, no overrides apply.
### GET `/api/v1/appointment-settings/holidays/list/{doctorUuid}`
Get all holidays for a doctor.
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
{
"success": true,
"data": {
"data": [
{
"uuid": "...",
"doctor_uuid": "...",
"start_date": 1719792000,
"end_date": 1720656000,
"reason": "تعطیلات تابستانی",
"active": true,
"created_at": 1717000000
}
]
}
}
```
> `start_date` and `end_date` are Unix timestamps. Holidays take **highest priority** — they block the day even if a date override exists.
---
### POST `/api/v1/appointment-settings/holidays`
Create a holiday range.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
### Request Body
```json
{
"doctor_uuid": "550e8400-...",
"start_date": "2024-07-01",
"end_date": "2024-07-10",
"reason": "تعطیلات تابستانی"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `start_date` | string | ✅ | Start date `Y-m-d` |
| `end_date` | string | ✅ | End date `Y-m-d` (must be ≥ start_date) |
| `reason` | string | ❌ | Holiday reason |
### Response `201`
```json
{
"success": true,
"data": {
"data": {
"uuid": "holiday-uuid-...",
"doctor_uuid": "...",
"start_date": 1719792000,
"end_date": 1720656000,
"reason": "تعطیلات تابستانی",
"active": true,
"created_at": 1717000000
}
}
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner |
| `ERR_VALIDATION_002` | 404 | Doctor not found |
| `ERR_VALIDATION_001` | 422 | end_date before start_date or invalid format |
---
### GET `/api/v1/appointment-settings/holidays/{uuid}`
Get a single holiday.
**Permission:** `AUTH` (class-level)
---
### PATCH `/api/v1/appointment-settings/holidays/{uuid}`
Update a holiday.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
### Request Body (all optional)
```json
{
"start_date": "2024-07-02",
"end_date": "2024-07-12",
"reason": "تمدید تعطیلات",
"active": false
}
```
### Response `200`
Updated holiday object.
---
### DELETE `/api/v1/appointment-settings/holidays/{uuid}`
Delete a holiday.
**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN`
### Response `200`
```json
{ "success": true, "data": { "message": "تعطیلات حذف شد" } }
```
---
## Slot Calculation Logic (Reference)
The `SlotCalculatorService` calculates available slots in this priority order:
1. **Holiday** — if date falls in a holiday range → return empty (no slots)
2. **Date Override** — if a date override exists for this date:
- `active: false` → return empty
- `active: true` → use `custom_slots` sessions
3. **Weekly Schedule** — use the day's `sessions` array (only `active: true` sessions, sorted by `start_time`, overlapping sessions skipped)
**Slot output format:**
```json
[
{
"start": 1718438400,
"end": 1718439600,
"start_time": "09:00",
"end_time": "09:20",
"location_id": 1973
}
]
```
---
## Available Locations
### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to.
#### Response `200`
```json
{
"success": true,
"data": [
{
"id": "5",
"uuid": "...",
"type": "personal",
"clinic_id": null,
"clinic_name": null,
"name": "مطب شخصی",
"address": "تهران، ...",
"telephone": "09121234567",
"map": { "latitude": "35.7", "longitude": "51.4" },
"city": { "id": "1", "name": "تهران" },
"province": { "id": "8", "name": "تهران" }
},
{
"id": "12",
"uuid": "...",
"type": "clinic",
"clinic_id": "3",
"clinic_name": "کلینیک نور",
"name": "شعبه مرکزی",
"address": "تهران، خیابان ولیعصر...",
"telephone": "02112345678",
"map": { "latitude": "35.699", "longitude": "51.337" },
"city": { "id": "1", "name": "تهران" },
"province": { "id": "8", "name": "تهران" }
}
]
}
```
| Field | Description |
|-------|-------------|
| `type` | `personal` = doctor's own address; `clinic` = clinic address |
| `clinic_id` | ID of the clinic (only for type=clinic) |
| `clinic_name` | Name of the clinic (only for type=clinic) |
> Use `id` as the `location_id` value in weekly schedule sessions or date override sessions.
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | Doctor not found |