diff --git a/.claude/prompt/full-backend-audit.md b/.claude/prompt/full-backend-audit.md new file mode 100644 index 00000000..d3f5146c --- /dev/null +++ b/.claude/prompt/full-backend-audit.md @@ -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های متوالی) تا ادامه‌پذیر باشد. diff --git a/docs/api/appointment.md b/docs/api/appointment.md index d7e4bad5..1aaf5d8d 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -155,6 +155,8 @@ Book an appointment slot. > **آدرس نوبت:** آدرس (`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. diff --git a/docs/audit-backlog.md b/docs/audit-backlog.md index 22eae9b9..2b119782 100644 --- a/docs/audit-backlog.md +++ b/docs/audit-backlog.md @@ -38,7 +38,7 @@ _None outstanding._ | # | 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` | -| 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) | | 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 | @@ -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. | | 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 | +| 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) | --- diff --git a/migrations/Version20260628152738.php b/migrations/Version20260628152738.php new file mode 100644 index 00000000..3c71b822 --- /dev/null +++ b/migrations/Version20260628152738.php @@ -0,0 +1,48 @@ +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(<<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'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 3e22cd2e..7324afff 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -3,6 +3,7 @@ namespace App\Admin\Controller; use App\Appointment\Entity\Appointment; +use App\Appointment\Repository\SlotTakenException; use App\Auth\Entity\User; use App\Shared\Service\InputValidator; use App\Location\Entity\City; @@ -805,26 +806,16 @@ class AdminApiController extends BaseController $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); if (!empty($data['note'])) $appointment->setNote($data['note']); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); if ($locationId !== null) $appointment->setAddressId($locationId); - $this->em->persist($appointment); - $this->em->flush(); + try { + $this->em->getRepository(Appointment::class)->bookAtomically($appointment); + } catch (SlotTakenException) { + return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409); + } return $this->success([ 'uuid' => $appointment->getUuid(), diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index f5a928f9..958e6ee8 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -3,6 +3,8 @@ namespace App\Appointment\Controller; use App\Appointment\Entity\Appointment; +use App\Appointment\Repository\AppointmentRepository; +use App\Appointment\Repository\SlotTakenException; use App\Appointment\Service\SlotCalculatorService; use App\Auth\Entity\User; use App\Auth\Repository\UserActiveContextRepository; @@ -25,6 +27,7 @@ class MyAppointmentsController extends BaseController { public function __construct( private readonly EntityManagerInterface $em, + private readonly AppointmentRepository $appointmentRepo, private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, private readonly DoctorSecretaryRepository $secretaryRepo, @@ -72,24 +75,16 @@ class MyAppointmentsController extends BaseController $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); if (!empty($data['note'])) $appointment->setNote($data['note']); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); 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([ 'uuid' => $appointment->getUuid(), diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index cec04ef5..20bf91fa 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -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], ]; + /** + * 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 #[ORM\Version] #[ORM\Column(type: 'integer')] @@ -64,6 +77,9 @@ class Appointment #[ORM\Column(type: 'string', length: 30)] 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)] private ?string $note = null; @@ -106,6 +122,18 @@ class Appointment $this->slotEnd = $slotEnd; $this->createdAt = 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; } @@ -160,6 +188,7 @@ class Appointment if ($newStatus !== self::STATUS_PENDING) { $this->expiresAt = null; } + $this->refreshActiveSlotKey(); return $this; } diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index 90ba278b..204d8505 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -6,6 +6,7 @@ use App\Appointment\Entity\Appointment; use App\Auth\Entity\User; use App\Doctor\Entity\Doctor; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\OptimisticLockException; use Doctrine\Persistence\ManagerRegistry; @@ -30,13 +31,60 @@ class AppointmentRepository extends ServiceEntityRepository public function bookAtomically(Appointment $appointment): void { $em = $this->getEntityManager(); - $em->wrapInTransaction(function () use ($em, $appointment): void { - if ($this->isSlotTaken($appointment->getDoctor(), $appointment->getSlotStart(), $appointment->getSlotEnd())) { - throw new SlotTakenException(); - } - $em->persist($appointment); - $em->flush(); - }); + try { + $em->wrapInTransaction(function () use ($em, $appointment): void { + $doctor = $appointment->getDoctor(); + $start = $appointment->getSlotStart(); + $end = $appointment->getSlotEnd(); + + 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) */ diff --git a/tests/Appointment/AppointmentExpiryServiceTest.php b/tests/Appointment/AppointmentExpiryServiceTest.php index 060aaea5..3f912725 100644 --- a/tests/Appointment/AppointmentExpiryServiceTest.php +++ b/tests/Appointment/AppointmentExpiryServiceTest.php @@ -25,7 +25,9 @@ class AppointmentExpiryServiceTest extends ApiTestCase $appointments = []; for ($i = 0; $i < 5; $i++) { $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); $payment = new Payment($patient, 100_000, 'mellat', 'appointment'); diff --git a/tests/Appointment/SlotUniquenessTest.php b/tests/Appointment/SlotUniquenessTest.php new file mode 100644 index 00000000..40673a2d --- /dev/null +++ b/tests/Appointment/SlotUniquenessTest.php @@ -0,0 +1,78 @@ +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)); + } +}