Extend Appointment for the clinic-facing appointments area:
- New nullable relations service_section/service_item/staff (بخش/سرویس/پرسنل)
plus deposit_required/deposit_amount_rials (بیعانه) and is_reserve.
- New statuses following_up (در حال پیگیری) and salon (سالن) with day-of
transition rules; reserve entries never occupy a slot (several reserves may
share one day), enforced in refreshActiveSlotKey.
- rescheduleTo(slotStart, slotEnd, isReserve) keeps active_slot_key consistent
for جا به جایی and reserve transfers.
- New PATCH /api/v1/appointment/{uuid}: partial update covering edit, slot
move (409 on taken slot, race backstop on the unique key), reserve toggle,
patient swap (جایگزینی) and optional status transition; optimistic lock via
version like the status endpoint.
- POST /my/appointment now accepts the workflow fields and is_reserve
(day-level entry: no past-slot rule, no atomic slot booking); GET
/my/appointments gains reserve=1 and returns the new fields per row.
Migration Version20260713195434 (+ mirrored on db_test). Docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 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 already booked (active pending/confirmed appointment) or its start time has already passed (for today's date). 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-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) |
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).
Auto-add to clinic: هنگام تأیید نوبت، اگر آدرس نوبت متعلق به یک کلینیک باشد (
DoctorAddress.clinic_id)، بیمار علاوه بر پروندهی پزشک، به پروندههای آن کلینیک هم اضافه میشود. اگر آدرس کلینیک نداشت ولی دکتر فقط عضو یک کلینیک بود، به همان کلینیک اضافه میشود. هر شاخه مشروط به فعالبودنpatient_records. جزئیات درdocs/api/patient.md.
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",
"version": 3
}
| Field | Type | Required | Description |
|---|---|---|---|
status |
string | ✅ | New status value |
version |
integer | ❌ | Optimistic lock version (prevents double-submit) |
Allowed Transitions by Role:
| Actor | Allowed transitions |
|---|---|
| Patient | pending → cancelled |
| Doctor / Secretary | pending → confirmed, confirmed → completed, confirmed → no_show |
| Admin | Any transition |
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 |
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": "علی محمدی",
"note": "optional note"
}
اگر کاربری با این شماره موبایل وجود نداشته باشد، یک کاربر جدید با نقش
ROLE_USERساخته میشود.
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 |
DOCTOR_NOT_FOUND |
404 | Doctor UUID not found |
SLOT_TAKEN |
409 | Slot already booked |
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` (سرویس), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `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.- 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, is_reserve.
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.
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).