# Appointment Settings API > **Prefix:** `/api/v1/appointment-settings` > **Permission:** every endpoint requires `AUTH` and resolves access through one shared rule (below) Doctors configure their availability via three resources: **weekly schedule**, **date overrides**, and **holidays**. ## Booking context (`clinic_uuid`) Every endpoint in this file operates inside **one booking context**, selected by the optional `clinic_uuid` parameter (query string on `GET`/`DELETE`, body field on `POST`/`PATCH`): | `clinic_uuid` | Context | Services usable | Addresses selectable | |---|---|---|---| | omitted / `null` | the doctor's **personal practice** | `entity_type='doctor'` | the doctor's own `personal` addresses | | a clinic uuid | that **doctor inside that clinic** | `entity_type='clinic'` | that clinic's addresses | A doctor holds **one schedule per context** — a personal one plus one per clinic — and they are fully independent: separate sessions, separate `booking_mode` lock, separate date overrides. Services never cross the boundary (they are polymorphic on `service_sections.entity_type`). If the doctor is not a member of the given clinic → `422 ERR_VALIDATION_001` («این پزشک عضو کلینیک انتخاب‌شده نیست»). Unknown clinic → `404 ERR_VALIDATION_002`. ## Access rule Given the target doctor and the resolved context, access is granted when the caller is: 1. `ROLE_ADMIN`, **or** 2. the doctor themselves, **or** 3. in a **clinic context only**, someone holding that clinic's `appointment_settings` permission — `view` for `GET`, `update` for `POST`/`PATCH`/`DELETE` (see `docs/api/clinic.md` → *Clinic Doctor Permissions*). The clinic owner always passes this check. Anything else → `403 ERR_AUTH_006`. > **Breaking change (2026-07):** a clinic owner can no longer read or write a member doctor's > **personal** schedule. Without `clinic_uuid` the request targets the personal context, which only > the doctor and an admin may touch. The clinic panel must send `clinic_uuid`; the admin SPA already > does (`ScheduleSection` takes a `clinicUuid` prop). --- ## Weekly Schedule Each doctor has **one weekly schedule per context** (upsert keyed by `doctor_id` + `clinic_id`). 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` — see [Access rule](#access-rule) > **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود. > > **الزام محیط:** آدرس انتخاب‌شده باید به همان context تعلق داشته باشد. آدرس کلینیک در محیط شخصی (و برعکس) → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»). > > **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند. ### Request Body (`application/json`) ```json { "doctor_uuid": "550e8400-e29b-41d4-a716-446655440000", "clinic_uuid": null, "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", "booking_mode": "service", "buffer_minutes": 5 } } ``` | 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 | ❌ | `"day"`, `"week"` or `"month"` (invalid value keeps the current one) | | `booking_mode` | string | ❌ | `"slot"` (پیش‌فرض) = نوبت‌دهی اسلاتی با مدت ثابت (`duration_per_patient`). `"service"` = مدت هر نوبت از `duration_minutes` سرویسِ انتخاب‌شده؛ زمان‌ها با `GET /api/v1/appointment-service-slots` گرفته می‌شوند. مقدار نامعتبر نادیده گرفته می‌شود | | `buffer_minutes` | integer | ❌ | فقط حالت سرویسی: فاصلهٔ بین نوبت‌ها (دقیقه، ≥ 0). در `slot_end` ذخیره نمی‌شود؛ فقط فاصلهٔ بین زمان‌های پیشنهادی | > Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 3, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `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. > > **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبت‌دهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد می‌شود. > > **غیرقابل‌تغییر پس از ثبت:** `booking_mode` فقط تا **اولین ثبت** قابل‌انتخاب است. پس از آنکه یک‌بار به‌صورت صریح ذخیره شد (در `setting.meta.booking_mode` نوشته شد)، هر `POST`/`PATCH` که آن را تغییر دهد با `422` («نوع نوبت‌دهی پس از ثبت قابل تغییر نیست»، field `booking_mode`) رد می‌شود. پاسخِ `toArray` فیلد boolean `booking_mode_locked` را برمی‌گرداند (`true` = قفل‌شده) تا پنل توگل را غیرفعال کند. رکوردهای قدیمی که هنوز mode صریح ندارند، `booking_mode_locked=false` و یک‌بار قابل‌انتخاب‌اند. **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": 3, "booking_window_unit": "month", "booking_mode": "slot", "buffer_minutes": 0 }, "booking_mode_locked": true, "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` — see [Access rule](#access-rule) ### 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` — see [Access rule](#access-rule) > 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` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise). ### Response `200` ```json { "success": true, "data": { "data": [ { "uuid": "...", "doctor_uuid": "...", "date": 1718476800, "date_string": "2024-06-15", "active": false, "reason": "تعطیل خاص", "custom_slots": [], "created_at": 1717000000 } ] } } ``` > `date` is a Unix timestamp at **server-local midnight** (`Asia/Tehran`); `date_string` is the same day as `Y-m-d` and is what clients must render — converting the timestamp in a browser with `toISOString()` (UTC) shifts it one day back. `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` — see [Access rule](#access-rule) ### 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, "date_string": "2024-06-15", "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` — see [Access rule](#access-rule) ### 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` — see [Access rule](#access-rule) ### 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` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise). ### Response `200` ```json { "success": true, "data": { "data": [ { "uuid": "...", "doctor_uuid": "...", "start_date": 1719792000, "end_date": 1720656000, "start_date_string": "2024-07-01", "end_date_string": "2024-07-11", "reason": "تعطیلات تابستانی", "active": true, "created_at": 1717000000 } ] } } ``` > `start_date` / `end_date` are Unix timestamps at **server-local midnight** (`Asia/Tehran`); `start_date_string` / `end_date_string` carry the same days as `Y-m-d` and are what clients must render (UTC conversion in the browser shifts them one day back). 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` — see [Access rule](#access-rule) ### 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, "start_date_string": "2024-07-01", "end_date_string": "2024-07-11", "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` — see [Access rule](#access-rule) ### 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` — see [Access rule](#access-rule) ### 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` — see [Access rule](#access-rule) (`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 | --- ## Context additions (2026-07) ### Response fields `WeeklySchedule.toArray()` now also returns: | Field | Type | Meaning | |---|---|---| | `clinic_uuid` | `string\|null` | the clinic this schedule belongs to; `null` = personal practice | | `context` | `"personal" \| "clinic"` | convenience mirror of the above | `DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid` plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below). ### Holidays are global by default A holiday means "the doctor is not there", which is a physical fact — so unlike schedules and date overrides it is **not** per-context by default: | `clinic_id` | Meaning | |---|---| | `NULL` | the doctor is absent **everywhere** — applies to the personal practice and every clinic | | set | the doctor is absent in that clinic only | `GET /holidays/list/{doctorUuid}?clinic_uuid=…` returns the **union**: the clinic's own holidays plus the doctor's global ones. Global rows come back with `editable: false` — a clinic must see that the doctor is away but may not delete that fact. `POST /holidays` **without** `clinic_uuid` creates a global holiday and is restricted to the doctor themselves and admins → otherwise `403 ERR_ACCESS_DENIED` («کلینیک فقط می‌تواند تعطیلی مخصوص خودش را ثبت کند»). A clinic closing for *all* its doctors is not expressible in this model and needs a separate `ClinicHoliday` entity — **not implemented**. ### Date overrides are always per-context `GET /date-override/list/{doctorUuid}?clinic_uuid=…` returns only that context's overrides — no union, because an override changes working hours and working hours are themselves per-context. ### `GET /available-locations/{doctorUuid}` Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it, only that clinic's addresses. The two sets are never merged (they used to be).