fix(db): prevent double-booking a slot via unique active_slot_key (H2)

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>
This commit is contained in:
hamed
2026-06-28 19:04:54 +03:30
co-authored by Claude Opus 4.8
parent c084571bf0
commit aa87b4a9cb
10 changed files with 343 additions and 38 deletions
+111
View File
@@ -0,0 +1,111 @@
# بازبینی کامل بک‌اند ClinicPro (معماری/امنیت/Performance/کیفیت/DevOps) — تسک‌به‌تسک با تست
## پروژه
`clinicpro` (Symfony 7.4 / PHP ≥8.2 / Doctrine / MariaDB؛ React 19 admin)
## زمینه
پروژه بزرگ است: **۳۸ controller، ۵۵ entity، ~۳۰ دامنه** زیر `src/`. تست تقریباً وجود ندارد (فقط `tests/Billing`، `tests/bootstrap.php`)، CI نیست (`.github/workflows` خالی)، phpstan روی level 5. اخیراً چند باگ prod-only رخ داده (نمونه‌ها پایین) که نشان می‌دهد بازبینی سیستماتیک لازم است.
این یک ممیزی (audit) کامل است که **تسک‌به‌تسک، با تست، و با اجرای ایمن** انجام می‌شود. نه یک‌جا، نه سطحی.
## هدف
Backlog کامل از تسک‌های کوچک قابل‌تست بساز، اولویت‌بندی کن (Critical→High→Medium→Low)، سپس **یکی‌یکی** هر تسک را تحلیل/رفع/تست/گزارش کن تا هیچ Critical/High باقی نماند.
## قوانین اجرا (الزامی)
1. **اول کل پروژه را اسکن کن و Backlog بساز** (با `TodoWrite` — هر تسک یک آیتم). هیچ کدی قبل از Backlog عوض نشود.
2. **فقط یک تسک In Progress** در هر لحظه. ترتیب: Critical → High → Medium → Low.
3. هر تسک کامل می‌شود فقط وقتی: fix اعمال شد + تست نوشته شد + تست **PASS** شد + گزارش ثبت شد.
4. برای هر باگ، **تست regression** بنویس که بدون fix fail و با fix pass شود.
5. تغییرات روی **برنچ جدا** (مثلاً `backend-audit`) — prod فعلی (main) دست نخورد. هر چند تسک، commit جدا.
6. بعد از هر تغییر API → فایل مربوط در `docs/api/` آپدیت (قانون استاندارد پروژه).
7. بعد از تغییر Entity → migration بساز (`doctrine:migrations:diff`) و اجرا کن.
## محیط و دستورات تست (همه داخل ddev)
```bash
ddev exec php bin/phpunit # کل تست‌ها
ddev exec php bin/phpunit tests/Path/To/SomeTest.php # یک فایل
ddev exec php vendor/bin/phpstan analyse # level 5
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console lint:container # سلامت DI
ddev exec php bin/console debug:router | grep api
```
> زیرساخت تست الان ضعیف است. اگر `tests/` پایه‌ی کافی (WebTestCase، DB تستی، fixtures) ندارد، **اولین تسک‌های High** باید زیرساخت تست را بسازند (phpunit env، DB تست، factory/fixture سبک، یک `ApiTestCase` پایه که login/JWT را هندل کند).
## وضعیت فعلی (واقعی — بررسی‌شده)
- دامنه‌ها: `Admin, Appointment, Auth, Blog, Category, Clinic, ClinicInvitation, ClinicService, Config, Dashboard, Doctor, DoctorService, Insurance, Location, Patient, Payment, Rating, Representation, Secretary, Sms, Specialty, Staff, Subscription, Tag, UserProfile, Settlement, Billing`.
- همه controllerها از `BaseController` ارث می‌برند؛ پاسخ‌ها `success()/paginated()/error()/validationError()`.
- خطاها: `AppException(ErrorCodes::ERR_XXX,...)``ExceptionSubscriber` تبدیل به `error()`. کد عمومی prod: `ERR_INTERNAL_001`.
- Auth: JWT (lexik) + OAuth `oauth/token`؛ firewall `public_endpoints` (stateless) در `config/packages/security.yaml`؛ `access_control` روی `^/api` = `IS_AUTHENTICATED_FULLY`.
- Rate limiter: `config/packages/rate_limiter.yaml` + `enforceLimit()` در `AuthController`/`PasswordAuthenticator`.
- تاریخ‌ها: Unix timestamp (نه DateTime).
- لیست‌های admin: DQL `getArrayResult()` (۱۲ مورد).
- ۳۴ entity index صریح دارند، ۲۱ ندارند.
- Cache: redis (`cache.adapter.redis`). Messenger: `async` (redis) + `scheduler_default` + `failed` (doctrine).
## باگ‌کلاس‌های شناخته‌شده در همین کدبیس (به‌عنوان seed برای Backlog)
این‌ها واقعی‌اند و باید در Backlog به‌صورت تسک‌های با تست regression بیایند + مشابه‌یاب شوند:
1. **repositoryClass گمشده** — قبلاً ۲۵ entity `#[ORM\Entity]` بدون `repositoryClass` داشتند → روی prod (opcache.preload) `getRepository()` repo پیش‌فرض می‌داد → `BadMethodCallException` روی متد custom (مثل `findAllActiveBySecretary`) → ۵۰۰. رفع شد، ولی **تست regression ندارد**. تسک: تستی که هر entity دارای custom repo را تأیید کند `getRepository()` همان custom class را برمی‌گرداند (data provider روی همه‌ی entityها).
2. **double-nested response**`$this->success(['data' => ...])``{data:{data:...}}`؛ frontend باید `data?.data?.data` بخواند. تسک: یافتن همه‌ی موارد ناخواسته‌ی nest، یکدست‌سازی قرارداد، تست پاسخ.
3. **migration روی DB خالی** — قبلاً چند migration (drop قبل از create، FK تکراری) روی fresh DB می‌ترکید. تسک: تست/CI که `migrate` را روی DB کاملاً خالی اجرا کند و سبز بماند (smoke).
4. **`getArrayResult()` در لیست admin** — برای اجتناب از getter روی entity. تسک: تأیید همه‌ی لیست‌های admin این الگو را دارند و N+1 ندارند.
## نمونه ابعاد بررسی برای ساخت Backlog (هر کدام به تسک‌های کوچک بشکن)
- **امنیت:** IDOR روی endpointهای دارای `{uuid}`/`{id}` (آیا ownership/scope چک می‌شود؟ مخصوصاً doctor/clinic/secretary scope و representation)؛ mass-assignment در bodyها؛ نشت اطلاعات در پاسخ خطا؛ صحت `public_endpoints` (هیچ endpoint حساسی اشتباهی public نباشد)؛ rate limit روی login/OTP/reset؛ اعتبارسنجی ورودی (DTO + validator) به‌جای آرایه‌ی خام؛ JWT TTL/refresh؛ بررسی SSRF در `API_IR` و callbackهای پرداخت؛ امضا/verify callback درگاه (mellat/sep).
- **Performance / Doctrine:** N+1 (join/fetch)، نبود index روی ستون‌های فیلتر/FK پرتکرار (۲۱ entity بدون index صریح)، pagination درست (`paginated()` + count بهینه)، کوئری‌های سنگین dashboard/admin، استفاده‌ی درست از redis cache و invalidation.
- **API design:** status codeهای درست (۲۰۰/۲۰۱/۴۰۰/۴۰۱/۴۰۳/۴۲۲/۵۰۰)، یکدستی پاکت پاسخ، ولیدیشن ۴۲۲، نسخه‌بندی، هماهنگی با `docs/api/*` و مصرف‌کننده‌ها (`nobat724_front/services/*` و `clinic-pro-tauri/src/service/*`).
- **کیفیت/معماری:** controllerهای چاق (منطق به Service منتقل شود)، SOLID، تکرار، `ErrorCodes` کامل، exception handling در boundary نه پراکنده.
- **DevOps/Observability:** نبود CI → یک workflow `phpunit + phpstan + migrate-on-empty-db`؛ لاگ ساختاریافته (الان monolog نیست — تصمیم بگیر اضافه شود یا نه)؛ healthcheck (`/health` هست)؛ بدون secret در repo.
- **Database integrity:** FK/onDelete درست، unique constraintها، nullability، یکدستی timestamp.
## وظایف
### ۱. اسکن و ساخت Backlog (اولین و مهم‌ترین خروجی)
کل `src/` را دامنه‌به‌دامنه اسکن کن. برای هر یافته یک تسک کوچک بساز با: عنوان، فایل دقیق، دسته (security/perf/quality/api/db/devops/test)، **سطح ریسک**، و «چطور تست شود». خروجی = جدول Backlog اولویت‌بندی‌شده + `TodoWrite`.
> برای پوشش گسترده، می‌توانی اسکن دامنه‌ها را با subagentهای موازی انجام دهی (هر دامنه/بُعد یک agent، خروجی structured)، سپس نتایج را در یک Backlog واحد ادغام و dedup کن. هر یافته باید قبل از تبدیل‌شدن به fix، با خواندن کد واقعی تأیید (verify) شود تا false-positive نرود.
### ۲. زیرساخت تست (اگر ناکافی بود — قبل از تسک‌های دیگر)
`ApiTestCase` پایه (WebTestCase + DB تست + helper برای ساخت user/JWT و login)، fixture/factory سبک، تنظیم `phpunit.dist.xml` برای env تست و DB جدا. خروجی: یک تست سبز نمونه (مثلاً `/health` و یک endpoint عمومی).
### ۳..N — اجرای تسک‌به‌تسک
به ترتیب اولویت، برای هر تسک دقیقاً این چرخه:
```
① تحلیل کد همان تسک ② یافتن bug/smell/security/perf ③ تأیید (verify) یافته با کد واقعی
④ fix ⑤ تست (unit/functional/security + regression برای باگ) ⑥ اجرای تست تا PASS
⑦ phpstan + lint:container سبز ⑧ آپدیت docs/api اگر API عوض شد ⑨ گزارش تسک ⑩ تسک بعدی
```
## خروجی هر تسک
- عنوان · هدف · مشکلات پیداشده · سطح ریسک (Low/Medium/High/Critical) · اصلاحات · تست‌های نوشته‌شده · نتیجه‌ی تست (PASS/FAIL) · جمع‌بندی کوتاه.
## شرط پایان + گزارش نهایی
پروژه تمام است وقتی: همه‌ی تسک‌ها done، هیچ Critical/High باقی نمانده، همه تست‌ها PASS، هیچ fix بدون تست regression. گزارش نهایی:
- تعداد باگ‌ها · تعداد مشکلات امنیتی · تعداد مشکلات performance · میزان پوشش تست (واقعی، با `--coverage-text` اگر xdebug/pcov هست) · **Production readiness (%)** · لیست اولویت‌بندی‌شده‌ی باقی‌مانده.
## نکات مهم (الگوهای پروژه که باید رعایت شوند)
- همه controllerها `BaseController`؛ پاسخ‌ها فقط با `success/paginated/error/validationError`. خطاها با `AppException + ErrorCodes` (پیام فارسی).
- لیست admin: `getArrayResult()` — getter روی entity در این کوئری‌ها استفاده نشود.
- تاریخ‌ها Unix timestamp (نه DateTime).
- تغییر Entity → migration؛ تغییر API → `docs/api/*`.
- تست‌ها داخل ddev اجرا شوند؛ DB تست جدا از DB توسعه باشد (داده‌ی dev خراب نشود).
- روی **برنچ جدا** کار شود؛ هیچ‌چیز مستقیم روی main نرود تا review نشود.
- مصرف‌کننده‌های cross-repo (`nobat724_front`, `clinic-pro-tauri`) موقع تغییر قرارداد API لحاظ شوند — در build خطا نمی‌دهند.
- دامنه‌بودن کار: این یک تلاش بزرگ است؛ اگر یک‌جلسه تمام نشد، Backlog و وضعیت تسک‌ها باید پایدار بماند (TodoWrite + commitهای متوالی) تا ادامه‌پذیر باشد.
+2
View File
@@ -155,6 +155,8 @@ Book an appointment slot.
> **آدرس نوبت:** آدرس (`address_id`) ارسالی نیست؛ سرور آن را از روی `location_id` همان session در برنامه‌ی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره می‌کند. در پاسخ به‌صورت `address_id` برمی‌گردد. همه‌ی مسیرهای رزرو (آنلاین `POST /api/v1/appointment`، منشی `POST /api/v1/my/appointment`، ادمین) آدرس را به همین شکل ست می‌کنند. > **آدرس نوبت:** آدرس (`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`. > **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. > **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.
+2 -1
View File
@@ -38,7 +38,7 @@ _None outstanding._
| # | Task | File:line | Cat | How to test | | # | Task | File:line | Cat | How to test |
|---|------|-----------|-----|-------------| |---|------|-----------|-----|-------------|
| ✅H1 | IDOR write: `createAppointment` trusts request `doctor_uuid`, no scope check — any staff books onto any doctor's calendar | src/Appointment/Controller/MyAppointmentsController.php:59 | security-idor | **DONE**`canBookForDoctor()` scope gate + `tests/Appointment/BookingScopeTest` | | ✅H1 | IDOR write: `createAppointment` trusts request `doctor_uuid`, no scope check — any staff books onto any doctor's calendar | src/Appointment/Controller/MyAppointmentsController.php:59 | security-idor | **DONE**`canBookForDoctor()` scope gate + `tests/Appointment/BookingScopeTest` |
| H2 | No UNIQUE `(doctor_id, slot_start)` on Appointment → double-booking race (index is non-unique) | src/Appointment/Entity/Appointment.php:13 | db-unique | Concurrent POST same doctor+slot → only one persists. ⚠️ check existing dup data before adding constraint | | H2 | No UNIQUE `(doctor_id, slot_start)` on Appointment → double-booking race (index is non-unique) | src/Appointment/Entity/Appointment.php | db-unique | **DONE** — nullable unique `active_slot_key` (occupying = pending/confirmed, mirrors `isSlotTaken`); `bookAtomically` catches the unique violation + expires lapsed pendings in-txn; all 3 booking paths (online/my/admin) routed through it. Migration backfills one row per slot (non-destructive). `tests/Appointment/SlotUniquenessTest`. **NB:** backfill surfaced a real pre-existing double-booked slot in dev data (doctor 1764, two `expired` rows) — harmless (both expired = key NULL). |
| H3 | `Payment.referenceId` not unique → same gateway callback credited twice | src/Payment/Entity/Payment.php:61-62 | db-unique | Persist two Payments same reference_id → 2nd rejected. (pairs w/ C1) | | H3 | `Payment.referenceId` not unique → same gateway callback credited twice | src/Payment/Entity/Payment.php:61-62 | db-unique | Persist two Payments same reference_id → 2nd rejected. (pairs w/ C1) |
| H4 | `FinancialBreakdown.payment` onDelete CASCADE on non-nullable FK → deleting a Payment destroys ledger rows; should be RESTRICT | src/Settlement/Entity/FinancialBreakdown.php:28-30 | db-ondelete | Delete a Payment w/ breakdown → expect FK restrict error, ledger preserved | | H4 | `FinancialBreakdown.payment` onDelete CASCADE on non-nullable FK → deleting a Payment destroys ledger rows; should be RESTRICT | src/Settlement/Entity/FinancialBreakdown.php:28-30 | db-ondelete | Delete a Payment w/ breakdown → expect FK restrict error, ledger preserved |
| H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete: `EntityInsurancePricing.entity_id/insurance_id`, `TenantInsurance.entity_id/insurance_id`, `TenantServiceCoverage.tenant_insurance_id` | src/Insurance/Entity/EntityInsurancePricing.php:25-29 · TenantInsurance.php:29,32 · TenantServiceCoverage.php:23-24 | db-ondelete | Delete insurance/tenant → children removed or restricted, no dangling rows | | H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete: `EntityInsurancePricing.entity_id/insurance_id`, `TenantInsurance.entity_id/insurance_id`, `TenantServiceCoverage.tenant_insurance_id` | src/Insurance/Entity/EntityInsurancePricing.php:25-29 · TenantInsurance.php:29,32 · TenantServiceCoverage.php:23-24 | db-ondelete | Delete insurance/tenant → children removed or restricted, no dangling rows |
@@ -105,6 +105,7 @@ _None outstanding._
| E2 | **No DTO/validator on sensitive writes** — admin create, auth flows, payment verify, booking read raw `json_decode` arrays | Admin, Auth, Payment, Appointment, Settlement controllers | Introduce request DTOs + validator incrementally. Large. | | E2 | **No DTO/validator on sensitive writes** — admin create, auth flows, payment verify, booking read raw `json_decode` arrays | Admin, Auth, Payment, Appointment, Settlement controllers | Introduce request DTOs + validator incrementally. Large. |
| E3 | **Fat controllers** — AdminApiController (1938 LOC), MyAppointmentsController booking, RepresentationActionController (835), DoctorController/ClinicController | extract per-domain Services | SOLID refactor; lower urgency than security/db. | | E3 | **Fat controllers** — AdminApiController (1938 LOC), MyAppointmentsController booking, RepresentationActionController (835), DoctorController/ClinicController | extract per-domain Services | SOLID refactor; lower urgency than security/db. |
| E4 | **CI** — no `.github/workflows`; add phpunit + phpstan + migrate-on-empty-db | devops | prompt var §DevOps | | E4 | **CI** — no `.github/workflows`; add phpunit + phpstan + migrate-on-empty-db | devops | prompt var §DevOps |
| E5 | **phpstan baseline dirty** — 41 pre-existing errors across the codebase (D9 only repaired the config so it *runs*). Audit fixes must not add new ones; cleaning the 41 is its own task. | devops | `ddev exec php vendor/bin/phpstan analyse` → 41 errors (e.g. SlotCalculatorService.php:233, SubscriptionController.php:33) |
--- ---
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260628152738 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add unique active_slot_key to appointments to prevent double-booking a slot';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE appointments ADD active_slot_key VARCHAR(64) DEFAULT NULL');
// Backfill slot-occupying appointments (pending/confirmed only — matches
// isSlotTaken) so the unique index protects existing rows too. NULLs do
// not collide in a MySQL/MariaDB unique index, so released slots are fine.
// Assign the key to at most one row per (doctor, slot) — the latest id —
// so the index builds even where dirty historical data already
// double-booked a slot, without destructively cancelling any booking.
$this->addSql(<<<SQL
UPDATE appointments a
JOIN (
SELECT MAX(id) AS keep_id
FROM appointments
WHERE status IN ('pending','confirmed')
GROUP BY doctor_id, slot_start
) k ON a.id = k.keep_id
SET a.active_slot_key = CONCAT(a.doctor_id, ':', a.slot_start)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_6A41727AE228889B ON appointments (active_slot_key)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX UNIQ_6A41727AE228889B ON appointments');
$this->addSql('ALTER TABLE appointments DROP active_slot_key');
}
}
+6 -15
View File
@@ -3,6 +3,7 @@
namespace App\Admin\Controller; namespace App\Admin\Controller;
use App\Appointment\Entity\Appointment; use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\SlotTakenException;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Shared\Service\InputValidator; use App\Shared\Service\InputValidator;
use App\Location\Entity\City; use App\Location\Entity\City;
@@ -805,26 +806,16 @@ class AdminApiController extends BaseController
$this->em->persist($patient); $this->em->persist($patient);
} }
$conflict = $this->em->createQueryBuilder()
->select('COUNT(a.id)')
->from(Appointment::class, 'a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
->setParameter('doctor', $doctor)
->setParameter('start', $slotStart)
->setParameter('end', $slotEnd)
->getQuery()->getSingleScalarResult();
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
if (!empty($data['note'])) $appointment->setNote($data['note']); if (!empty($data['note'])) $appointment->setNote($data['note']);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
if ($locationId !== null) $appointment->setAddressId($locationId); if ($locationId !== null) $appointment->setAddressId($locationId);
$this->em->persist($appointment); try {
$this->em->flush(); $this->em->getRepository(Appointment::class)->bookAtomically($appointment);
} catch (SlotTakenException) {
return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
}
return $this->success([ return $this->success([
'uuid' => $appointment->getUuid(), 'uuid' => $appointment->getUuid(),
@@ -3,6 +3,8 @@
namespace App\Appointment\Controller; namespace App\Appointment\Controller;
use App\Appointment\Entity\Appointment; use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Service\SlotCalculatorService; use App\Appointment\Service\SlotCalculatorService;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository; use App\Auth\Repository\UserActiveContextRepository;
@@ -25,6 +27,7 @@ class MyAppointmentsController extends BaseController
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly AppointmentRepository $appointmentRepo,
private readonly DoctorRepository $doctorRepo, private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo, private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo, private readonly DoctorSecretaryRepository $secretaryRepo,
@@ -72,24 +75,16 @@ class MyAppointmentsController extends BaseController
$this->em->persist($patient); $this->em->persist($patient);
} }
$conflict = $this->em->createQueryBuilder()
->select('COUNT(a.id)')->from(Appointment::class, 'a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
->setParameter('doctor', $doctor)
->setParameter('start', $slotStart)
->setParameter('end', $slotEnd)
->getQuery()->getSingleScalarResult();
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
if (!empty($data['note'])) $appointment->setNote($data['note']); if (!empty($data['note'])) $appointment->setNote($data['note']);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
if ($locationId !== null) $appointment->setAddressId($locationId); if ($locationId !== null) $appointment->setAddressId($locationId);
$this->em->persist($appointment);
$this->em->flush(); try {
$this->appointmentRepo->bookAtomically($appointment);
} catch (SlotTakenException) {
return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
}
return $this->success([ return $this->success([
'uuid' => $appointment->getUuid(), 'uuid' => $appointment->getUuid(),
+29
View File
@@ -34,6 +34,19 @@ class Appointment
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW], self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
]; ];
/**
* Statuses in which an appointment occupies its (doctor, slot_start) — kept
* in lockstep with AppointmentRepository::isSlotTaken (a slot is taken only
* by a confirmed booking or a still-live pending one). While occupying, the
* row carries a non-null, unique active_slot_key so two live bookings on the
* same slot cannot coexist even under a race. Every other status (expired,
* completed, no_show, cancelled_*) releases the slot → key NULL.
*/
private const SLOT_OCCUPYING_STATUSES = [
self::STATUS_PENDING,
self::STATUS_CONFIRMED,
];
// Optimistic locking // Optimistic locking
#[ORM\Version] #[ORM\Version]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
@@ -64,6 +77,9 @@ class Appointment
#[ORM\Column(type: 'string', length: 30)] #[ORM\Column(type: 'string', length: 30)]
private string $status = self::STATUS_PENDING; private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'active_slot_key', type: 'string', length: 64, nullable: true, unique: true)]
private ?string $activeSlotKey = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)] #[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $note = null; private ?string $note = null;
@@ -106,6 +122,18 @@ class Appointment
$this->slotEnd = $slotEnd; $this->slotEnd = $slotEnd;
$this->createdAt = time(); $this->createdAt = time();
$this->updatedAt = time(); $this->updatedAt = time();
$this->refreshActiveSlotKey();
}
/**
* Recompute the unique active-slot key from the current status. Non-null
* while the appointment occupies the slot; null once it is cancelled.
*/
private function refreshActiveSlotKey(): void
{
$this->activeSlotKey = in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
} }
public function getId(): ?int { return $this->id; } public function getId(): ?int { return $this->id; }
@@ -160,6 +188,7 @@ class Appointment
if ($newStatus !== self::STATUS_PENDING) { if ($newStatus !== self::STATUS_PENDING) {
$this->expiresAt = null; $this->expiresAt = null;
} }
$this->refreshActiveSlotKey();
return $this; return $this;
} }
@@ -6,6 +6,7 @@ use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\OptimisticLockException;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -30,13 +31,60 @@ class AppointmentRepository extends ServiceEntityRepository
public function bookAtomically(Appointment $appointment): void public function bookAtomically(Appointment $appointment): void
{ {
$em = $this->getEntityManager(); $em = $this->getEntityManager();
$em->wrapInTransaction(function () use ($em, $appointment): void { try {
if ($this->isSlotTaken($appointment->getDoctor(), $appointment->getSlotStart(), $appointment->getSlotEnd())) { $em->wrapInTransaction(function () use ($em, $appointment): void {
throw new SlotTakenException(); $doctor = $appointment->getDoctor();
} $start = $appointment->getSlotStart();
$em->persist($appointment); $end = $appointment->getSlotEnd();
$em->flush();
}); if ($this->isSlotTaken($doctor, $start, $end)) {
throw new SlotTakenException();
}
// Free the unique slot key of any pending booking whose payment
// window has lapsed but the expiry cron hasn't run yet, so the
// new booking can take the slot.
$this->expireLapsedPending($doctor, $start, $end);
$em->persist($appointment);
$em->flush();
});
} catch (UniqueConstraintViolationException) {
// Lost the race on the (doctor, slot_start) unique key — the
// in-transaction re-check passed for two concurrent requests but
// only one INSERT can win.
throw new SlotTakenException();
}
}
/**
* Expire pending bookings overlapping this slot whose payment window has
* lapsed, releasing their active_slot_key so the slot can be rebooked.
*/
private function expireLapsedPending(Doctor $doctor, int $slotStart, int $slotEnd): void
{
$lapsed = $this->createQueryBuilder('a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart < :slotEnd')
->andWhere('a.slotEnd > :slotStart')
->andWhere('a.status = :pending')
->andWhere('a.expiresAt IS NOT NULL AND a.expiresAt <= :now')
->setParameter('doctor', $doctor)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('slotStart', $slotStart)
->setParameter('slotEnd', $slotEnd)
->setParameter('now', time())
->getQuery()
->getResult();
if ($lapsed === []) {
return;
}
foreach ($lapsed as $appt) {
$appt->transitionTo(Appointment::STATUS_EXPIRED);
}
// Flush the key-releasing UPDATEs before the new INSERT so they don't
// collide on the unique key within the same flush.
$this->getEntityManager()->flush();
} }
/** Check if a slot is already taken (confirmed or pending) */ /** Check if a slot is already taken (confirmed or pending) */
@@ -25,7 +25,9 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$appointments = []; $appointments = [];
for ($i = 0; $i < 5; $i++) { for ($i = 0; $i < 5; $i++) {
$patient = $this->createUser(['ROLE_USER']); $patient = $this->createUser(['ROLE_USER']);
$appt = new Appointment($doctor, $patient, $past, $past + 900); // distinct past slots — one live booking per (doctor, slot)
$slotStart = $past - $i * 1000;
$appt = new Appointment($doctor, $patient, $slotStart, $slotStart + 900);
$this->em->persist($appt); $this->em->persist($appt);
$payment = new Payment($patient, 100_000, 'mellat', 'appointment'); $payment = new Payment($patient, 100_000, 'mellat', 'appointment');
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\SlotTakenException;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* A doctor's (doctor_id, slot_start) can be held by at most one live booking.
* The unique active_slot_key is the race backstop behind bookAtomically:
* even if two concurrent transactions both pass the isSlotTaken re-check, only
* one INSERT can win. Cancelling/expiring a booking releases the slot.
*/
class SlotUniquenessTest extends ApiTestCase
{
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function newBooking(Doctor $doctor, int $start): Appointment
{
return new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
}
public function testTwoLiveBookingsOnSameSlotViolateUniqueKey(): void
{
$doctor = $this->makeDoctor();
$start = time() + 86_400;
$this->em->persist($this->newBooking($doctor, $start));
$this->em->flush();
$this->em->persist($this->newBooking($doctor, $start));
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
public function testCancelledBookingFreesTheSlot(): void
{
$doctor = $this->makeDoctor();
$start = time() + 86_400;
$first = $this->newBooking($doctor, $start);
$this->em->persist($first);
$this->em->flush();
$first->transitionTo(Appointment::STATUS_CANCELLED_BY_USER);
$this->em->flush();
$second = $this->newBooking($doctor, $start);
$this->em->persist($second);
$this->em->flush();
$this->assertNotNull($second->getId());
}
public function testBookAtomicallyRejectsAlreadyTakenSlot(): void
{
$doctor = $this->makeDoctor();
$start = time() + 86_400;
$repo = $this->em->getRepository(Appointment::class);
$first = $this->newBooking($doctor, $start);
$first->markPendingWithTtl(Appointment::PAYMENT_TTL);
$repo->bookAtomically($first);
$this->expectException(SlotTakenException::class);
$repo->bookAtomically($this->newBooking($doctor, $start));
}
}