fix(doctor): hide deactivated doctors from public site

The public list GET /api/v1/doctors only excluded inactive doctors
when an explicit `active` filter was passed; with no param it returned
everyone (deactivated doctors just ranked lower). Deactivated doctors
(admin toggled active_doctor_appointment off) leaked onto nobat724.

- DoctorRepository::findWithFilters: default (no `active` param) now
  filters activeDoctorAppointment = true. The active=1 (bookable) and
  active=0 (admin, inactive-only) escape hatches are unchanged.
- Doctor::toDetailArray: expose raw `is_active` (= activeDoctorAppointment,
  independent of schedule) so public clients can 404 a deactivated
  doctor's profile page; distinct from `active` (flag && has_schedule).
- Tests + docs/api/doctor.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-23 19:39:16 +03:30
co-authored by Claude Opus 4.8
parent 46be6a4575
commit d0fbe204a1
5 changed files with 48 additions and 8 deletions
+13 -5
View File
@@ -197,10 +197,18 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
> برمی‌گردد؛ `hours_of_work` در صورت وجود برنامه حفظ می‌شود. تا وقتی حتی یک برنامه روشن و
> دارای روز فعال باشد، همان مبنا قرار می‌گیرد.
>
> چنین پزشکی (همه خاموش) در لیست عمومی `GET /api/v1/doctors` **نمایش داده می‌شود** — این
> اندپوینت فیلتر `active` پیش‌فرض ندارد — ولی با `bookableRank` پایین‌تر از پزشکان دارای
> نوبت مرتب می‌شود و تنها با `active=1` از نتایج حذف می‌گردد. صفحهٔ تکی
> `GET /api/v1/doctor/{slug}` همیشه قابل دسترسی است.
> چنین پزشکی (همه برنامه‌ها خاموش، ولی فلگ `active_doctor_appointment` **روشن**) در لیست
> عمومی `GET /api/v1/doctors` **نمایش داده می‌شود** — چون هنوز فعال است — ولی با
> `bookableRank` پایین‌تر از پزشکان دارای نوبت مرتب می‌شود و تنها با `active=1` از نتایج حذف
> می‌گردد.
>
> **پزشک غیرفعال (`active_doctor_appointment` خاموش):** با غیرفعال‌کردن پزشک از پنل ادمین،
> او دیگر در لیست عمومی `GET /api/v1/doctors` ظاهر نمی‌شود (پیش‌فرض این اندپوینت پزشکان
> غیرفعال را کنار می‌گذارد). صفحهٔ تکی `GET /api/v1/doctor/{slug}` همچنان پاسخ می‌دهد
> (ادمین/کلینیک از همین اندپوینت برای مشاهده/ویرایش استفاده می‌کنند) و در بدنهٔ پاسخ فیلد
> خام `is_active` (`= active_doctor_appointment`، مستقل از داشتن برنامه) را برمی‌گرداند؛
> کلاینت عمومی مثل nobat724 با `is_active === false` صفحهٔ پزشک را `404` می‌کند. این با
> فیلد `active` (که `active_doctor_appointment && has_schedule` است) فرق دارد.
---
@@ -224,7 +232,7 @@ List doctors with pagination and filters.
| `degree` | string | ❌ | `expert`, `general`, `specialist`, `subspecialistplus` |
| `name` | string | ❌ | جستجوی `LIKE` روی نام پزشک |
| `sort` | string | ❌ | `ASC` یا `DESC` (پیش‌فرض `DESC`) — مرتب‌سازی ثانویه بر اساس `doctorRate` |
| `active` | `0`/`1` | ❌ | `1` → فقط پزشکان **دارای نوبت** (تعریف پایین). `0` → فقط پزشکانی که فلگ `active_doctor_appointment` آن‌ها خاموش است (کاربرد ادمین). بدون این پارامتر → **همه** پزشکان برمی‌گردند |
| `active` | `0`/`1` | ❌ | `1` → فقط پزشکان **دارای نوبت** (تعریف پایین). `0` → فقط پزشکانی که فلگ `active_doctor_appointment` آن‌ها خاموش است (کاربرد ادمین). بدون این پارامتر → فقط پزشکان **فعال** (`active_doctor_appointment` روشن)؛ پزشکان غیرفعال هرگز در لیست عمومی نمی‌آیند |
### مرتب‌سازی و تعریف «دارای نوبت»
+3
View File
@@ -616,6 +616,9 @@ class Doctor
'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
], $this->specialties->toArray()),
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
// فلگ خام فعال/غیرفعال بودن پزشک (مستقل از داشتن برنامه) — کلاینت عمومی
// مثل nobat724 با این می‌تواند صفحهٔ پزشک غیرفعال را 404 کند.
'is_active' => $this->activeDoctorAppointment,
'img' => $this->images ?? [],
'social_media' => $this->socialMedia,
'expertise' => array_map(fn(DoctorService $ds) => [
@@ -108,6 +108,12 @@ class DoctorRepository extends ServiceEntityRepository
// Legacy admin escape hatch: active=0 → flag explicitly off.
$qb->andWhere('d.activeDoctorAppointment = false');
}
} else {
// Public listing default: deactivated doctors (admin toggled the
// active flag off) must never surface on the public site, even
// without an explicit `active` filter. Bookability is a separate,
// stricter concern handled by `active=1`.
$qb->andWhere('d.activeDoctorAppointment = true');
}
// Bookable doctors always rank above non-bookable ones.
@@ -100,6 +100,24 @@ class DoctorBookingStateAggregationTest extends ApiTestCase
$this->assertSame('نوبت‌دهی آنلاین غیرفعال است', $data['free_turn']);
}
public function testDetailExposesRawIsActiveFlagForDeactivatedDoctor(): void
{
['doctor' => $doctor] = $this->makeDoctorWithInactivePersonalAndClinic();
// فلگ روشن (پیش‌فرض): is_active باید true باشد.
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$this->assertTrue($this->doctorPayload()['is_active']);
// ادمین پزشک را غیرفعال می‌کند → is_active=false (مبنای 404 در سایت عمومی).
$doctor->setActiveDoctorAppointment(false);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$data = $this->doctorPayload();
$this->assertFalse($data['is_active']);
$this->assertFalse($data['active']);
}
public function testDisabledClinicScheduleDoesNotMaskActivePersonalSchedule(): void
{
['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule]
@@ -71,17 +71,22 @@ class DoctorListBookableSortFilterTest extends ApiTestCase
return $body['data'];
}
public function testDefaultListShowsAllDoctorsBookableFirst(): void
public function testDefaultListHidesDeactivatedDoctorsBookableFirst(): void
{
$items = $this->listDoctors();
$this->assertCount(4, $items);
// Deactivated doctor (active flag off) must be absent from the public
// list even without an explicit `active` filter.
$uuids = array_column($items, 'uuid');
$this->assertNotContains($this->flagOff->getUuid(), $uuids, 'deactivated doctor must not appear');
$this->assertCount(3, $items);
$this->assertSame($this->bookable->getUuid(), $items[0]['uuid']);
$this->assertTrue($items[0]['active']);
$rest = array_column(array_slice($items, 1), 'uuid');
sort($rest);
$expected = [$this->noSchedule->getUuid(), $this->flagOff->getUuid(), $this->bookingDisabled->getUuid()];
$expected = [$this->noSchedule->getUuid(), $this->bookingDisabled->getUuid()];
sort($expected);
$this->assertSame($expected, $rest);
foreach (array_slice($items, 1) as $item) {