- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status. - Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions. - Modified appointment-related endpoints to handle management context and ensure proper authorization checks. - Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted. - Improved documentation for API endpoints to reflect new management parameters and behaviors.
53 KiB
Appointment API
Prefix:
/api/v1/appointment*
GET /api/v1/appointment-slots
Get all appointment slots (available and booked) for a doctor on a specific date.
Permission: PUBLIC (anonymous), plus an authenticated management mode — see management below.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
doctor_uuid |
string (UUID) | ✅ | Doctor UUID |
date |
string | ✅ | Date in Y-m-d format (e.g. 2024-06-15) |
clinic_uuid |
string (UUID) | ❌ | Booking context; omitted = doctor's personal office |
management |
1 |
❌ | Management mode — see note |
Management mode (
management=1). Turning off online booking (online_booking_enabled=false) or the advance booking-window limit are public-site rules only. When the request carries a valid JWT of a user who may manage this doctor/clinic's appointments (admin, the doctor themself, a clinic manager/secretary with theappointmentspermission), passingmanagement=1bypasses those two gates so the panel always shows slots. Past dates are still rejected. If the token is missing or the user is not authorized,managementis ignored and the endpoint behaves as public (fail-safe). Same flag applies to/appointment-service-slots,/appointment-booking-locations/{doctorUuid}, and/appointment-settings/month-availability/{doctorUuid}.These four routes moved from the
security: falsefirewall onto the JWT firewall so a bearer token can be authenticated on them; anonymous callers still reach them via thePUBLIC_ACCESSaccess-control rules.
Response 200
{
"success": true,
"data": {
"doctor_uuid": "550e8400-...",
"date": "2024-06-15",
"sessions": [
{
"start_time": "09:00",
"end_time": "13:00",
"slots": [
{
"start": 1718438400,
"end": 1718439600,
"start_time": "09:00",
"end_time": "09:20",
"location_id": null,
"is_available": true
},
{
"start": 1718439600,
"end": 1718440800,
"start_time": "09:20",
"end_time": "09:40",
"location_id": null,
"is_available": false
}
]
},
{
"start_time": "15:00",
"end_time": "17:00",
"slots": [...]
}
]
}
}
Returns all slots grouped by work shift.
is_available: falsemeans the slot is either taken by a booking or its start time has already passed (for today's date). A slot counts as taken when an overlapping appointment is in any blocking status (Appointment::SLOT_BLOCKING_STATUSES):confirmed,completed,following_up,salon,no_show, or a still-livepending(not yet expired). Onlycancelled_by_user/cancelled_by_doctor/expiredrelease the slot. Session boundaries match the doctor'sWeeklyScheduleor date override config.Returns an empty
sessionsarray when the date is a holiday, a closed date override, in the past, beyond the doctor's booking window, or when online booking is disabled (seemetainappointment-settings.md).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Doctor not found |
ERR_VALIDATION_001 |
422 | Missing or invalid date/doctor_uuid |
GET /api/v1/appointment-service-slots
زمانهای خالیِ کافی در حالت نوبتدهی سرویسی (booking_mode = service). برخلاف /appointment-slots که اسلاتِ ثابت میسازد، این endpoint مدت نوبت را از مجموعِ duration_minutes سرویسهای انتخابشده (+ buffer_minutes برنامهٔ هفتگی) میگیرد و فضای خالی داخل شیفتها را با رد کردن نوبتهای اشغالشده میچیند. فقط سرویسهای «نمایش در نوبتدهی» (bookable = true) پذیرفته میشوند.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
doctor_uuid |
string (uuid) | ✅ | |
date |
string Y-m-d |
✅ | |
service_item_uuids[] |
string[] | ✅ | یک یا چند UUID سرویسِ bookable |
durations[<service_uuid>] |
int | ❌ | override مدت (دقیقه) برای همان سرویس — فقط در این محاسبه استفاده میشود و مقدار پیشفرضِ سرویس در تنظیمات تغییر نمیکند. برای نوبتدهیِ منشی که مدت را برای یک نوبت تغییر میدهد. مقدار ≤ 0 یا غایب ⇒ مدت پیشفرض سرویس |
management |
1 |
❌ | حالت مدیریت — با JWTِ مجاز، توگلِ نوبتدهی آنلاین و سقف بازهٔ رزرو دور زده میشود (رجوع به توضیح /appointment-slots) |
Response 200
{
"success": true,
"data": {
"doctor_uuid": "…",
"date": "2026-07-16",
"total_duration_minutes": 45,
"buffer_minutes": 5,
"start_times": [
{ "start": 1750000000, "end": 1750002700, "start_time": "15:00", "end_time": "15:45", "location_id": 12 }
]
}
}
start_times خالی یعنی در آن روز فضای کافی نیست. end بدونِ بافر است (بافر فقط فاصلهٔ بین نوبتهای پیشنهادی است). عمومی (بدون احراز هویت — مصرفکننده: سایت nobat724).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_002 |
404/422 | Doctor / service item not found |
ERR_VALIDATION_001 |
422 | فرمت تاریخ نادرست، پزشک در حالت سرویسی نیست، سرویس bookable نیست، یا مدت سرویس تعریف نشده |
GET /api/v1/appointment-booking-services/{doctorUuid}
عمومی. روش نوبتدهی پزشک + سرویسهای قابلانتخاب برای نوبتگیری سرویسی. سایت با این پاسخ تصمیم میگیرد مرحلهٔ «انتخاب سرویس» را نشان دهد (حالت service) یا جریان اسلاتیِ فعلی (حالت slot).
Response 200
{
"success": true,
"data": {
"doctor_uuid": "…",
"booking_mode": "service",
"buffer_minutes": 5,
"services": [
{ "uuid": "…", "name": "عصبکشی", "duration_minutes": 30, "price_rials": 5000000, "service_section": { "uuid": "…", "name": "دندان" } }
]
}
}
services فقط سرویسهای bookable=true و فعالِ پزشک را دارد؛ در حالت slot معمولاً خالی است.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_002 |
404 | Doctor not found |
GET /api/v1/appointment-settings/month-availability/{doctorUuid}
Which days of a month are bookable — used by the public calendar to grey out unavailable days.
Permission: PUBLIC
Path Parameters
| Param | Type | Description |
|---|---|---|
doctorUuid |
string (UUID) | Doctor UUID |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
year |
integer | ✅ | Gregorian year (e.g. 2026) |
month |
integer | ✅ | Gregorian month 1–12 |
clinic_uuid |
string (UUID) | ❌ | Booking context; omitted = personal office |
management |
1 |
❌ | حالت مدیریت — با JWTِ مجاز، توگلِ نوبتدهی آنلاین دور زده میشود (رجوع به /appointment-slots) |
Input is Gregorian. A Jalali (Shamsi) front-end must convert the displayed month to the Gregorian month(s) it spans before calling.
Response 200
{
"success": true,
"data": {
"year": 2026,
"month": 6,
"disabled_dates": ["2026-06-01", "2026-06-17", "2026-06-26"],
"enabled_dates": ["2026-06-15", "2026-06-16", "2026-06-18"],
"online_booking_enabled": true,
"booking_window": { "value": 1, "unit": "month" }
}
}
| Field | Type | Description |
|---|---|---|
disabled_dates |
string[] | Y-m-d days with no bookable slot (holiday / closed override / non-working / past / out-of-window) |
enabled_dates |
string[] | Y-m-d days with at least one slot |
online_booking_enabled |
boolean | Doctor's online-booking flag |
booking_window |
object | { value, unit } — unit is week or month |
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_002 |
404 | Doctor not found |
ERR_VALIDATION_001 |
422 | Invalid year/month |
POST /api/v1/appointment
Book an appointment slot.
Permission: AUTH — any authenticated user
Request Body (application/json)
{
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"slot_start": 1718438400,
"slot_end": 1718439600,
"for_self": false,
"patient_name": "علی احمدی",
"patient_mobile": "09120000000",
"patient_national_code": "0012345678",
"patient_gender": "man",
"patient_reason": "چکاپ",
"note": "لطفاً سریع ویزیت شوم"
}
| Field | Type | Required | Description |
|---|---|---|---|
doctor_uuid |
string (UUID) | ✅ | Doctor UUID |
slot_start |
integer | ✅ | Slot start (Unix timestamp) |
slot_end |
integer | ⚠️ | Slot end (Unix timestamp). فقط وقتی duration_from_services=true باشد سرور آن را از slot_start + Σ duration_minutes بازمحاسبه میکند؛ در غیر این صورت مقدار کلاینت حفظ میشود |
service_item_uuids |
string[] | ❌ | یک یا چند UUID سرویس که به نوبت پیوست میشوند (چند سرویس). اولین سرویس بهعنوان سرویسِ اصلی (service_item) ثبت و همه در service_items برمیگردند. UUID ناموجود ⇒ 422 |
duration_from_services |
boolean | ❌ | true = حالت نوبتدهی سرویسی: مدت نوبت از مجموع duration_minutes سرویسها محاسبه و slot_end بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ 422. پیشفرض false (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ میشود) |
service_durations |
object | ❌ | override مدت هر سرویس بهصورت { "<service_uuid>": <minutes> } — فقط وقتی duration_from_services=true. برای نوبتدهیِ منشی که مدت را برای همان نوبت تغییر میدهد؛ در محاسبهٔ slot_end لحاظ میشود و مقدار پیشفرضِ سرویس تغییر نمیکند. مقدار ≤ 0 یا غایب ⇒ مدت پیشفرض |
for_self |
boolean | ❌ | true (default) = patient is the logged-in payer; false = booking for someone else |
patient_name |
string | ⚠️ | Required when for_self=false; otherwise filled from the payer's profile |
patient_mobile |
string | ⚠️ | Required when for_self=false; otherwise the payer's mobile |
patient_national_code |
string | ✅ | کد ملی بیمار — همیشه الزامی (هر دو حالت for_self). باید ۱۰ رقم معتبر باشد (isValidIranNationalCode)؛ ارقام فارسی به انگلیسی تبدیل میشوند |
patient_gender |
string | ✅ | جنسیت بیمار — همیشه الزامی. ورودی man/male یا woman/female پذیرفته میشود و به فرمِ متعارف man/woman ذخیره میگردد |
patient_reason |
string | ❌ | Reason for visit |
note |
string | ❌ | Patient note |
city_id |
integer | ❌ | شناسهی شهرِ دامنهی جاری (از city.json سایت). برای گاردِ پورسانت نماینده: اگر شهر نمایندهی فعال داشته باشد، booking_representation_id نوبت ست میشود. پورسانت فقط وقتی واریز میشود که این نماینده با نمایندهی پزشک یکی باشد. خالی/ناموجود ⇒ بدون پورسانت |
آدرس نوبت: آدرس (
address_id) ارسالی نیست؛ سرور آن را از رویlocation_idهمان session در برنامهی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره میکند. در پاسخ بهصورتaddress_idبرمیگردد. همهی مسیرهای رزرو (آنلاینPOST /api/v1/appointment، منشیPOST /api/v1/my/appointment، ادمین) آدرس را به همین شکل ست میکنند.
تضمین عدم رزرو دوگانه: هر سه مسیر رزرو از
AppointmentRepository::bookAtomically()عبور میکنند و یک قید یکتای دیتابیسی (active_slot_key) پشت آن قرار دارد؛ بنابراین حتی در شرایط رقابتی (race) فقط یک نوبتِ زنده روی هر(doctor, slot_start)ممکن است و درخواست بازنده409 SLOT_TAKENمیگیرد. نوبتهای لغو/منقضی اسلات را آزاد میکنند (کلیدNULL). علاوه بر این،bookAtomicallyداخل تراکنش یک قفلِ per-doctor (PESSIMISTIC_WRITEروی ردیف پزشک) میگیرد؛ چون در حالت سرویسی نوبتها طول متغیر و شروعِ متفاوت دارند و قید یکتای(doctor, slot_start)تداخلِ بازهایِ دو رزروِ همزمان با شروعِ متفاوت را نمیگیرد. این قفل بررسیِ overlap و insert را نسبت به سایر رزروهای همان پزشک اتمیک میکند.
Auto-add to clinic: هنگام تأیید نوبت، اگر آدرس نوبت متعلق به یک کلینیک باشد (
DoctorAddress.clinic_id)، بیمار علاوه بر پروندهی پزشک، به پروندههای آن کلینیک هم اضافه میشود. اگر آدرس کلینیک نداشت ولی دکتر فقط عضو یک کلینیک بود، به همان کلینیک اضافه میشود. هر شاخه مشروط به فعالبودنpatient_records. جزئیات درdocs/api/patient.md.Auto-fill session on confirm: پروندهای که هنگام تأیید نوبت خودکار ساخته میشود، اکنون از خود نوبت پر میشود:
session_at= زمان واقعی نوبت (slot_start)،visit_price_rials= هزینه ویزیت نوبت (در نبود آن، «قیمت ویزیت آزاد» تنظیمات)، برای هر سرویسِ نوبت یک ردیفSessionServiceبا قیمت snapshot از خود سرویس، وservices_total_rials/final_price_rialsمحاسبهشده. قبلاً همهی این مقادیر صفر/خالی ثبت میشدند (باگ).
Payer vs patient: the authenticated user (
user) is always the payer; thepatient_*fields describe who the visit is for and are stored separately. Temporary lock: the slot is held by the newpendingbooking for 15 minutes (expires_at = created_at + 900). If payment is not completed in time, the booking is moved toexpiredand the slot is freed (seeapp:cancel-expired-appointments). An expired pending booking no longer blocks the slot even before the cron runs.
Response 201
{
"success": true,
"data": {
"uuid": "appt-uuid-...",
"doctor": { "uuid": "...", "name": "علی احمدی" },
"user": { "uuid": "...", "mobile": "..." },
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "pending",
"note": "...",
"expires_at": 1718438100,
"patient_name": "علی احمدی",
"patient_mobile": "09120000000",
"patient_national_code": "0012345678",
"patient_gender": "man",
"patient_reason": "چکاپ",
"version": 1,
"created_at": 1717000000
}
}
Appointment Status Values:
| Value | Description |
|---|---|
pending |
Awaiting payment |
confirmed |
Paid and confirmed |
cancelled |
Cancelled |
completed |
Visit completed |
no_show |
Patient did not show |
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_VALIDATION_002 |
404 | Doctor not found |
ERR_CONFLICT_001 |
409 | Slot already booked (incl. concurrent booking — the booking is atomic) |
ERR_VALIDATION_001 |
422 | Invalid slot times, past slot, missing patient name/mobile when for_self=false, missing/invalid patient_national_code, or patient_gender not in man/male/woman/female |
Single-appointment access model
GET /appointment/{uuid}, PATCH /appointment/{uuid}, PATCH /appointment/{uuid}/status
and GET /appointment/{uuid}/events all resolve access through
App\Appointment\Security\AppointmentAccessChecker. The decision is driven by the
appointment's own environment (appointment.clinic: null = the doctor's personal
office, a value = that clinic) — not by the caller's role.
| Caller | Allowed |
|---|---|
ROLE_ADMIN |
everything |
Owning doctor (appointment.doctor.user) |
everything |
Patient (appointment.user) |
view and cancel only — never reschedule/edit |
| Clinic owner | everything, when appointment.clinic is their clinic |
| Member doctor of that clinic | per ClinicDoctorPermission.appointments.{view,cancel,update_status}; denied once the row is active = false |
| Secretary | active-context scope must match the appointment (same clinic and an assigned doctor, or the scope doctor), then DoctorSecretary.appointments.{view,cancel,update_status} |
Actions map onto the existing permission vocabulary: reads use view; edit / move /
reserve-transfer / replace / non-cancel status changes use update_status; any
transition to cancelled_by_doctor / cancelled_by_user requires cancel — including
an inline status sent to PATCH /appointment/{uuid}. Denials return
ERR_ACCESS_DENIED with HTTP 403.
Deactivating a doctor in a clinic (
ClinicDoctorPermission.active = false) or a secretary (DoctorSecretary.active = false) is the single source of truth for "collaboration ended" — both checkers refuse on it. The clinic owner keeps full access.
GET /api/v1/appointment/{uuid}
Get appointment detail.
Permission: AUTH — see Single-appointment access model
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Appointment UUID |
Response 200
{
"success": true,
"data": {
"uuid": "appt-uuid-...",
"doctor": {
"uuid": "...",
"name": "علی احمدی",
"specialties": [
{ "uuid": "...", "name": "اورولوژی عمومی" }
]
},
"address": {
"uuid": "...",
"name": "مطب دکتر علی احمدی",
"address": "یزد، خیابان ...",
"telephone": "035...",
"map": { "latitude": "31.8", "longitude": "54.3" },
"city": { "id": "132", "name": "یزد" },
"province": { "id": "100", "name": "یزد" }
},
"user": { "uuid": "...", "mobile": "09..." },
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "confirmed",
"note": "...",
"patient_name": "...",
"patient_mobile": "...",
"created_at": 1717000000
}
}
doctor.specialtiesآرایه (ممکن است خالی)؛addressاولین آدرس پزشک است (ممکن استnullاگر پزشک آدرسی ندارد).address.map.latitude/longitudeرشته یاnull. تاریخها Unix.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_ACCESS_DENIED |
403 | Caller fails the single-appointment access model |
ERR_NOT_FOUND_001 |
404 | Appointment not found |
GET /api/v1/appointments/doctor/{doctorUuid}
Get all appointments for a specific doctor.
Permission: AUTH — the doctor themselves or ROLE_ADMIN see every appointment of
that doctor. A clinic user (owner, or member doctor holding appointments.view) may also
call it, but the result is scoped to their own clinic: only appointments whose
clinic_id is that clinic are returned, so the doctor's personal-office appointments
never leak into a clinic. Anyone else gets 403.
Path Parameters
| Param | Type | Description |
|---|---|---|
doctorUuid |
string (UUID) | Doctor UUID |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
status |
string | ❌ | Single-status filter (legacy) |
statuses |
string[] | ❌ | Repeatable: statuses=pending&statuses=confirmed |
from |
int | ❌ | Unix ts — slot_start >= from |
to |
int | ❌ | Unix ts — slot_start <= to |
q |
string | ❌ | Substring match on patient name / mobile (appointment and user fields) |
service_uuid |
string (UUID) | ❌ | Filter by service item |
page |
int | ❌ | Default 1 |
limit |
int | ❌ | Default 20, max 100 |
Two response shapes. With none of statuses/from/to/q/service_uuid/page/limit
present, the legacy nested-array response below is returned unchanged. With any of them
present the response is the standard paginated envelope
({ success, data: [...], meta: { totalRecords, totalPages, currentPage, limit } }).
The doctor dashboard filter bar uses the paginated form, defaulting statuses to
pending + confirmed (i.e. "not yet visited").
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"user": { "uuid": "...", "real_name": "..." },
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "confirmed",
"price": 500000
}
]
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_ACCESS_DENIED |
403 | Not the doctor/admin, and no clinic scope granting appointments.view over this doctor |
ERR_NOT_FOUND_001 |
404 | Doctor not found |
GET /api/v1/appointments/user
Get all appointments for the authenticated user.
Permission: AUTH
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
status |
string | ❌ | Filter by status |
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"doctor": { "uuid": "...", "title": "علی احمدی" },
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "confirmed",
"price": 500000
}
]
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
PATCH /api/v1/appointment/{uuid}/status
Change appointment status.
Permission: AUTH — see Single-appointment access model.
The required action depends on the target status: a transition to cancelled_by_doctor /
cancelled_by_user needs appointments.cancel, everything else needs
appointments.update_status. A clinic secretary therefore confirms and completes by
default but cannot cancel until cancel is granted.
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Appointment UUID |
Request Body
{
"status": "cancelled_by_doctor",
"version": 3,
"cancel_reason": "بیمار درخواست لغو داد"
}
| Field | Type | Required | Description |
|---|---|---|---|
status |
string | ✅ | New status value |
version |
integer | ❌ | Optimistic lock version (prevents double-submit) |
cancel_reason |
string | ❌ | Only when transitioning to cancelled_by_doctor / cancelled_by_user. Stored on the recorded cancellation event (Timeline). Ignored for other statuses. |
Allowed Transitions by Actor: the state machine itself is
Appointment::ALLOWED_TRANSITIONS (identical for everyone); the actor only decides
whether the transition may be attempted:
| Actor | Allowed |
|---|---|
| Patient | cancellation of their own appointment only |
| Doctor (owner) / clinic owner / admin | any transition the state machine permits |
| Member doctor / secretary | non-cancel transitions with update_status; cancellations only with cancel |
Cancellation is logged. When the status becomes
cancelled_by_doctororcancelled_by_user, anAppointmentEvent(typecancelled, title «نوبت لغو شد») is recorded with the actor (user id + name), the optionalcancel_reason, and the cancel time — surfaced viaGET /api/v1/appointment/{uuid}/events. Awarning-level entry is also written toapp_log.
Response 200
Updated appointment object.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_ACCESS_DENIED |
403 | Caller lacks update_status (or cancel for a cancellation) on this appointment |
ERR_NOT_FOUND_001 |
404 | Appointment not found |
ERR_CONFLICT_001 |
409 | Version mismatch (optimistic lock) |
ERR_VALIDATION_001 |
422 | Invalid status value |
POST /api/v1/appointment/{uuid}/confirm
Confirm an appointment («ثبت شده» → «قطعی شده») and register its money on the patient case file — status transition, case file / visit, and payments in one atomic transaction. If any step fails nothing is committed.
Permission: AUTH — appointments.update_status per the
single-appointment access model.
Request Body
{
"version": 3,
"payments": [
{ "method": "cash", "amount_rials": 2000000 },
{ "method": "pos", "amount_rials": 3000000 }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
version |
integer | ❌ | Optimistic lock version; defaults to the stored one |
payments |
array | ❌ | Empty/absent = confirm without payment. Each row: method ∈ wallet|pos|cash|card and amount_rials > 0. Several rows are allowed (split payment). |
The sum of payments may not exceed the visit's payable amount → ERR_SESSION_PAYMENT_EXCEEDS.
Partial payment is normal: the remainder stays as remaining_rials on the visit and can be
collected later through POST /api/v1/session/{uuid}/payments.
What happens on the server
pending → confirmed(state machine still applies).AppointmentConfirmationServicefiles the case file for the appointment's environment (appointment.clinic→ clinic, otherwise the doctor's personal office): an existing record for that patient in that environment is reused, otherwise a new one is created. APatientSessionis opened with the visit price and one line per attached service.- Each payment row is registered on that visit (
walletalso debits the patient wallet).
Response 200
{
"success": true,
"data": {
"appointment": { "uuid": "…", "status": "confirmed", "version": 4 },
"session": {
"uuid": "…",
"visit_price_rials": 5000000,
"services_total_rials": 1500000,
"final_price_rials": 6500000,
"discount_rials": 0,
"paid_total_rials": 5000000,
"remaining_rials": 1500000,
"is_paid": false
}
}
}
session is null when the tenant does not have the patient_records subscription
feature — the appointment is still confirmed, it simply has no case file. Sending
payments in that situation fails with 403 ERR_SUBSCRIPTION_REQUIRED and confirms
nothing, because there would be nowhere to record the money.
Reserve-list entries (is_reserve: true) never open a visit; move them onto a real slot
first.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_ACCESS_DENIED |
403 | No update_status on this appointment |
ERR_SUBSCRIPTION_REQUIRED |
403 | Payments sent but the tenant has no patient_records feature |
ERR_VALIDATION_002 |
404 | Appointment not found |
ERR_CONFLICT_001 |
409 | Version mismatch (optimistic lock) |
ERR_VALIDATION_001 |
422 | Transition to confirmed not allowed from the current status |
ERR_SESSION_PAYMENT_INVALID |
422 | Unknown method or non-positive amount_rials |
ERR_SESSION_PAYMENT_EXCEEDS |
422 | Payments exceed the payable amount |
Admin panel: this is the only path to «قطعی شده». Picking
confirmedinAppointmentStatusDropdownopens the «قطعی کردن نوبت» modal rather than issuing a rawPATCH .../status, so confirmation can never silently skip the case file and payment.
GET /api/v1/appointment/{uuid}/events
Appointment Timeline — chronological event history for one appointment. Currently records cancellation events; the structure is generic for future event types.
Permission: IS_AUTHENTICATED_FULLY — read-only, so it needs view (not update_status):
see Single-appointment access model. The patient sees
their own Timeline.
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Appointment UUID |
Response 200
{
"success": true,
"data": [
{
"type": "cancelled",
"title": "نوبت لغو شد",
"actor_name": "دکتر حامد حسینی",
"reason": "بیمار درخواست لغو داد",
"created_at": 1784273931
}
]
}
Events are ordered oldest → newest. data is a flat array (single nesting). actor_name and reason may be null. created_at is a Unix timestamp.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_ACCESS_DENIED |
403 | Not allowed to view this appointment |
ERR_VALIDATION_002 |
404 | Appointment not found |
POST /api/v1/my/appointment
Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
Initial status is
pending(«ثبت شده»), notconfirmed. Every appointment — online, quick, or regular — starts as registered; confirming it is a separate act that shows the costs and takes payment (POST /api/v1/appointment/{uuid}/confirm). Because of that, no case file / visit is opened at creation time any more; it is opened on confirmation.A panel-created
pendingappointment still occupies its slot (so the time stays reserved) and carries noexpires_at, so it is never auto-expired: only online gateway holds (created with a 15-minute TTL byPOST /api/v1/appointment) are swept byAppointmentExpiryService. Ending a stale registered appointment is an operator decision (cancel).
Auth: IS_AUTHENTICATED_FULLY — Roles: ROLE_DOCTOR, ROLE_CLINIC, ROLE_SECRETARY, ROLE_ADMIN
Scope enforced: the caller must be related to the target
doctor_uuid, not merely hold an allowed role. A doctor may book only onto their own calendar; a clinic only onto doctors that belong to it; a secretary only within their active clinic/doctor scope and with theappointments.createpermission; admin onto any. Otherwise403 FORBIDDEN.
Request Body
{
"doctor_uuid": "doctor-uuid",
"slot_start": 1718438400,
"slot_end": 1718439600,
"patient_mobile": "09123456789",
"patient_name": "علی محمدی",
"patient_national_code": "0012345678",
"note": "optional note"
}
| Field | Type | Required | Notes |
|---|---|---|---|
patient_mobile |
string | ✅ | راه تماس بیمار |
patient_name |
string | ✅ | نام بیمار — فقط برای بیمارِ کاملاً جدید استفاده میشود؛ اگر کد ملی به پروفایلِ موجود بخورد، نامِ همان پروفایل روی نوبت ذخیره و نمایش داده میشود و این ورودی نادیده گرفته میشود |
patient_national_code |
string | ✅ | کد ملی بیمار — باید ۱۰ رقم معتبر باشد (isValidIranNationalCode)؛ ارقام فارسی به انگلیسی تبدیل میشوند |
visit_price_rials |
int | شرطی | هزینه ویزیت (ریال). اختیاری؛ ولی اگر فلگ require_visit_price در insurance-pricing برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار > 0 الزامی است. روی نوبت ذخیره و در toArray با کلید visit_price_rials برمیگردد |
هویت بیمار بر پایهی کد ملی: کد ملی روی پروفایل بیمار ذخیره میشود (
profiles.national_code، یکتا). بیمار اول با کد ملیِ پروفایل پیدا میشود، سپس با موبایل. پس یک شخص میتواند چند موبایل داشته باشد ولی پروندهاش (PatientRecord) یکتا میماند. اگر موبایلی که پروفایلش کد ملی دیگری دارد دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمیگردد. اگر هیچ بیماری یافت نشود، کاربر جدید (ROLE_USER) بههمراه پروفایلِ حاملِ همان کد ملی ساخته میشود. موبایلِ واردشده در هر نوبت بهصورت snapshot روی خودِ نوبت (patient_mobile) هم ذخیره میشود.نامِ نمایشدادهشدهی بیمار: چون بیمار با کد ملی به پروفایل واقعیاش resolve میشود، snapshotِ نامِ نوبت (
patient_name) از نامِ همان پروفایل (User.realName) پر میشود، نه از نامِ تایپشده در مودال. نامِ ورودی فقط وقتی روی نوبت مینشیند که بیمار کاملاً جدید باشد و نامی نداشته باشد. لیستِGET /api/v1/my/appointmentsهم همین را نشان میدهد (override_nameتنها برای رزروِ عمومیِ «برای شخص دیگر» — کهuserصاحب حساب است — ازrealNameجدا میشود).
Response 201
{
"success": true,
"data": {
"uuid": "appt-uuid",
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "pending"
}
}
Error Responses
| Code | HTTP | Description |
|---|---|---|
FORBIDDEN |
403 | Role not allowed, or caller not scoped to this doctor |
VALIDATION |
422 | Missing required fields, or missing/invalid patient_national_code (field: patient_national_code), or required visit_price_rials <= 0 when require_visit_price is on (field: visit_price_rials) |
ERR_PROFILE_MOBILE_TAKEN |
422 | این شماره موبایل با کد ملی دیگری ثبت شده است (field: patient_mobile) |
DOCTOR_NOT_FOUND |
404 | Doctor UUID not found |
SLOT_TAKEN |
409 | Slot already booked |
GET /api/v1/my/appointment/patient-lookup
جستجوی بیمار با شماره موبایل، پیش از ثبت نوبت. فرم ثبت نوبت اول با موبایل جستجو میکند؛ اگر بیمار یافت شد و کد ملی دارد، مستقیم استفاده میشود، وگرنه کد ملی و نام از کاربر گرفته میشود.
Auth: IS_AUTHENTICATED_FULLY — Roles: ROLE_DOCTOR, ROLE_CLINIC, ROLE_SECRETARY, ROLE_ADMIN
برخلاف
GET /api/v1/patient/search-user، این endpoint به فیچرpatient_recordsاشتراک وابسته نیست وROLE_ADMINرا هم میپذیرد، چون ثبت نوبت باید مستقل از اشتراک کار کند.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
mobile |
string | ✅ | شماره موبایل ایران (^09\d{9}$)؛ ارقام فارسی به انگلیسی تبدیل میشوند |
Response 200 — یافت شد
{
"success": true,
"data": {
"found": true,
"name": "علی محمدی",
"mobile": "09123456789",
"national_code": "0012345678"
}
}
national_codeممکن استnullباشد (بیمار قدیمی بدون کد ملی) — در این حالت فرم کد ملی را میگیرد.
Response 200 — یافت نشد
{ "success": true, "data": { "found": false } }
Error Responses
| Code | HTTP | Description |
|---|---|---|
FORBIDDEN |
403 | Role not allowed |
VALIDATION |
422 | Invalid mobile (field: mobile) |
GET /api/v1/my/appointments
Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.
Auth: IS_AUTHENTICATED_FULLY (any role)
Role behavior:
| Role | Scope |
|---|---|
ROLE_ADMIN |
All appointments |
ROLE_CLINIC |
Appointments for doctors in this clinic |
ROLE_DOCTOR |
Appointments for this doctor |
ROLE_SECRETARY |
Appointments of the doctors assigned to this secretary in their active scope (empty if appointments.view is false) |
(plain patient ROLE_USER) |
The patient's own appointments (a.user = current user) |
GET /api/v1/my/appointments/today-stats
Same scoping rules as the list above, aggregated into { total, completed, waiting, cancelled }
for one day (?date=Y-m-d, defaults to today).
Auth: IS_AUTHENTICATED_FULLY. A caller with no resolvable scope (clinic/doctor row
missing, secretary without appointments.view or with no assigned doctors) gets all-zero
counts rather than an unscoped, system-wide count. A plain patient gets counts over their
own appointments only.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number |
limit |
int | 15 | Items per page (max 100) |
search |
string | — | Search by mobile, real name, or doctor name |
status |
string | — | Filter by appointment status |
date |
string | — | Filter by date in Y-m-d format |
Response 200
{
"success": true,
"data": [
{
"uuid": "string",
"patient_name": "string",
"patient_mobile": "string",
"doctor_name": "string",
"clinic_name": "string | null",
"appointment_date": "2026-07-25",
"appointment_time": "14:30",
"slot_start": 1700000000,
"status": "reserved",
"amount": 0,
"created_at": "ISO 8601 string"
}
],
"meta": {
"totalRecords": 8000,
"totalPages": 533,
"currentPage": 1
}
}
---
## Clinic workflow extensions (نوبتها — Figma)
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت — چند سرویس، هر عضو `{uuid, name, price_rials}`؛ `price_rials` افزوده شد تا مودالِ «قطعی کردن نوبت» بتواند هزینهها را پیش از ساختهشدنِ مراجعه نشان دهد), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو — day-level, never occupies a slot).
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
`pending → confirmed|following_up|cancelled_*|expired` · `confirmed → completed|following_up|salon|cancelled_*|no_show` · `following_up → confirmed|salon|completed|cancelled_*|no_show` · `salon → completed|following_up|cancelled_*|no_show`
### PATCH `/api/v1/appointment/{uuid}`
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model) — the appointment's owning doctor, admin, the clinic owner / member doctor / assigned secretary of `appointment.clinic`. The patient is **not** allowed here (view + cancel only).
```json
{
"slot_start": 1731000000, "slot_end": 1731001800,
"is_reserve": false,
"service_section_uuid": "…", "service_item_uuid": "…", "staff_uuid": "…",
"deposit_required": true, "deposit_amount_rials": 5000000,
"note": "…", "patient_name": "…", "patient_mobile": "…",
"status": "confirmed", "version": 3
}
slot_start/slot_endmust be sent together; moving to an occupied slot →409.- Relation uuids: empty string clears; unknown uuid →
422. statusfollows the same transition rules asPATCH /appointment/{uuid}/status. A transition tocancelled_by_doctor/cancelled_by_userrecords a cancellation event (Timeline) +app_logwarning; an optionalcancel_reasonbody field is stored on the event. An inline cancellation is gated onappointments.cancelexactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.- Optimistic lock via
version→409on concurrent edit.
Response 200: { success, data: { data: <appointment.toArray()> } }
| HTTP | Description |
|---|---|
| 404 | نوبت یافت نشد |
| 403 | ERR_ACCESS_DENIED — no update_status on this appointment, or an inline cancellation without cancel |
| 422 | half slot pair, end < start, unknown relation uuid, invalid transition |
| 409 | slot taken or version conflict |
POST /api/v1/my/appointment (extended)
Extra optional body fields: service_section_uuid, service_item_uuid, staff_uuid, deposit_required, deposit_amount_rials, visit_price_rials, is_reserve, service_item_uuids[], duration_from_services.
deposit_amount_rials ریال است (مثل بقیه فیلدهای _rials)؛ UI ادمین تومان میگیرد و با tomanToRial تبدیل میکند. دادههای قدیمی که تومانِ خام ذخیره شده بودند با migration Version20260717093000 ×۱۰ اصلاح شدند.
is_reserve: true → day-level reserve entry: slot_end may equal slot_start, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response 201 now also returns is_reserve.
service_item_uuids[] (غیرِ رزرو): یک یا چند سرویس که به نوبت پیوست میشوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در service_items برمیگردند. UUID ناموجود ⇒ 422.
duration_from_services: true (حالت نوبتدهی سرویسی): مدت نوبت از مجموع duration_minutes سرویسها محاسبه و slot_end بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ 422. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ میشود.
service_durations (فقط با duration_from_services=true): override مدت هر سرویس { "<uuid>": <minutes> } برای همان نوبت (منشی)؛ در slot_end لحاظ میشود و مقدار پیشفرضِ سرویس تغییر نمیکند.
بخشِ سرویس در
appointment-booking-services: هر آیتمservices[]علاوه برuuid/name/duration_minutes/price_rials، فیلدservice_section: { uuid, name }هم دارد تا فرمِ نوبتدهیِ سرویسی سرویسها را «بخش → سرویس» گروهبندی کند. عقبرو-سازگار (افزودنِ فیلد).
GET /api/v1/my/appointments (extended)
New query param reserve=1 → returns only reserve-list entries; without it only regular slot bookings are returned. Each row now also includes: patient_uuid, is_reserve, deposit_required, deposit_amount_rials, note, service_section, service_item, staff (each {uuid, name|full_name} or null).
Booking context (2026-07)
A doctor may now hold several booking schedules — one for the personal practice and one per clinic.
Every public booking endpoint therefore accepts an optional clinic_uuid:
| Endpoint | Where |
|---|---|
GET /api/v1/appointment-slots |
query |
GET /api/v1/appointment-service-slots |
query |
GET /api/v1/appointment-booking-services/{doctorUuid} |
query |
GET /api/v1/appointment-settings/month-availability/{doctorUuid} |
query |
POST /api/v1/appointment |
body |
POST /api/v1/my/appointment |
body |
admin booking (src/Admin/) |
body |
Omitting it means the personal practice — it is never a wildcard. If the doctor is not a member
of the given clinic → 404 ERR_VALIDATION_002 («محل نوبتدهی یافت نشد»). All four GETs echo back
clinic_uuid so a client can tell which context answered.
On POST /api/v1/appointment, any service_item_uuids must belong to the same context, otherwise
422 ERR_VALIDATION_001 («سرویس انتخابشده به این محل نوبتدهی تعلق ندارد»). The appointment's
address_id is resolved from that context's schedule.
The context is stored on the row. All three booking paths persist it as
appointments.clinic_id (NULL = personal practice). Downstream consumers — above all the
automatic case-file creation documented in patient.md —
read that column instead of inferring the clinic from address_id. The old inference had a
fallback of "the doctor's only clinic", which silently filed appointments under the wrong practice
once per-context schedules existed.
وضعیت اولیهٔ نوبت
| مسیر | وضعیت هنگام ثبت |
|---|---|
POST /api/v1/appointment (سایت عمومی) |
pending با TTL پرداخت (Appointment::PAYMENT_TTL = ۱۵ دقیقه)؛ با پرداخت موفق confirmed میشود |
POST /api/v1/my/appointment (پنل) |
مستقیم confirmed |
| admin booking | مستقیم confirmed |
نوبتی که خودِ کلینیک/پزشک ثبت میکند پرداخت آنلاین ندارد و منتظر چیزی نیست؛ pending ماندنش
یعنی نه در تقویم درست شمرده میشود و نه پرونده میسازد.
Silent-failure warning: before this change the location was inferred from the doctor's single schedule. A client that does not send
clinic_uuidwill now book into the personal practice — which is correct, but is a behaviour change for any doctor who also works in a clinic. Update callers before relying on the default.
GET /api/v1/appointment-booking-locations/{doctorUuid}
Permission: public (anonymous via the PUBLIC_ACCESS access_control entry). This route runs on
the JWT firewall, so a valid bearer + management=1 enables management mode (next_available_at /
available_on_date ignore the online-booking toggle) — see the note under /appointment-slots.
Query: date (optional, Y-m-d), management (optional, 1).
Lists every place the doctor can be booked at. The site should show all of them, grouped by location — picking one and hiding the rest removes real capacity from the doctor.
{
"success": true,
"data": {
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"booking_locations": [
{
"location_uuid": "0f0b…",
"type": "personal",
"title": "مطب شخصی",
"address": "یزد، خیابان …",
"clinic_uuid": null,
"booking_mode": "slot",
"buffer_minutes": 0,
"opening_hours": [
{ "day": "Saturday", "opens": "09:00", "closes": "13:00" }
],
"services": [],
"next_available_at": 1755000000
},
{
"location_uuid": "7c21…",
"type": "clinic",
"title": "کلینیک علی بهروزی",
"address": "یزد، بلوار …",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"booking_mode": "service",
"buffer_minutes": 10,
"opening_hours": [
{ "day": "Saturday", "opens": "09:00", "closes": "13:00" },
{ "day": "Sunday", "opens": "16:00", "closes": "20:00" }
],
"services": [
{ "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000,
"service_section": { "uuid": "…", "name": "عمومی" } }
],
"next_available_at": 1754900000
}
]
}
}
| Field | Type | Notes |
|---|---|---|
location_uuid |
string|null |
the DoctorAddress uuid; null when the context has no address yet |
type |
"personal" | "clinic" |
|
booking_mode |
"slot" | "service" |
per-context — the same doctor can differ between locations |
opening_hours |
array |
active weekly shifts of that context, flattened; each entry is {day, day_index, location_id, opens, closes}. day is the English weekday name so it maps straight onto schema.org openingHoursSpecification; day_index is the schedule key (0=Saturday) |
available_on_date |
bool|null |
only when ?date= is supplied — whether that location has a free slot that day. null without date |
services |
array |
populated only in service mode, scoped to that context's owner |
next_available_at |
int|null |
Unix timestamp of the earliest free slot within 30 days, capped by the context's booking window |
next_available_at is resolved by SlotCalculatorService::findNextAvailableStart(), which fetches
the schedule, holidays, overrides and taken appointments once per location and walks the days in
memory. It counts a reserve appointment as blocking, matching isSlotTaken() — the looser
findBusyIntervals() used by service-mode slot generation would report such a slot as free.
Sorted by next_available_at ascending, so booking_locations[0] is the sensible default
selection; locations with no capacity sort last. Deep links should carry the chosen location
(/doctor/{uuid}?location={location_uuid}).
Status codes: 200, 404 ERR_VALIDATION_002 (doctor not found).
A location must be bookable to be listed
An entry is returned only when both hold:
- the context has at least one registered address, and
- at least one active shift points at one of those addresses.
A schedule whose shifts carry no location_id, or point at an address belonging to a different
context (a personal schedule referencing a clinic address, say), is not a place a patient can go —
it is omitted entirely, and its shifts never appear in opening_hours.
This filters out rows that predate the validateSessions rule, which now rejects such shifts at
write time. Use php bin/console app:schedule:audit-locations to list the offenders; --fix
deactivates them (it never deletes — the hours are user data). If the schedule was in truth a
clinic's, move it instead with app:schedule:assign-clinic.
A related legacy issue is the shape of the stored setting: very old rows are a bare JSON list
([{"sessions": …}]) covering only Saturday, so every other weekday reads as day-off and the
meta block is missing. php bin/console app:schedule:normalize-format reports such rows;
--fix rewrites them to the canonical {"0"…"6", "meta"} shape without touching the session
data (absent days become {"sessions": []}, absent meta becomes the defaults).
?date=YYYY-MM-DD
Adds available_on_date to every entry and echoes date in the response. Use it to grey out
locations that cannot be booked on the day the patient picked, rather than showing an empty slot
list. An impossible date (2026-13-99) → 422 ERR_VALIDATION_001 on field date; the check is a
real calendar check, not just a regex.
opening_hours lists one entry per active shift, so a day with a morning and an evening shift
appears twice. Days with no active shift are absent. Times are local HH:MM strings, and the
per-shift location_id is not repeated here — every shift in an entry already belongs to that
location's context.
Consumer: nobat724_front — the doctor page must render one booking block per entry, pass the
matching clinic_uuid into the slot and booking calls, and feed opening_hours into the
openingHoursSpecification of each MedicalClinic in the Physician JSON-LD.
Why a day has no slots — empty_reason (2026-07)
GET /api/v1/appointment-slots now returns empty_reason alongside sessions. It is null when
sessions exist, otherwise one of:
| Value | Meaning |
|---|---|
no_schedule |
no weekly schedule exists for that context — the commonest cause is a caller that forgot clinic_uuid and so asked about the personal practice |
holiday |
an active holiday covers the day (the doctor's own global holiday, or one the clinic set) |
day_off |
a schedule exists but that weekday has no active shift |
outside_window |
the date is past, beyond the booking window, or online booking is switched off |
Clients must not translate an empty sessions array into "closed". The admin panel used to do
exactly that and reported «این روز تعطیل است» for a doctor whose clinic schedule was perfectly
active — the request simply carried no clinic_uuid.