- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules. - Updated unique constraints and indexes to accommodate the new clinic context. feat(command): create AssignScheduleClinicCommand to move schedules - Added a command to move a doctor's personal weekly schedule into a clinic context. - Implemented checks to ensure sessions align with the target clinic. feat(context): implement EntityContext and EntityContextResolver - Created EntityContext to represent the effective working environment of a request (doctor or clinic). - Developed EntityContextResolver to determine the execution context based on user roles and active contexts. test: add ServiceModeContextTest for appointment scheduling - Implemented tests to ensure service booking respects clinic and personal contexts. - Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
37 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
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) |
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 یا غایب ⇒ مدت پیشفرض سرویس |
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 |
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 |
GET /api/v1/appointment/{uuid}
Get appointment detail.
Permission: AUTH — must be the patient, the doctor, or ROLE_ADMIN
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_FORBIDDEN_001 |
403 | Not the patient/doctor/admin |
ERR_NOT_FOUND_001 |
404 | Appointment not found |
GET /api/v1/appointments/doctor/{doctorUuid}
Get all appointments for a specific doctor.
Permission: AUTH — must be the doctor, their secretary, or ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
doctorUuid |
string (UUID) | Doctor UUID |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
status |
string | ❌ | Filter: pending, confirmed, cancelled, completed, no_show |
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_FORBIDDEN_001 |
403 | Not authorized to view this doctor's appointments |
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 — patient can cancel; doctor/secretary can confirm/complete/no_show; admin can do all
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 Role:
| Actor | Allowed transitions |
|---|---|
| Patient | pending → cancelled |
| Doctor / Secretary | pending → confirmed, confirmed → completed, confirmed → no_show |
| Admin | Any transition |
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_FORBIDDEN_001 |
403 | Not authorized for this transition |
ERR_NOT_FOUND_001 |
404 | Appointment not found |
ERR_CONFLICT_001 |
409 | Version mismatch (optimistic lock) |
ERR_VALIDATION_001 |
422 | Invalid status value |
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 — caller must be able to manage the appointment (canManage).
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_AUTH_006 |
403 | Not allowed to manage 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.
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 for the linked doctor (empty if appointments.view permission is false) |
(plain patient ROLE_USER) |
The patient's own appointments (a.user = current user) |
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}`), `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:** appointment's patient, owning doctor, or admin.
```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.- Optimistic lock via
version→409on concurrent edit.
Response 200: { success, data: { data: <appointment.toArray()> } }
| HTTP | Description |
|---|---|
| 404 | نوبت یافت نشد |
| 403 | not patient/doctor/admin |
| 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 |
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.
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.
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,
"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,
"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 |
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 |
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).
Consumer: nobat724_front — the doctor page must render one booking block per entry and pass the
matching clinic_uuid into the slot and booking calls.