Task 06's engine could only be switched on through the API, and nothing checked whether the environment was ready for it. Since the mode choice is irreversible, picking it with no resources defined would lock a clinic into a state where no appointment is ever computable. Backend now refuses that: resource mode requires at least one active resource, with a message that says what to define first. Same shape as the existing service-mode guard, applied on both save paths. The panel shows the same conditions as a ✓/✗ list before the choice is made, each unmet one linking to where it gets fixed — a 422 after an irreversible decision is the wrong place to learn about a prerequisite. Also adds the search step (minimum 5 minutes) and extends the existing mode cards to three rather than building a parallel component. No strategy picker: task 06 never built the strategies, and an empty menu reads worse than an absent one. GET /api/v1/service-items now returns has_segments, computed with one aggregate query for the whole list rather than one per service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
25 KiB
Appointment Settings API
Prefix:
/api/v1/appointment-settings
Permission: every endpoint requiresAUTHand 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:
ROLE_ADMIN, or- the doctor themselves, or
- in a clinic context only, someone holding that clinic's
appointment_settingspermission —viewforGET,updateforPOST/PATCH/DELETE(seedocs/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_uuidthe request targets the personal context, which only the doctor and an admin may touch. The clinic panel must sendclinic_uuid; the admin SPA already does (ScheduleSectiontakes aclinicUuidprop).
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
الزام آدرس: هر 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)
{
"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:
{
"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
metais absent:{ online_booking_enabled: true, booking_window_value: 3, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }.metais stored inside the schedulesettingJSON (no DB migration) and is preserved when onlyscheduleis sent.SlotCalculatorServicerejects any date in the past, beyondtoday + value unit, or when online booking is disabled — for the weekly schedule, date overrides, andappointment-slotsalike.اجبار حالت سرویسی: اگر
booking_mode = serviceذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبتدهی» (bookable = true) نداشته باشد،POST/PATCHبرنامهٔ هفتگی با422(ERR_VALIDATION_001, fieldbooking_mode) رد میشود.غیرقابلتغییر پس از ثبت:
booking_modeفقط تا اولین ثبت قابلانتخاب است. پس از آنکه یکبار بهصورت صریح ذخیره شد (درsetting.meta.booking_modeنوشته شد)، هرPOST/PATCHکه آن را تغییر دهد با422(«نوع نوبتدهی پس از ثبت قابل تغییر نیست»، fieldbooking_mode) رد میشود. پاسخِtoArrayفیلد booleanbooking_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_timeand overlapping ones are skipped.
Response 201
{
"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
Request Body
{
"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
scheduleobject 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
Note: route is
/booking-setting/, not/appointment-settings/
Response 200
{ "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 (403 ERR_AUTH_006 otherwise).
Response 200
{
"success": true,
"data": {
"data": [
{
"uuid": "...",
"doctor_uuid": "...",
"date": 1718476800,
"date_string": "2024-06-15",
"active": false,
"reason": "تعطیل خاص",
"custom_slots": [],
"created_at": 1717000000
}
]
}
}
dateis a Unix timestamp at server-local midnight (Asia/Tehran);date_stringis the same day asY-m-dand is what clients must render — converting the timestamp in a browser withtoISOString()(UTC) shifts it one day back.active: false= entire day blocked.active: truewithcustom_slots= custom session schedule.
POST /api/v1/appointment-settings/date-override
Create a date override.
Permission: AUTH — see Access rule
Request Body
{
"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:
{
"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):
{
"custom_slots": [
{ "start": "14:00", "end": "18:00", "duration": 20 }
]
}
Response 201
{
"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
Request Body (all optional)
{
"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
Response 200
{ "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 (403 ERR_AUTH_006 otherwise).
Response 200
{
"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_dateare Unix timestamps at server-local midnight (Asia/Tehran);start_date_string/end_date_stringcarry the same days asY-m-dand 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
Request Body
{
"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
{
"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
Request Body (all optional)
{
"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
Response 200
{ "success": true, "data": { "message": "تعطیلات حذف شد" } }
Slot Calculation Logic (Reference)
The SlotCalculatorService calculates available slots in this priority order:
- Holiday — if date falls in a holiday range → return empty (no slots)
- Date Override — if a date override exists for this date:
active: false→ return emptyactive: true→ usecustom_slotssessions
- Weekly Schedule — use the day's
sessionsarray (onlyactive: truesessions, sorted bystart_time, overlapping sessions skipped)
Slot output format:
[
{
"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 (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
{
"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
idas thelocation_idvalue 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).
حالت سوم: نوبتدهی منبعمحور
meta.booking_mode مقدار سوم resource را هم میپذیرد (تسک ۰۶). در این حالت نوبت به
بخشها تقسیم میشود و وقت آزاد از تقاطع تقویم منابع میآید.
فیلد meta |
معنی |
|---|---|
booking_mode: "resource" |
حالت منبعمحور |
step_minutes |
گام جستجوی وقت؛ پیشفرض ۱۵، کمینه ۵ |
شرط آمادگی
انتخاب این حالت برگشتناپذیر است، پس پیش از ثبت سنجیده میشود: محیط باید حداقل یک منبع فعال داشته باشد.
{
"success": false,
"data": null,
"errors": [{
"code": "ERR_VALIDATION_001",
"message": "برای نوبتدهی منبعمحور حداقل یک منبع فعال لازم است؛ اول اتاق، اپراتور یا دستگاه تعریف کنید",
"field": "booking_mode"
}]
}
بدون این نگهبان، کلینیک حالتی را برای همیشه قفل میکرد که هیچ وقتی در آن محاسبه نمیشود.
پنل همین شرطها را پیش از ثبت بهصورت ✓/✗ نشان میدهد تا کاربر به ۴۲۲ نخورد.
has_segments روی فهرست سرویسها
GET /api/v1/service-items حالا فیلد has_segments هم میدهد — با یک کوئری تجمعی برای
کل فهرست، نه یکی per سرویس. تنظیمات نوبتدهی از همین میفهمد آمادگیِ حالت منبعمحور
هست یا نه.
جزئیات بخشها: appointment-plan.md · منابع: resource.md