A non-unique index on (doctor_id, slot_start) plus a count-then-insert check left a TOCTOU race: two concurrent requests could both pass isSlotTaken and both insert. wrapInTransaction alone doesn't stop the phantom under InnoDB REPEATABLE-READ. Add a nullable, unique active_slot_key on Appointment = "doctorId:slotStart" while the booking occupies the slot (pending/confirmed — in lockstep with isSlotTaken); NULL once expired/completed/no_show/cancelled (NULLs don't collide in a MySQL unique index, so released slots rebook freely). bookAtomically now: catches the unique violation -> SlotTakenException, and expires lapsed pendings in-transaction so the ~1-min window before the expiry cron doesn't wrongly block rebooking. All three booking paths (online / my / admin) routed through it. Migration backfills one row per (doctor, slot) — the latest id — so the index builds even on dirty historical data without destructively cancelling bookings. (Backfill surfaced a real pre-existing double-booked slot in dev data.) Regression: tests/Appointment/SlotUniquenessTest. Adjusted the expiry-service test fixture to use distinct slots (one live booking per slot is now enforced). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
478 lines
16 KiB
Markdown
478 lines
16 KiB
Markdown
# 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`
|
||
```json
|
||
{
|
||
"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: false` means 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's `WeeklySchedule` or date override config.
|
||
>
|
||
> Returns an **empty** `sessions` array 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 (see `meta` in `appointment-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`
|
||
```json
|
||
{
|
||
"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`)
|
||
```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; the `patient_*` fields describe who the visit is for and are stored separately. **Temporary lock:** the slot is held by the new `pending` booking for **15 minutes** (`expires_at = created_at + 900`). If payment is not completed in time, the booking is moved to `expired` and the slot is freed (see `app:cancel-expired-appointments`). An expired pending booking no longer blocks the slot even before the cron runs.
|
||
|
||
### Response `201`
|
||
```json
|
||
{
|
||
"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`
|
||
```json
|
||
{
|
||
"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`
|
||
```json
|
||
{
|
||
"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`
|
||
```json
|
||
{
|
||
"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
|
||
```json
|
||
{
|
||
"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 the `appointments.create` permission; admin onto any. Otherwise `403 FORBIDDEN`.
|
||
|
||
### Request Body
|
||
```json
|
||
{
|
||
"doctor_uuid": "doctor-uuid",
|
||
"slot_start": 1718438400,
|
||
"slot_end": 1718439600,
|
||
"patient_mobile": "09123456789",
|
||
"patient_name": "علی محمدی",
|
||
"note": "optional note"
|
||
}
|
||
```
|
||
|
||
> اگر کاربری با این شماره موبایل وجود نداشته باشد، یک کاربر جدید با نقش `ROLE_USER` ساخته میشود.
|
||
|
||
### Response `201`
|
||
```json
|
||
{
|
||
"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`
|
||
```json
|
||
{
|
||
"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
|
||
}
|
||
}
|