From 0333b240711632532891907c6d590e647e18916d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 13:39:37 +0330 Subject: [PATCH] feat: enhance DoctorAddress entity to support clinic addresses and types - Added `clinic_id` and `type` fields to `DoctorAddress` entity to differentiate between personal and clinic addresses. - Updated constructor to support creation of addresses for both doctors and clinics. - Modified repository methods to handle new address types and added methods for counting and finding addresses by clinic. - Implemented migration to update the database schema accordingly. - Removed deprecated endpoint for creating addresses from clinics and updated related controller methods. - Added new endpoints for managing clinic addresses, including CRUD operations. - Updated frontend components to handle new address types and display accordingly. --- .claude/prompt/clinic-doctor-deactivate.md | 355 +++++++++++++++ .claude/prompt/location-clinic-address.md | 418 ++++++++++++++++++ assets/admin/pages/ClinicDetailPage.tsx | 178 ++++++++ assets/admin/pages/DoctorDetailPage.tsx | 42 +- config/packages/security.yaml | 4 +- docs/api/appointment-settings.md | 58 +++ docs/api/clinic.md | 108 +++++ migrations/Version20260612132848.php | 36 ++ .../AppointmentSettingsController.php | 34 ++ src/Clinic/Controller/ClinicController.php | 126 +++++- src/Doctor/Controller/DoctorController.php | 62 +-- src/Doctor/Entity/DoctorAddress.php | 69 ++- .../Repository/DoctorAddressRepository.php | 45 ++ 13 files changed, 1446 insertions(+), 89 deletions(-) create mode 100644 .claude/prompt/clinic-doctor-deactivate.md create mode 100644 .claude/prompt/location-clinic-address.md create mode 100644 migrations/Version20260612132848.php diff --git a/.claude/prompt/clinic-doctor-deactivate.md b/.claude/prompt/clinic-doctor-deactivate.md new file mode 100644 index 00000000..63f9225f --- /dev/null +++ b/.claude/prompt/clinic-doctor-deactivate.md @@ -0,0 +1,355 @@ +# پرامپت: غیرفعال کردن دکتر توسط صاحب کلینیک + +## هدف + +صاحب کلینیک بتواند یک دکتر را از کلینیک خودش **غیرفعال** کند (نه حذف — تا سوابق حفظ شوند). +از لحظه غیرفعال شدن، تمام نوبت‌های آینده (pending/confirmed) آن دکتر **لغو** می‌شوند. +صاحب کلینیک می‌تواند در آینده دکتر را مجدداً فعال کند. + +--- + +## زمینه فنی موجود + +### جدول `clinic_doctors` (join table فعلی): +```sql +clinic_id INT (PK) +doctor_id INT (PK) +-- فاقد is_active یا deactivated_at +``` + +### API موجود: +- `GET /api/v1/clinic/doctor-list/{clinicUuid}` → لیست دکترهای کلینیک + پیاده‌سازی: `$clinic->getDoctors()->toArray()` → `$d->toListArray()` + فیلد `active` در response = `doctor.active_doctor_appointment` (**global** flag — نه clinic-specific) + +### Frontend موجود: +- **`ClinicDetailPage.tsx`**: نمایش لیست دکترها با badge active/inactive + Interface: `{ id, uuid, name, gender, degree, img, specialties, active: boolean }` + Route: `/admin/clinics/:uuid` + دسترسی: admin + clinic owner (هر دو می‌توانند ببینند) + +### احراز هویت clinic owner: +```php +// در ClinicController.update: +if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(..., 403); +} +``` +همین الگو را در endpoint های جدید استفاده کن. + +### نوبت‌ها: +- جدول `appointments` فقط `doctor_id` دارد — **clinic_id و address_id ندارد** +- جدول `doctor_addresses` هم **clinic_id ندارد** — فقط `doctor_id`, `name`, `address`, `telephone`, `city_id`, `province_id` +- `weekly_schedules.setting` (JSON) هر session دارای `location_id` است که به `doctor_addresses.id` اشاره دارد — اما این مقدار در appointment ذخیره نمی‌شود +- **بنابراین در حال حاضر از طریق دیتابیس نمی‌توان فهمید هر نوبت در کدام کلینیک بوده** +- استاتوس‌ها: `pending`, `confirmed`, `completed`, `cancelled_by_doctor`, `cancelled_by_user`, `expired`, `no_show` +- کنسل کردن = تغییر status به `cancelled_by_doctor` + +--- + +## قابلیت‌ها + +--- + +### قابلیت ۱ — Migration: سه تغییر schema + +برای پیاده‌سازی لغو نوبت‌های مختص کلینیک، سه ستون در سه جدول مختلف لازم است: + +#### ۱-الف: جدول `clinic_doctors` +```sql +is_active TINYINT(1) NOT NULL DEFAULT 1 +deactivated_at INT(11) NULL +``` + +#### ۱-ب: جدول `doctor_addresses` +```sql +clinic_id INT(11) NULL -- FK به clinics.id (اگر این آدرس متعلق به کلینیکی باشد) +``` + +#### ۱-ج: جدول `appointments` +```sql +address_id INT(11) NULL -- FK به doctor_addresses.id (آدرس محل نوبت) +``` + +**چرا این سه تغییر؟** +- `clinic_doctors.is_active` + `deactivated_at` → برای مدیریت وضعیت دکتر در کلینیک +- `doctor_addresses.clinic_id` → برای دانستن کدام آدرس‌ها به این کلینیک تعلق دارند +- `appointments.address_id` → برای دانستن نوبت در کدام آدرس (و در نتیجه کدام کلینیک) ثبت شده + +> ⚠️ هر سه migration باید **دستی** نوشته شوند (join table و فیلدهای extra با Doctrine Entity آپدیت نمی‌شوند به صورت خودکار). + +**Migration file (یک فایل، سه addSql):** +```php +$this->addSql("ALTER TABLE clinic_doctors ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1"); +$this->addSql("ALTER TABLE clinic_doctors ADD COLUMN deactivated_at INT(11) NULL"); +$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT(11) NULL"); +$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE SET NULL"); +$this->addSql("ALTER TABLE appointments ADD COLUMN address_id INT(11) NULL"); +$this->addSql("ALTER TABLE appointments ADD CONSTRAINT FK_appt_address FOREIGN KEY (address_id) REFERENCES doctor_addresses(id) ON DELETE SET NULL"); +``` + +**آپدیت Entity:** +- در `DoctorAddress.php`: فیلد `private ?int $clinicId = null;` اضافه کن (با getter/setter) +- در `Appointment.php`: فیلد `private ?int $addressId = null;` اضافه کن (با getter/setter) + +**اجرا:** +```bash +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +--- + +### قابلیت ۲ — Backend: endpoint غیرفعال کردن دکتر + +**فایل:** `src/Clinic/Controller/ClinicController.php` + +**Route:** `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/deactivate` + +**Auth:** `IS_AUTHENTICATED_FULLY` + باید owner کلینیک باشد + +**منطق:** +1. کلینیک را با `clinicUuid` پیدا کن — اگر نبود: 404 +2. بررسی owner: `$clinic->getUser()->getId() !== $user->getId()` → 403 +3. دکتر را با `doctorUuid` پیدا کن — اگر نبود: 404 +4. بررسی عضویت: دکتر باید در `clinic_doctors` این کلینیک باشد → اگر نبود: 404 +5. بررسی: اگر قبلاً غیرفعال بود (`is_active = 0`) → `ERR_CONFLICT_001` / 409 +6. با native SQL جدول join را آپدیت کن: + ```sql + UPDATE clinic_doctors SET is_active = 0, deactivated_at = :now + WHERE clinic_id = :clinicId AND doctor_id = :doctorId + ``` +7. **کنسل کردن نوبت‌های آینده مختص این کلینیک:** + ```php + // فقط نوبت‌هایی که address_id آن‌ها به آدرس‌های این کلینیک اشاره می‌کند + $future = $appointmentRepo->findFutureActiveByDoctorAndClinic($doctor, $clinic, time()); + foreach ($future as $appt) { + $appt->transitionTo(Appointment::STATUS_CANCELLED_BY_DOCTOR); + } + $this->em->flush(); + ``` +8. Response: `{ message: 'دکتر غیرفعال شد', cancelled_appointments: count }` + +**Repository method لازم (`AppointmentRepository`):** +```php +public function findFutureActiveByDoctorAndClinic(Doctor $doctor, Clinic $clinic, int $now): array +{ + // نوبت‌های آینده این دکتر که در آدرس‌های متعلق به این کلینیک رزرو شده‌اند + return $this->createQueryBuilder('a') + ->join(DoctorAddress::class, 'addr', 'WITH', 'a.addressId = addr.id') + ->where('a.doctor = :doctor') + ->andWhere('addr.clinicId = :clinicId') + ->andWhere('a.slotStart > :now') + ->andWhere('a.status IN (:statuses)') + ->setParameter('doctor', $doctor) + ->setParameter('clinicId', $clinic->getId()) + ->setParameter('now', $now) + ->setParameter('statuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED]) + ->getQuery() + ->getResult(); +} +``` + +> ⚠️ چون `address_id` یک integer column است (نه ORM relation)، از native query استفاده کن اگر DQL مشکل داشت: +> ```php +> return $this->getEntityManager()->createNativeQuery( +> "SELECT a.* FROM appointments a +> JOIN doctor_addresses da ON da.id = a.address_id +> WHERE a.doctor_id = :doctorId +> AND da.clinic_id = :clinicId +> AND a.slot_start > :now +> AND a.status IN ('pending', 'confirmed')", +> (new ResultSetMappingBuilder($this->getEntityManager()))->addRootEntityFromClassMetadata(Appointment::class, 'a') +> )->setParameters(['doctorId' => $doctor->getId(), 'clinicId' => $clinic->getId(), 'now' => $now]) +> ->getResult(); +> ``` + +--- + +### قابلیت ۲-ب — Backend: ذخیره `address_id` هنگام ثبت نوبت + +**فایل:** `src/Appointment/Controller/AppointmentController.php` + +برای اینکه بعداً بتوان نوبت‌ها را بر اساس کلینیک فیلتر کرد، باید `address_id` در هنگام booking ذخیره شود. + +**تغییر لازم در booking endpoint:** +- Request body باید `address_id` (nullable integer) را بپذیرد +- این مقدار از `location_id` در slot response می‌آید (فرانت‌اند آن را دارد) +- هنگام ایجاد `Appointment`: `$appointment->setAddressId($request->get('address_id'))` + +> ⚠️ این تغییر باید با frontend booking flow هماهنگ باشد — `location_id` از هر slot به عنوان `address_id` ارسال شود. + +--- + +### قابلیت ۳ — Backend: endpoint فعال کردن مجدد دکتر + +**Route:** `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/reactivate` + +**Auth:** `IS_AUTHENTICATED_FULLY` + owner + +**منطق:** +1. کلینیک + owner check + دکتر + عضویت (همان قابلیت ۲) +2. بررسی: اگر قبلاً فعال بود (`is_active = 1`) → `ERR_CONFLICT_001` / 409 +3. آپدیت: + ```sql + UPDATE clinic_doctors SET is_active = 1, deactivated_at = NULL + WHERE clinic_id = :clinicId AND doctor_id = :doctorId + ``` +4. Response: `{ message: 'دکتر مجدداً فعال شد' }` + +--- + +### قابلیت ۴ — Backend: آپدیت endpoint `doctor-list` + +**فایل:** `src/Clinic/Controller/ClinicController.php` — متد `doctorList` + +**مشکل فعلی:** `active` field از `doctor.active_doctor_appointment` می‌آید (global) — باید از `clinic_doctors.is_active` بیاید (clinic-specific). + +**راه‌حل:** Query را به native SQL تغییر بده: + +```php +$rows = $conn->fetchAllAssociative( + "SELECT d.uuid, d.name, d.gender, d.degree, d.images, + cd.is_active, cd.deactivated_at, + GROUP_CONCAT(DISTINCT cs.name SEPARATOR '||') as specialty_names, + GROUP_CONCAT(DISTINCT cs.id SEPARATOR '||') as specialty_ids + FROM clinic_doctors cd + JOIN doctors d ON d.id = cd.doctor_id + JOIN clinics c ON c.id = cd.clinic_id + LEFT JOIN doctor_specialties ds ON ds.doctor_id = d.id + LEFT JOIN categories cs ON cs.id = ds.category_id + WHERE c.uuid = :uuid + GROUP BY d.id, cd.is_active, cd.deactivated_at", + ['uuid' => $clinicUuid] +); +``` + +Response format (همان ساختار قبلی + فیلدهای جدید): +```json +{ + "uuid": "...", + "name": "دکتر احمدی", + "gender": "male", + "degree": "متخصص", + "img": [], + "specialties": [{ "id": "1", "name": "قلب" }], + "active": true, + "deactivated_at": null +} +``` + +--- + +### قابلیت ۵ — Frontend: دکمه غیرفعال/فعال در `ClinicDetailPage.tsx` + +**فایل:** `assets/admin/pages/ClinicDetailPage.tsx` + +**موارد لازم:** + +#### آپدیت interface: +```ts +interface ClinicDoctorItem { + id: string; uuid: string; name: string; + gender: string | null; degree: string | null; + img: { url: string }[]; + specialties: { id: string; name: string }[]; + active: boolean; + deactivated_at: number | null; // جدید +} +``` + +#### منطق نمایش دکمه: +- دکمه فقط برای **clinic owner** نمایش داده شود: + ```ts + const authStore = useAuthStore(); + const isOwner = authStore.context?.type === 'clinic' && authStore.dbUuid === uuid; // uuid از params + ``` +- اگر `isOwner = true`: کنار هر دکتر دکمه نمایش بده + +#### دکمه‌ها: +- اگر `doc.active = true`: + دکمه «غیرفعال کردن» (className: `btn sm soft`) با آیکون `NoSymbolIcon` + با `ConfirmDialog` که متن هشدار داشته باشد: **«تمام نوبت‌های آینده این دکتر لغو خواهند شد»** + +- اگر `doc.active = false`: + دکمه «فعال کردن» (className: `btn sm primary`) با آیکون `CheckCircleIcon` + بدون confirm dialog + +#### API calls (با `useMutation`): +```ts +const deactivateMutation = useMutation({ + mutationFn: (doctorUuid: string) => + api.patch(`/api/v1/clinic/${uuid}/doctor/${doctorUuid}/deactivate`, {}), + onSuccess: (res) => { + const count = res?.data?.cancelled_appointments ?? 0; + toast.success(`دکتر غیرفعال شد${count ? ` — ${count} نوبت لغو شد` : ''}`); + queryClient.invalidateQueries({ queryKey: ['clinic-doctors', uuid] }); + }, + onError: (err) => toast.error(err.message ?? 'خطا'), +}); + +const reactivateMutation = useMutation({ + mutationFn: (doctorUuid: string) => + api.patch(`/api/v1/clinic/${uuid}/doctor/${doctorUuid}/reactivate`, {}), + onSuccess: () => { + toast.success('دکتر مجدداً فعال شد'); + queryClient.invalidateQueries({ queryKey: ['clinic-doctors', uuid] }); + }, + onError: (err) => toast.error(err.message ?? 'خطا'), +}); +``` + +#### UX نکات: +- `ConfirmDialog` برای deactivate: عنوان «غیرفعال کردن دکتر»، متن «تمام نوبت‌های آینده این دکتر لغو می‌شوند. آیا مطمئن هستید؟» +- badge دکتر غیرفعال: `className="badge gray"` + تاریخ غیرفعالی با `formatDate(String(doc.deactivated_at))` +- import لازم: `NoSymbolIcon`, `CheckCircleIcon` از `@heroicons/react/24/outline` +- `ConfirmDialog` از `../components/ui/ConfirmDialog` + +--- + +## ترتیب اجرا + +1. Migration دستی (یک فایل): `clinic_doctors.is_active/deactivated_at` + `doctor_addresses.clinic_id` + `appointments.address_id` +2. آپدیت Entity: `DoctorAddress.clinicId` و `Appointment.addressId` (getter/setter) +3. Backend: آپدیت booking endpoint تا `address_id` را بگیرد و ذخیره کند +4. Backend: `findFutureActiveByDoctorAndClinic` در `AppointmentRepository` +5. Backend: `deactivate` endpoint +6. Backend: `reactivate` endpoint +7. Backend: آپدیت `doctorList` به native SQL (شامل `is_active` از `clinic_doctors`) +8. تست backend +9. Frontend: آپدیت `ClinicDetailPage.tsx` (دکمه deactivate/reactivate) +10. تست frontend +11. مستندسازی `docs/api/clinic.md` + +--- + +## تست هر مرحله + +```bash +# migration (دستی ساخته می‌شود — نه از طریق diff) +ddev exec php bin/console doctrine:migrations:migrate --no-interaction + +# syntax +ddev exec php -l src/Clinic/Controller/ClinicController.php +ddev exec php -l src/Appointment/Repository/AppointmentRepository.php +ddev exec php -l src/Doctor/Entity/DoctorAddress.php +ddev exec php -l src/Appointment/Entity/Appointment.php +ddev exec php -l src/Appointment/Controller/AppointmentController.php + +# cache + routes +ddev exec php bin/console cache:clear +ddev exec php bin/console debug:router | grep -E "deactivate|reactivate" + +# frontend +ddev exec yarn dev +ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -20 +``` + +--- + +## مستندسازی + +به `docs/api/clinic.md` اضافه کن: + +### `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/deactivate` +### `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/reactivate` +### آپدیت `GET /api/v1/clinic/doctor-list/{clinicUuid}` (فیلدهای جدید) diff --git a/.claude/prompt/location-clinic-address.md b/.claude/prompt/location-clinic-address.md new file mode 100644 index 00000000..6506e5c3 --- /dev/null +++ b/.claude/prompt/location-clinic-address.md @@ -0,0 +1,418 @@ +# پرامپت: مدیریت آدرس کلینیک و location_id در برنامه هفتگی + +## هدف + +مشکل فعلی: `location_id` در برنامه هفتگی (`weekly_schedules.setting`) به `doctor_addresses.id` اشاره می‌کند، +اما این آدرس‌ها فقط شخصی هستند (به `doctor_id` وابسته‌اند) و هیچ ارتباطی با کلینیک ندارند. + +**خواسته:** +۱. کلینیک باید بتواند آدرس خودش را ثبت و مدیریت کند (آدرس کلینیک، نه آدرس شخصی دکتر) +۲. وقتی دکتری به کلینیک اضافه شد، بتواند آدرس آن کلینیک را به عنوان `location_id` در برنامه‌اش انتخاب کند +۳. آدرس‌های شخصی دکتر هم همچنان قابل استفاده باشند +۴. endpoint فعلی `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` که یک workaround بود باید حذف شود + +--- + +## زمینه فنی موجود + +### جدول `doctor_addresses` (فعلی): +```sql +id, uuid, name, address, telephone, latitude, longitude, +created_at, updated_at, +doctor_id INT NOT NULL, -- FK به doctors.id (CASCADE) +city_id INT NULL, +province_id INT NULL +``` +→ فقط برای آدرس‌های شخصی دکتر — clinic_id ندارد + +### Entity `DoctorAddress`: +- constructor: `__construct(Doctor $doctor)` — فقط برای دکتر +- `toArray()` → `{ id, uuid, name, address, telephone, map, city, province }` + +### Endpoint موجود (workaround که حذف می‌شود): +- `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` در `DoctorController.php` +- این endpoint آدرس کلینیک را کپی می‌کند به عنوان آدرس شخصی دکتر — **باید حذف شود** + +### Frontend موجود (`DoctorDetailPage.tsx`): +- `addresses` از `doctor.address` می‌آید (فقط آدرس‌های شخصی دکتر) +- `location_id` dropdown در `SessionEditor` از همین `addresses` ساخته می‌شود +- warning «مکان مطب الزامی است» اگر `location_id = null` در session فعال باشد +- interface: `AddressData { id: string; name: string | null; address: string | null; telephone: string | null; map: {...}; city: {...}; province: {...} }` + +### API endpoint موجود برای آدرس دکتر: +- `POST /api/v1/clinic-pro/doctor-address` — ایجاد آدرس شخصی دکتر +- `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` — لیست آدرس‌های دکتر +- `PATCH /api/v1/clinic-pro/doctor-address/{id}` — ویرایش +- `DELETE /api/v1/clinic-pro/doctor-address/{id}` — حذف + +--- + +## قابلیت‌ها + +--- + +### قابلیت ۱ — Migration: تغییر ساختار `doctor_addresses` + +سه تغییر در یک migration file: + +```php +// ۱. اضافه کردن type +$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN type VARCHAR(10) NOT NULL DEFAULT 'personal'"); + +// ۲. clinic_id nullable FK +$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT NULL"); +$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE CASCADE"); +$this->addSql("CREATE INDEX idx_doctor_addr_clinic ON doctor_addresses (clinic_id)"); + +// ۳. doctor_id را nullable کن (آدرس‌های کلینیک doctor ندارند) +$this->addSql("ALTER TABLE doctor_addresses MODIFY COLUMN doctor_id INT NULL"); + +// قانون: حداقل یکی از doctor_id یا clinic_id باید مقدار داشته باشد +$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT chk_addr_owner CHECK (doctor_id IS NOT NULL OR clinic_id IS NOT NULL)"); +``` + +**مقادیر `type`:** +- `personal` — آدرس شخصی دکتر (doctor_id NOT NULL, clinic_id NULL) +- `clinic` — آدرس کلینیک (clinic_id NOT NULL, doctor_id NULL) + +```bash +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +--- + +### قابلیت ۲ — آپدیت Entity `DoctorAddress` + +**فایل:** `src/Doctor/Entity/DoctorAddress.php` + +تغییرات: +- `doctor_id` → nullable (`?int $doctorId` + join nullable) +- اضافه کردن `clinic_id` (nullable int) +- اضافه کردن `type` string (`personal` | `clinic`) +- constructor: دو حالت — برای دکتر یا برای کلینیک + +```php +// فیلدهای جدید +#[ORM\Column(type: 'string', length: 10)] +private string $type = 'personal'; + +#[ORM\Column(name: 'clinic_id', type: 'integer', nullable: true)] +private ?int $clinicId = null; + +// doctor nullable شود +#[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')] +#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] +private ?Doctor $doctor = null; +``` + +Constructor باید هر دو حالت را پشتیبانی کند: +```php +public static function forDoctor(Doctor $doctor): self { + $a = new self(); + $a->type = 'personal'; + $a->doctor = $doctor; + return $a; +} + +public static function forClinic(int $clinicId): self { + $a = new self(); + $a->type = 'clinic'; + $a->clinicId = $clinicId; + return $a; +} +``` + +`toArray()` آپدیت شود: فیلد `type` اضافه شود: +```php +'type' => $this->type, +'clinic_id' => $this->clinicId, +``` + +> ⚠️ Constructor قدیمی `__construct(Doctor $doctor)` تغییر می‌کند. همه جاهایی که `new DoctorAddress($doctor)` دارند باید به `DoctorAddress::forDoctor($doctor)` تغییر کنند: +> - `src/Doctor/Controller/DoctorController.php` متد `createAddress` و `createAddressFromClinic` + +--- + +### قابلیت ۳ — حذف endpoint قدیمی و آپدیت DoctorController + +**فایل:** `src/Doctor/Controller/DoctorController.php` + +۱. متد `createAddressFromClinic` (route: `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`) را کامل حذف کن. + +۲. در `createAddress`: `new DoctorAddress($doctor)` → `DoctorAddress::forDoctor($doctor)` + +۳. در `updateAddress` (PATCH): چک کن آدرس از نوع `personal` باشد — آدرس‌های `clinic` type را فقط صاحب کلینیک می‌تواند ویرایش کند (در قابلیت ۴ هندل می‌شود) + +--- + +### قابلیت ۴ — CRUD آدرس کلینیک + +**فایل:** `src/Clinic/Controller/ClinicController.php` + +#### `POST /api/v1/clinic/{clinicUuid}/address` — ایجاد آدرس کلینیک + +Auth: صاحب کلینیک یا admin + +Request body: +```json +{ + "name": "شعبه مرکزی", + "address": "تهران، خیابان ولیعصر...", + "telephone": "02112345678", + "latitude": 35.699, + "longitude": 51.337, + "city_id": 123, + "province_id": 7 +} +``` + +منطق: +1. کلینیک را پیدا کن با `clinicUuid` → 404 اگر نبود +2. بررسی owner → 403 اگر دسترسی نداشت +3. `DoctorAddress::forClinic($clinic->getId())` بساز +4. فیلدها را ست کن (name, address, telephone, lat/lng, city, province) +5. ذخیره و برگردان + +Response: `{ success, data: { id, uuid, type: 'clinic', name, address, ... } }` + +#### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}` — ویرایش + +- پیدا کردن `DoctorAddress` با `uuid` و `clinic_id = clinic.id` +- بررسی ownership +- آپدیت فیلدها + +#### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}` — حذف + +- اگر فقط یک آدرس مانده → 409 با پیام «کلینیک باید حداقل یک آدرس داشته باشد» +- در غیر این صورت حذف کن + +#### `GET /api/v1/clinic/{clinicUuid}/addresses` — لیست آدرس‌ها + +- برگرداندن همه `doctor_addresses` که `clinic_id = clinic.id` +- Auth: public (برای نمایش در صفحه کلینیک برای بیماران) + +> ⚠️ برای find با uuid نیاز به متد `findByUuidAndClinic(string $uuid, int $clinicId)` در `DoctorAddressRepository` است. + +--- + +### قابلیت ۵ — Endpoint آدرس‌های در دسترس دکتر + +**فایل:** `src/Appointment/Controller/AppointmentSettingsController.php` + +**Route:** `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` + +**Auth:** IS_AUTHENTICATED_FULLY (دکتر یا admin) + +**منطق:** +```php +// ۱. آدرس‌های شخصی دکتر +$personal = $addressRepo->findBy(['doctor' => $doctor, 'type' => 'personal']); + +// ۲. آدرس‌های کلینیک‌هایی که این دکتر عضوشان است +$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinicRepo->findByDoctor($doctor)); +$clinicAddresses = $addressRepo->findBy(['clinicId' => $clinicIds, 'type' => 'clinic']); + +// ۳. ادغام با label مناسب +``` + +Response: +```json +{ + "success": true, + "data": [ + { + "id": "5", + "uuid": "...", + "type": "personal", + "name": "مطب شخصی", + "address": "...", + "clinic_name": null + }, + { + "id": "12", + "uuid": "...", + "type": "clinic", + "name": "شعبه مرکزی", + "address": "...", + "clinic_name": "کلینیک نور" + } + ] +} +``` + +> `clinic_name` برای آدرس‌های `clinic` type از جدول `clinics` می‌آید — با یک query join بگیر. + +**Repository method لازم:** +```php +// در DoctorAddressRepository +public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array +{ + $qb = $this->createQueryBuilder('a'); + return $qb + ->where('(a.doctor = :doctor AND a.type = :personal)') + ->orWhere('(a.clinicId IN (:clinicIds) AND a.type = :clinic)') + ->setParameter('doctor', $doctor) + ->setParameter('personal', 'personal') + ->setParameter('clinicIds', empty($clinicIds) ? [0] : $clinicIds) + ->setParameter('clinic', 'clinic') + ->getQuery() + ->getResult(); +} +``` + +**Security:** اضافه کردن به `public_endpoints` در `security.yaml`: +```yaml +pattern: ^/(api/v1/appointment-settings/available-locations/|...) +``` + +--- + +### قابلیت ۶ — Frontend: `DoctorDetailPage.tsx` + +**فایل:** `assets/admin/pages/DoctorDetailPage.tsx` + +#### آپدیت interface: +```ts +interface AddressData { + id: string; + uuid: string; + type: 'personal' | 'clinic'; + name: string | null; + address: string | null; + telephone: string | null; + map: { latitude: string | null; longitude: string | null }; + city: { id: string; name: string } | null; + province: { id: string; name: string } | null; + clinic_name: string | null; // جدید +} +``` + +#### آپدیت بارگذاری آدرس‌ها در `ScheduleSection`: + +فعلاً: `addresses = doctor.address ?? []` (فقط شخصی) + +جدید: آدرس‌ها از `available-locations` endpoint بارگذاری شوند: +```ts +const locationsQ = useQuery({ + queryKey: ['available-locations', doctorUuid], + queryFn: () => api.get>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`), + enabled: !!doctorUuid, +}); +const availableLocations = locationsQ.data?.data ?? []; +``` + +این query در `ScheduleSection` component باشد و `addresses` prop از parent حذف شود. + +#### آپدیت `SessionEditor` — dropdown لوکیشن: + +نمایش label مناسب با type: +```tsx + +``` + +--- + +### قابلیت ۷ — Frontend: مدیریت آدرس کلینیک در `ClinicDetailPage.tsx` + +**فایل:** `assets/admin/pages/ClinicDetailPage.tsx` + +#### Interface جدید: +```ts +interface ClinicAddress { + id: string; uuid: string; + name: string | null; address: string | null; + telephone: string | null; + map: { latitude: string | null; longitude: string | null }; + city: { id: string; name: string } | null; + province: { id: string; name: string } | null; +} +``` + +#### بارگذاری آدرس‌های کلینیک: +```ts +const addressesQ = useQuery({ + queryKey: ['clinic-addresses', uuid], + queryFn: () => api.get>(`/api/v1/clinic/${uuid}/addresses`), +}); +const clinicAddresses = addressesQ.data?.data ?? []; +``` + +#### UI (فقط برای clinic owner): +- سکشن «آدرس‌های کلینیک» با دکمه «افزودن آدرس» +- هر آدرس: نام + آدرس + تلفن + دکمه‌های ویرایش/حذف +- اگر آدرسی ندارد: warning «کلینیک هنوز آدرسی ثبت نکرده — دکتران نمی‌توانند این کلینیک را به عنوان لوکیشن انتخاب کنند» +- Modal برای add/edit با فیلدهای: name, address, telephone, city_id, province_id +- mutation‌های `addAddressMutation`, `editAddressMutation`, `deleteAddressMutation` + +--- + +## ترتیب اجرا + +1. Migration: آپدیت `doctor_addresses` (type + clinic_id + nullable doctor_id) +2. Entity: آپدیت `DoctorAddress` (static factory methods, فیلدهای جدید) +3. Controller: آپدیت `DoctorController` (حذف `from-clinic` workaround، آپدیت `createAddress`) +4. Controller: اضافه کردن CRUD آدرس کلینیک به `ClinicController` +5. Repository: `findAvailableForDoctor` و `findByUuidAndClinic` به `DoctorAddressRepository` +6. Controller: endpoint `available-locations` به `AppointmentSettingsController` +7. Security: آپدیت `security.yaml` برای `available-locations` public +8. تست backend +9. Frontend: آپدیت `DoctorDetailPage.tsx` (ScheduleSection، SessionEditor) +10. Frontend: آپدیت `ClinicDetailPage.tsx` (مدیریت آدرس) +11. تست frontend +12. مستندسازی `docs/api/clinic.md` و `docs/api/appointment-settings.md` + +--- + +## تست هر مرحله + +```bash +# migration +ddev exec php bin/console doctrine:migrations:migrate --no-interaction + +# syntax +ddev exec php -l src/Doctor/Entity/DoctorAddress.php +ddev exec php -l src/Doctor/Controller/DoctorController.php +ddev exec php -l src/Clinic/Controller/ClinicController.php +ddev exec php -l src/Appointment/Controller/AppointmentSettingsController.php + +# cache + routes +ddev exec php bin/console cache:clear +ddev exec php bin/console debug:router | grep -E "clinic.*address|available-location" + +# frontend +ddev exec yarn dev +ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -30 +``` + +--- + +## مستندسازی + +### `docs/api/clinic.md`: +- `POST /api/v1/clinic/{clinicUuid}/address` +- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}` +- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}` +- `GET /api/v1/clinic/{clinicUuid}/addresses` +- حذف endpoint: `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` + +### `docs/api/appointment-settings.md`: +- `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 2c10deb9..b6e9f22a 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -57,6 +57,16 @@ interface ClinicDoctorItem { specialties: { id: string; name: string }[]; active: boolean; } + +interface ClinicAddress { + id: string; uuid: string; + name: string | null; address: string | null; + telephone: string | null; + map: { latitude: string | null; longitude: string | null }; + city: { id: string; name: string } | null; + province: { id: string; name: string } | null; +} + interface Opt { id: number; name: string; } interface OptUuid { id: number; uuid: string; name: string; } @@ -514,6 +524,9 @@ export default function ClinicDetailPage() { const navigate = useNavigate(); const qc = useQueryClient(); const primaryRole = useAuthStore(s => s.primaryRole); + const dbUuid = useAuthStore(s => s.dbUuid); + const isOwner = primaryRole === 'clinic' && dbUuid === uuid; + const [editOpen, setEditOpen] = useState(false); const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic'); const [deleteOpen, setDeleteOpen] = useState(false); @@ -542,6 +555,46 @@ export default function ClinicDetailPage() { enabled: !!uuid, }); + const clinicAddressesQ = useQuery({ + queryKey: ['clinic-addresses', uuid], + queryFn: () => api.get>(`/api/v1/clinic/${uuid}/addresses`), + enabled: !!uuid, + }); + const clinicAddresses: ClinicAddress[] = clinicAddressesQ.data?.data ?? []; + + const [addrFormOpen, setAddrFormOpen] = useState(false); + const [editingClinicAddr, setEditingClinicAddr] = useState(null); + const [deleteAddrConfirm, setDeleteAddrConfirm] = useState(null); + + const [addrForm, setAddrForm] = useState({ name: '', address: '', telephone: '' }); + + const saveAddrMutation = useMutation({ + mutationFn: (payload: typeof addrForm) => { + if (editingClinicAddr) { + return api.patch>(`/api/v1/clinic/${uuid}/address/${editingClinicAddr.uuid}`, payload); + } + return api.post>(`/api/v1/clinic/${uuid}/address`, payload); + }, + onSuccess: () => { + toast.success(editingClinicAddr ? 'آدرس ویرایش شد' : 'آدرس اضافه شد'); + qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] }); + setAddrFormOpen(false); + setEditingClinicAddr(null); + setAddrForm({ name: '', address: '', telephone: '' }); + }, + onError: () => toast.error('خطا در ذخیره آدرس'), + }); + + const deleteAddrMutation = useMutation({ + mutationFn: (addrUuid: string) => api.delete>(`/api/v1/clinic/${uuid}/address/${addrUuid}`), + onSuccess: () => { + toast.success('آدرس حذف شد'); + qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] }); + setDeleteAddrConfirm(null); + }, + onError: (err: any) => toast.error(err?.message ?? 'خطا در حذف آدرس'), + }); + const clinic: ClinicDetail | undefined = useMemo(() => { const raw = data?.data; return (raw as any)?.data ?? raw; @@ -985,9 +1038,134 @@ export default function ClinicDetailPage() { )} + {/* Clinic Addresses Section */} + {(isOwner || primaryRole === 'admin') && ( +
+
+
+ آدرس‌های کلینیک ({formatNumber(clinicAddresses.length)}) +
+ {isOwner && ( + + )} +
+ + {clinicAddresses.length === 0 ? ( +
+ +

+ هنوز آدرسی ثبت نشده — دکتران نمی‌توانند این کلینیک را به عنوان لوکیشن انتخاب کنند +

+
+ ) : ( +
+ {clinicAddresses.map((addr, idx) => ( +
+ +
+ {addr.name &&
{addr.name}
} + {addr.address &&
{addr.address}
} + {addr.telephone && ( +
+ + {addr.telephone} +
+ )} +
+ {isOwner && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+ )} + + {/* Clinic Address Form Modal */} + {addrFormOpen && ( +
setAddrFormOpen(false)}> +
e.stopPropagation()}> +
+ {editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'} + +
+
+
+ +
+ setAddrForm(f => ({ ...f, name: e.target.value }))} /> +
+
+
+ +
+ setAddrForm(f => ({ ...f, address: e.target.value }))} /> +
+
+
+ +
+ setAddrForm(f => ({ ...f, telephone: e.target.value }))} /> +
+
+
+
+ + +
+
+
+ )} + + {/* Delete Address Confirm */} + deleteAddrConfirm && deleteAddrMutation.mutate(deleteAddrConfirm.uuid)} + onCancel={() => setDeleteAddrConfirm(null)} + /> + {/* Edit modal — portal to escape Leaflet transform context */} {editOpen && createPortal( upd('location_id', e.target.value ? Number(e.target.value) : null)}> - {addresses.map(a => ( - - ))} + {addresses.filter(a => a.type === 'personal').length > 0 && ( + + {addresses.filter(a => a.type === 'personal').map(a => ( + + ))} + + )} + {addresses.filter(a => a.type === 'clinic').length > 0 && ( + + {addresses.filter(a => a.type === 'clinic').map(a => ( + + ))} + + )} @@ -1736,8 +1753,17 @@ const SCHEDULE_TABS = [ { id: 'holidays' as const, label: 'تعطیلات' }, ]; -function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) { +function ScheduleSection({ doctorUuid }: { doctorUuid: string }) { const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly'); + + const locationsQ = useQuery({ + queryKey: ['available-locations', doctorUuid], + queryFn: () => api.get>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`), + enabled: !!doctorUuid, + staleTime: 30_000, + }); + const availableLocations: AddressData[] = locationsQ.data?.data ?? []; + return (

برنامه کاری

@@ -1753,8 +1779,8 @@ function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addres ))}
- {tab === 'weekly' && } - {tab === 'overrides' && } + {tab === 'weekly' && } + {tab === 'overrides' && } {tab === 'holidays' && } ); @@ -2136,7 +2162,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil )} - {uuid && } + {uuid && } {doctor.clinics && doctor.clinics.length > 0 && (
diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 88087495..0cabab4d 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -33,7 +33,7 @@ security: provider: api_doc_provider public_endpoints: - pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$) + pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/available-locations/|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$) stateless: true security: false @@ -77,6 +77,8 @@ security: methods: [GET] roles: PUBLIC_ACCESS - { path: ^/api/v1/clinic/doctor-list/, roles: PUBLIC_ACCESS } + - { path: '^/api/v1/clinic/[^/]+/addresses$', roles: PUBLIC_ACCESS } + - { path: ^/api/v1/appointment-settings/available-locations/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/clinic-pro/doctor-addresses/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/clinics$, roles: PUBLIC_ACCESS } - path: '^/api/v1/clinic/[^/]+$' diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index 92b53598..15cb33d9 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -538,3 +538,61 @@ The `SlotCalculatorService` calculates available slots in this priority order: } ] ``` + +--- + +## Available Locations + +### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` + +**Permission:** Public + +Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to. + +#### Response `200` +```json +{ + "success": true, + "data": [ + { + "id": "5", + "uuid": "...", + "type": "personal", + "clinic_id": null, + "clinic_name": null, + "name": "مطب شخصی", + "address": "تهران، ...", + "telephone": "09121234567", + "map": { "latitude": "35.7", "longitude": "51.4" }, + "city": { "id": "1", "name": "تهران" }, + "province": { "id": "8", "name": "تهران" } + }, + { + "id": "12", + "uuid": "...", + "type": "clinic", + "clinic_id": "3", + "clinic_name": "کلینیک نور", + "name": "شعبه مرکزی", + "address": "تهران، خیابان ولیعصر...", + "telephone": "02112345678", + "map": { "latitude": "35.699", "longitude": "51.337" }, + "city": { "id": "1", "name": "تهران" }, + "province": { "id": "8", "name": "تهران" } + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `type` | `personal` = doctor's own address; `clinic` = clinic address | +| `clinic_id` | ID of the clinic (only for type=clinic) | +| `clinic_name` | Name of the clinic (only for type=clinic) | + +> Use `id` as the `location_id` value in weekly schedule sessions or date override sessions. + +#### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_VALIDATION_002` | 404 | Doctor not found | diff --git a/docs/api/clinic.md b/docs/api/clinic.md index 50cf4c48..5b5cdca1 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -308,3 +308,111 @@ Upload clinic gallery image. |------|------|-------------| | `ERR_FILE_001` | 422 | Invalid file type | | `ERR_AUTH_001` | 401 | Missing token | + +--- + +## Clinic Address Management + +### `GET /api/v1/clinic/{clinicUuid}/addresses` + +**Permission:** Public + +Returns all addresses registered for a clinic (type=clinic entries). + +#### Response `200` +```json +{ + "success": true, + "data": [ + { + "id": "12", + "uuid": "abc-123", + "type": "clinic", + "clinic_id": "5", + "name": "شعبه مرکزی", + "address": "تهران، خیابان ولیعصر...", + "telephone": "02112345678", + "map": { "latitude": "35.699", "longitude": "51.337" }, + "city": { "id": "1", "name": "تهران" }, + "province": { "id": "8", "name": "تهران" } + } + ] +} +``` + +--- + +### `POST /api/v1/clinic/{clinicUuid}/address` + +**Permission:** Clinic owner or `ROLE_ADMIN` + +Creates a new address for the clinic. The address will appear in `available-locations` for doctors belonging to this clinic. + +#### Request +```json +{ + "name": "شعبه مرکزی", + "address": "تهران، خیابان ولیعصر...", + "telephone": "02112345678", + "latitude": 35.699, + "longitude": 51.337, + "city_id": 123, + "province_id": 7 +} +``` + +| Field | Type | Required | +|-------|------|----------| +| `name` | string | ❌ | +| `address` | string | ❌ | +| `telephone` | string | ❌ | +| `latitude` | float | ❌ | +| `longitude` | float | ❌ | +| `city_id` | integer | ❌ | +| `province_id` | integer | ❌ | + +#### Response `201` +```json +{ "success": true, "data": { "id": "12", "uuid": "...", "type": "clinic", ... } } +``` + +#### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_AUTH_006` | 403 | Not the clinic owner | +| `ERR_VALIDATION_002` | 404 | Clinic not found | + +--- + +### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}` + +**Permission:** Clinic owner or `ROLE_ADMIN` + +Updates an existing clinic address. Same body fields as POST (all optional). + +#### Response `200` +```json +{ "success": true, "data": { ... } } +``` + +--- + +### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}` + +**Permission:** Clinic owner or `ROLE_ADMIN` + +Deletes a clinic address. + +> A clinic must retain at least one address — attempting to delete the last address returns `409`. + +#### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_CONFLICT_001` | 409 | Cannot delete the last address | +| `ERR_VALIDATION_002` | 404 | Address or clinic not found | +| `ERR_AUTH_006` | 403 | Not the clinic owner | + +--- + +### Removed endpoint +`POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` — **removed**. Use clinic address management endpoints instead. diff --git a/migrations/Version20260612132848.php b/migrations/Version20260612132848.php new file mode 100644 index 00000000..41bb1c8b --- /dev/null +++ b/migrations/Version20260612132848.php @@ -0,0 +1,36 @@ +addSql("ALTER TABLE doctor_addresses ADD COLUMN type VARCHAR(10) NOT NULL DEFAULT 'personal'"); + $this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT NULL"); + $this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE CASCADE"); + $this->addSql("CREATE INDEX idx_doctor_addr_clinic ON doctor_addresses (clinic_id)"); + $this->addSql("ALTER TABLE doctor_addresses MODIFY COLUMN doctor_id INT NULL"); + $this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT chk_addr_owner CHECK (doctor_id IS NOT NULL OR clinic_id IS NOT NULL)"); + } + + public function down(Schema $schema): void + { + $this->addSql("ALTER TABLE doctor_addresses DROP CONSTRAINT chk_addr_owner"); + $this->addSql("ALTER TABLE doctor_addresses MODIFY COLUMN doctor_id INT NOT NULL"); + $this->addSql("DROP INDEX idx_doctor_addr_clinic ON doctor_addresses"); + $this->addSql("ALTER TABLE doctor_addresses DROP FOREIGN KEY FK_doctor_addr_clinic"); + $this->addSql("ALTER TABLE doctor_addresses DROP COLUMN clinic_id"); + $this->addSql("ALTER TABLE doctor_addresses DROP COLUMN type"); + } +} diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 6e763f3d..c1698724 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -9,6 +9,10 @@ use App\Appointment\Repository\DateOverrideRepository; use App\Appointment\Repository\HolidayRepository; use App\Appointment\Repository\WeeklyScheduleRepository; use App\Auth\Entity\User; +use App\Clinic\Entity\Clinic; +use App\Clinic\Repository\ClinicRepository; +use App\Doctor\Entity\DoctorAddress; +use App\Doctor\Repository\DoctorAddressRepository; use App\Doctor\Repository\DoctorRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; @@ -26,6 +30,8 @@ class AppointmentSettingsController extends BaseController private readonly DateOverrideRepository $overrideRepo, private readonly HolidayRepository $holidayRepo, private readonly DoctorRepository $doctorRepo, + private readonly DoctorAddressRepository $addressRepo, + private readonly ClinicRepository $clinicRepo, ) {} // ── Weekly Schedule ─────────────────────────────────────────────────────── @@ -313,4 +319,32 @@ class AppointmentSettingsController extends BaseController return $this->success(['data' => $holiday->toArray()]); } + + // ── Available Locations ─────────────────────────────────────────────────── + + #[Route('/api/v1/appointment-settings/available-locations/{doctorUuid}', methods: ['GET'])] + public function availableLocations(string $doctorUuid): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + $clinics = $this->clinicRepo->findByDoctor($doctor); + $clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinics); + $clinicMap = []; + foreach ($clinics as $clinic) { + $clinicMap[$clinic->getId()] = $clinic->getName(); + } + + $addresses = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds); + + $result = array_map(function (DoctorAddress $a) use ($clinicMap): array { + $data = $a->toArray(); + $data['clinic_name'] = $a->getClinicId() !== null ? ($clinicMap[$a->getClinicId()] ?? null) : null; + return $data; + }, $addresses); + + return $this->success(['data' => $result]); + } } diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index 7ea5b3e4..6dd85bee 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -7,6 +7,8 @@ use App\Auth\Repository\UserRepository; use App\Clinic\Entity\Clinic; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Entity\Doctor; +use App\Doctor\Entity\DoctorAddress; +use App\Doctor\Repository\DoctorAddressRepository; use App\Doctor\Repository\DoctorRepository; use App\DoctorService\Repository\DoctorServiceRepository; use App\Insurance\Repository\InsuranceRepository; @@ -28,16 +30,17 @@ use Symfony\Component\Uid\Uuid; class ClinicController extends BaseController { public function __construct( - private readonly ClinicRepository $clinicRepo, - private readonly DoctorRepository $doctorRepo, - private readonly SpecialtyRepository $specialtyRepo, + private readonly ClinicRepository $clinicRepo, + private readonly DoctorRepository $doctorRepo, + private readonly DoctorAddressRepository $addressRepo, + private readonly SpecialtyRepository $specialtyRepo, private readonly DoctorServiceRepository $serviceRepo, - private readonly InsuranceRepository $insuranceRepo, - private readonly ProvinceRepository $provinceRepo, - private readonly CityRepository $cityRepo, - private readonly UserRepository $userRepo, - private readonly FileValidatorService $fileValidator, - private readonly string $projectDir, + private readonly InsuranceRepository $insuranceRepo, + private readonly ProvinceRepository $provinceRepo, + private readonly CityRepository $cityRepo, + private readonly UserRepository $userRepo, + private readonly FileValidatorService $fileValidator, + private readonly string $projectDir, ) {} #[OA\Post( @@ -535,4 +538,109 @@ class ClinicController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); } } + + // ── Clinic Addresses ───────────────────────────────────────────────────── + + #[Route('/api/v1/clinic/{clinicUuid}/addresses', methods: ['GET'])] + public function listAddresses(string $clinicUuid): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($clinicUuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + $addresses = $this->addressRepo->findBy(['clinicId' => $clinic->getId()]); + + return $this->success(['data' => array_map(fn(DoctorAddress $a) => $a->toArray(), $addresses)]); + } + + #[Route('/api/v1/clinic/{clinicUuid}/address', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function createAddress(string $clinicUuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($clinicUuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $address = DoctorAddress::forClinic($clinic->getId()); + $this->hydrateClinicAddress($address, $data); + $this->addressRepo->save($address); + + return $this->success(['data' => $address->toArray()], 201); + } + + #[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['PATCH'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function updateAddress(string $clinicUuid, string $addressUuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($clinicUuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $address = $this->addressRepo->findByUuidAndClinic($addressUuid, $clinic->getId()); + if ($address === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + $this->hydrateClinicAddress($address, $data); + $this->addressRepo->save($address); + + return $this->success(['data' => $address->toArray()]); + } + + #[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['DELETE'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function deleteAddress(string $clinicUuid, string $addressUuid, #[CurrentUser] User $user): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($clinicUuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $address = $this->addressRepo->findByUuidAndClinic($addressUuid, $clinic->getId()); + if ($address === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); + } + + $remaining = $this->addressRepo->countByClinic($clinic->getId()); + if ($remaining <= 1) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'کلینیک باید حداقل یک آدرس داشته باشد', 409); + } + + $this->addressRepo->remove($address); + + return $this->success(['message' => 'آدرس با موفقیت حذف شد']); + } + + private function hydrateClinicAddress(DoctorAddress $address, array $data): void + { + if (array_key_exists('name', $data)) $address->setName($data['name']); + if (array_key_exists('address', $data)) $address->setAddress($data['address']); + if (array_key_exists('telephone', $data)) $address->setTelephone($data['telephone']); + if (array_key_exists('latitude', $data)) $address->setLatitude($data['latitude'] !== null ? (float) $data['latitude'] : null); + if (array_key_exists('longitude', $data)) $address->setLongitude($data['longitude'] !== null ? (float) $data['longitude'] : null); + + if (array_key_exists('city_id', $data)) { + $address->setCity($data['city_id'] !== null ? $this->cityRepo->find((int) $data['city_id']) : null); + } + if (array_key_exists('province_id', $data)) { + $address->setProvince($data['province_id'] !== null ? $this->provinceRepo->find((int) $data['province_id']) : null); + } + } } diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 5bc5c90c..2d380cd9 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -469,7 +469,7 @@ class DoctorController extends BaseController } } - $address = new DoctorAddress($doctor); + $address = DoctorAddress::forDoctor($doctor); $this->hydrateAddress($address, $data); $this->addressRepo->save($address); @@ -554,7 +554,11 @@ class DoctorController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); } - if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if ($address->getType() !== DoctorAddress::TYPE_PERSONAL) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک ویرایش می‌شود', 403); + } + + if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -599,7 +603,11 @@ class DoctorController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); } - if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if ($address->getType() !== DoctorAddress::TYPE_PERSONAL) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک حذف می‌شود', 403); + } + + if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -724,52 +732,4 @@ class DoctorController extends BaseController } } - #[Route('/api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}', methods: ['POST'])] - #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function createAddressFromClinic(string $clinicUuid, #[CurrentUser] User $user): JsonResponse - { - $doctor = $this->doctorRepo->findByUser($user); - if ($doctor === null) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403); - } - - $clinic = $this->clinicRepo->findByUuid($clinicUuid); - if ($clinic === null) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); - } - - // Verify doctor belongs to this clinic - $clinics = $this->clinicRepo->findByDoctor($doctor); - $belongs = array_filter($clinics, fn(Clinic $c) => $c->getUuid() === $clinicUuid); - if (empty($belongs)) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'این دکتر عضو این کلینیک نیست', 403); - } - - // Prevent duplicate: if this doctor already has an address for this clinic (same name), skip - foreach ($doctor->getAddresses() as $existing) { - if ($existing->getName() === $clinic->getName()) { - return $this->success(['data' => $existing->toArray()]); - } - } - - $address = new DoctorAddress($doctor); - $address->setName($clinic->getName()); - $address->setAddress($clinic->getAddress()); - $address->setTelephone($clinic->getTelephone()); - $address->setLatitude($clinic->getLatitude()); - $address->setLongitude($clinic->getLongitude()); - - if ($clinic->getCityId() !== null) { - $city = $this->cityRepo->find($clinic->getCityId()); - $address->setCity($city); - } - if ($clinic->getProvinceId() !== null) { - $province = $this->provinceRepo->find($clinic->getProvinceId()); - $address->setProvince($province); - } - - $this->addressRepo->save($address); - - return $this->success(['data' => $address->toArray()], 201); - } } diff --git a/src/Doctor/Entity/DoctorAddress.php b/src/Doctor/Entity/DoctorAddress.php index 798c5e69..5e9a1f18 100644 --- a/src/Doctor/Entity/DoctorAddress.php +++ b/src/Doctor/Entity/DoctorAddress.php @@ -10,8 +10,12 @@ use Symfony\Component\Uid\Uuid; #[ORM\Entity] #[ORM\Table(name: 'doctor_addresses')] #[ORM\Index(columns: ['doctor_id'], name: 'idx_doctor_addresses_doctor')] +#[ORM\Index(columns: ['clinic_id'], name: 'idx_doctor_addr_clinic')] class DoctorAddress { + public const TYPE_PERSONAL = 'personal'; + public const TYPE_CLINIC = 'clinic'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -21,8 +25,14 @@ class DoctorAddress private string $uuid; #[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')] - #[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] - private Doctor $doctor; + #[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Doctor $doctor = null; + + #[ORM\Column(name: 'clinic_id', type: 'integer', nullable: true)] + private ?int $clinicId = null; + + #[ORM\Column(type: 'string', length: 10)] + private string $type = self::TYPE_PERSONAL; #[ORM\Column(type: 'string', length: 255, nullable: true)] private ?string $name = null; @@ -53,23 +63,40 @@ class DoctorAddress #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; - public function __construct(Doctor $doctor) + private function __construct() { $this->uuid = Uuid::v4()->toRfc4122(); - $this->doctor = $doctor; $this->createdAt = time(); $this->updatedAt = time(); } - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getDoctor(): Doctor { return $this->doctor; } - public function getName(): ?string { return $this->name; } - public function getAddress(): ?string { return $this->address; } + public static function forDoctor(Doctor $doctor): self + { + $a = new self(); + $a->type = self::TYPE_PERSONAL; + $a->doctor = $doctor; + return $a; + } + + public static function forClinic(int $clinicId): self + { + $a = new self(); + $a->type = self::TYPE_CLINIC; + $a->clinicId = $clinicId; + return $a; + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): ?Doctor { return $this->doctor; } + public function getClinicId(): ?int { return $this->clinicId; } + public function getType(): string { return $this->type; } + public function getName(): ?string { return $this->name; } + public function getAddress(): ?string { return $this->address; } public function getTelephone(): ?string { return $this->telephone; } - public function getLatitude(): ?float { return $this->latitude; } - public function getLongitude(): ?float { return $this->longitude; } - public function getCity(): ?City { return $this->city; } + public function getLatitude(): ?float { return $this->latitude; } + public function getLongitude(): ?float { return $this->longitude; } + public function getCity(): ?City { return $this->city; } public function getProvince(): ?Province { return $this->province; } public function setName(?string $v): self { $this->name = $v; return $this; } @@ -85,20 +112,22 @@ class DoctorAddress public function toArray(): array { return [ - 'id' => (string) $this->id, - 'uuid' => $this->uuid, - 'name' => $this->name, - 'map' => [ + 'id' => (string) $this->id, + 'uuid' => $this->uuid, + 'type' => $this->type, + 'clinic_id' => $this->clinicId, + 'name' => $this->name, + 'map' => [ 'latitude' => $this->latitude !== null ? (string) $this->latitude : null, 'longitude' => $this->longitude !== null ? (string) $this->longitude : null, ], - 'address' => $this->address, - 'telephone' => $this->telephone, - 'city' => $this->city !== null ? [ + 'address' => $this->address, + 'telephone' => $this->telephone, + 'city' => $this->city !== null ? [ 'id' => (string) $this->city->getId(), 'name' => $this->city->getName(), ] : null, - 'province' => $this->province !== null ? [ + 'province' => $this->province !== null ? [ 'id' => (string) $this->province->getId(), 'name' => $this->province->getName(), ] : null, diff --git a/src/Doctor/Repository/DoctorAddressRepository.php b/src/Doctor/Repository/DoctorAddressRepository.php index a71bd246..0d96251d 100644 --- a/src/Doctor/Repository/DoctorAddressRepository.php +++ b/src/Doctor/Repository/DoctorAddressRepository.php @@ -2,6 +2,7 @@ namespace App\Doctor\Repository; +use App\Doctor\Entity\Doctor; use App\Doctor\Entity\DoctorAddress; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -28,4 +29,48 @@ class DoctorAddressRepository extends ServiceEntityRepository $this->getEntityManager()->flush(); } } + + public function findByUuidAndClinic(string $uuid, int $clinicId): ?DoctorAddress + { + return $this->createQueryBuilder('a') + ->where('a.uuid = :uuid') + ->andWhere('a.clinicId = :clinicId') + ->setParameter('uuid', $uuid) + ->setParameter('clinicId', $clinicId) + ->getQuery() + ->getOneOrNullResult(); + } + + public function countByClinic(int $clinicId): int + { + return (int) $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.clinicId = :clinicId') + ->setParameter('clinicId', $clinicId) + ->getQuery() + ->getSingleScalarResult(); + } + + public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array + { + $qb = $this->createQueryBuilder('a'); + $qb->where( + $qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->eq('a.doctor', ':doctor'), + $qb->expr()->eq('a.type', ':personal') + ), + $qb->expr()->andX( + $qb->expr()->in('a.clinicId', ':clinicIds'), + $qb->expr()->eq('a.type', ':clinic') + ) + ) + ) + ->setParameter('doctor', $doctor) + ->setParameter('personal', DoctorAddress::TYPE_PERSONAL) + ->setParameter('clinicIds', empty($clinicIds) ? [0] : $clinicIds) + ->setParameter('clinic', DoctorAddress::TYPE_CLINIC); + + return $qb->getQuery()->getResult(); + } }