diff --git a/docs/api/clinic.md b/docs/api/clinic.md
index cd1767c9..9ff6d638 100644
--- a/docs/api/clinic.md
+++ b/docs/api/clinic.md
@@ -148,10 +148,25 @@ Get clinic detail.
> `city`/`state`/`map` are resolved from the clinic's **address** (`DoctorAddress` linked by `clinic_id`), not from columns on the clinic. Each is an array with a single object (or empty `[]` if the clinic has no address). `doctors` is a **count**; the actual doctor list comes from `GET /api/v1/clinic/doctor-list/{clinicUuid}` (`doctor_list` here is always `null`).
+### معنای `is_active`
+
+`is_active: false` یعنی **«موقتاً غیرفعال»**، نه «حذفشده». تصمیم صریح، چون رکورد و نوبتهای تاریخیاش باقی میمانند و کلینیک ممکن است دوباره فعال شود.
+
+پیامدها:
+
+- کلینیک غیرفعال همچنان از API برمیگردد و لینک مستقیمش **۲۰۰** میدهد (نه ۴۰۴/۴۱۰) تا لینکهای موجود نشکنند.
+- سایت عمومی همان صفحه را `noindex` میکند و از sitemap بیرون میگذارد (`nobat724_front/lib/entityQuality.js` → `isThinClinic`).
+- اگر روزی معنای «حذفشده» لازم شد، باید فیلد جداگانهای اضافه شود — نه بازتعریف این یکی.
+
+### نام کلینیک
+
+`name` نمیتواند شمارهتلفن یا مقدار آزمایشی (`test`، `تست`، `-`) باشد؛ این مقادیر با `422` رد میشوند (`App\Shared\Util\DisplayName`). `null` مجاز است و یعنی «هنوز نامگذاری نشده».
+
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
+| `ERR_VALIDATION_001` | 422 | نام کلینیک شمارهتلفن یا مقدار آزمایشی است |
---
diff --git a/docs/api/doctor.md b/docs/api/doctor.md
index 6e0a2663..f3c2fcd0 100644
--- a/docs/api/doctor.md
+++ b/docs/api/doctor.md
@@ -71,6 +71,7 @@ Create a doctor profile for the authenticated user.
| `ERR_AUTH_001` | 401 | Missing or invalid token |
| `ERR_CONFLICT_001` | 409 | Doctor profile already exists for this user |
| `ERR_VALIDATION_002` | 422 | Missing required field |
+| `ERR_VALIDATION_001` | 422 | نام پزشک شمارهتلفن یا مقدار آزمایشی است |
---
@@ -248,6 +249,21 @@ List doctors with pagination and filters.
> ℹ️ `point` و `satisfaction` فقط برای `owner_status="claimed"` مقدار دارند؛ برای `unclaimed`/`pending_transfer` هر دو `null` هستند.
+### اعتبارسنجی نام پزشک
+
+`name` نمیتواند شمارهتلفن (`^0?9\d{9}$`) یا مقدار آزمایشی (`test`، `تست`، `-`، `null`) باشد. این مقادیر در **هر** مسیر نوشتن با `422` رد میشوند — API عمومی، پنل ادمین، import و دعوت کلینیک — چون گارد روی خودِ Entity نشسته است (`App\Shared\Util\DisplayName`).
+
+دلیل: نام پزشک در `
` و نتایج جستوجوی سایت عمومی رندر میشود؛ رکوردی با نام «09390039833» یک صفحهٔ بیارزش ایندکسشدنی میسازد.
+
+> دعوت پزشک توسط کلینیک، اگر نام ارسال نشود، دیگر شمارهٔ موبایل را بهعنوان نام نمینشاند — برچسب خنثای «پزشک دعوتشده» میگیرد تا خود پزشک پروفایلش را claim کند. (ریشهٔ آلودگی تولیدی همین بود.)
+
+فرمان ممیزی رکوردهای موجود:
+
+```bash
+php bin/console app:audit-polluted-records # فقط گزارش
+php bin/console app:audit-polluted-records --force # خارجکردن از انتشار (بدون حذف)
+```
+
### `city` / `state` در پاسخ لیست
آرایه با حداکثر یک عضو — همشکل با `city`/`state` در پاسخ جزئیات پزشک و پاسخ لیست کلینیکها.
diff --git a/src/Clinic/Entity/Clinic.php b/src/Clinic/Entity/Clinic.php
index 37943d1b..c0c8df38 100644
--- a/src/Clinic/Entity/Clinic.php
+++ b/src/Clinic/Entity/Clinic.php
@@ -6,6 +6,7 @@ use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\DoctorService\Entity\DoctorService;
use App\Insurance\Entity\Insurance;
+use App\Shared\Util\DisplayName;
use App\Specialty\Entity\Specialty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -166,7 +167,8 @@ class Clinic
public function getServices(): Collection { return $this->services; }
public function getInsurances(): Collection { return $this->insurances; }
- public function setName(?string $v): self { $this->name = $v; $this->touch(); return $this; }
+ // null مجاز است (کلینیک تازهساخته هنوز نام ندارد)؛ ولی نام آلوده رد میشود.
+ public function setName(?string $v): self { if ($v !== null) DisplayName::assertReal($v); $this->name = $v; $this->touch(); return $this; }
public function setInfo(?string $v): self { $this->info = $v; $this->touch(); return $this; }
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
public function setTelephone(?string $v): self { $this->telephone = $v; $this->touch(); return $this; }
diff --git a/src/ClinicInvitation/Service/ClinicInvitationService.php b/src/ClinicInvitation/Service/ClinicInvitationService.php
index 56060eff..10dbb9a5 100644
--- a/src/ClinicInvitation/Service/ClinicInvitationService.php
+++ b/src/ClinicInvitation/Service/ClinicInvitationService.php
@@ -10,12 +10,15 @@ use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Exception\AppException;
+use App\Shared\Util\DisplayName;
use App\Sms\Service\SmsService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class ClinicInvitationService
{
+ private const UNNAMED_DOCTOR = 'پزشک دعوتشده';
+
public function __construct(
private readonly ClinicDoctorInvitationRepository $repo,
private readonly DoctorRepository $doctorRepo,
@@ -161,7 +164,9 @@ class ClinicInvitationService
}
$mobile = $inv->getMobile();
- $name = $inv->getInvitedName() ?: $mobile;
+ // شمارهٔ موبایل نامِ پزشک نیست. دعوت بدون نام قبلاً موبایل را بهعنوان نام
+ // مینشاند و همان رکورد در نتایج عمومی و صفحات سایت منتشر میشد.
+ $name = $this->resolveInvitedName($inv->getInvitedName());
$user = $this->userRepo->findOneBy(['mobileNumber' => $mobile]);
if ($user === null) {
@@ -173,7 +178,7 @@ class ClinicInvitationService
$doctor = $this->doctorRepo->findOneBy(['user' => $user]);
if ($doctor === null) {
- $doctor = new Doctor($user, $user->getRealName() ?: $name);
+ $doctor = new Doctor($user, $this->resolveInvitedName($user->getRealName()));
$doctor->setMobileNumber($mobile);
$doctor->setOwnerStatus('unclaimed');
$this->em->persist($doctor);
@@ -182,6 +187,16 @@ class ClinicInvitationService
return $doctor;
}
+ /**
+ * نام پزشکِ دعوتشده. کلینیک اغلب فقط شمارهٔ موبایل را دارد، پس نام واقعی هنوز
+ * ناشناخته است — یک برچسب خنثی مینشیند تا وقتی خود پزشک پروفایلش را claim کند.
+ * هرگز موبایل یا مقدار آزمایشی برنمیگرداند.
+ */
+ private function resolveInvitedName(?string $candidate): string
+ {
+ return DisplayName::isPlaceholder($candidate) ? self::UNNAMED_DOCTOR : $candidate;
+ }
+
/**
* برای کاربری که هنوز رمز عبور ندارد یک رمز تولید میکند تا بتواند وارد شود.
* رمز کاربران موجود هرگز بازنویسی نمیشود.
diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php
index f30c5443..404479cf 100644
--- a/src/Doctor/Entity/Doctor.php
+++ b/src/Doctor/Entity/Doctor.php
@@ -7,6 +7,7 @@ use App\Auth\Entity\User;
use App\DoctorService\Entity\DoctorService;
use App\Location\Entity\City;
use App\Location\Entity\Province;
+use App\Shared\Util\DisplayName;
use App\Specialty\Entity\Specialty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -142,6 +143,8 @@ class Doctor
public function __construct(User $user, string $name)
{
+ DisplayName::assertReal($name);
+
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->name = $name;
@@ -273,6 +276,7 @@ class Doctor
public function setName(string $v): self
{
+ DisplayName::assertReal($v);
$this->name = $v;
return $this;
}
diff --git a/src/Shared/Command/AuditPollutedRecordsCommand.php b/src/Shared/Command/AuditPollutedRecordsCommand.php
new file mode 100644
index 00000000..b02c894b
--- /dev/null
+++ b/src/Shared/Command/AuditPollutedRecordsCommand.php
@@ -0,0 +1,157 @@
+ صفحات سایت منتشر میشوند. این فرمان آنها را پیدا و دستهبندی میکند.
+ *
+ * گزارشمحور است: بدون --force هیچ چیزی نوشته نمیشود.
+ *
+ * php bin/console app:audit-polluted-records # فقط گزارش
+ * php bin/console app:audit-polluted-records --force # اعمال تغییرات
+ *
+ * حذف انجام نمیدهد. رکورد دارای رابطهٔ واقعی (نوبت/پرداخت) هرگز خودکار دست نمیخورد؛
+ * برای بقیه فقط انتشار عمومی را میبندد (active=false / is_active=false) تا تصمیمِ
+ * حذف با تیم بماند و برگشتپذیر باشد.
+ */
+#[AsCommand(
+ name: 'app:audit-polluted-records',
+ description: 'Find doctors/clinics whose name is a phone number or a placeholder; optionally unpublish them',
+)]
+class AuditPollutedRecordsCommand extends Command
+{
+ public function __construct(private readonly EntityManagerInterface $em)
+ {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this->addOption('force', null, InputOption::VALUE_NONE, 'Apply changes. Without it nothing is written.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $force = (bool) $input->getOption('force');
+
+ $doctors = $this->auditDoctors($io);
+ $clinics = $this->auditClinics($io);
+
+ $unpublishable = array_merge(
+ array_filter($doctors, fn(array $r) => !$r['hasRelations'] && $r['published']),
+ array_filter($clinics, fn(array $r) => !$r['hasRelations'] && $r['published']),
+ );
+
+ if ($doctors === [] && $clinics === []) {
+ $io->success('هیچ رکورد آلودهای یافت نشد.');
+
+ return Command::SUCCESS;
+ }
+
+ if (!$force) {
+ $io->warning(sprintf(
+ 'حالت گزارش. %d رکورد قابل خارجکردن از انتشار است. برای اعمال: --force',
+ count($unpublishable)
+ ));
+
+ return Command::SUCCESS;
+ }
+
+ foreach ($unpublishable as $row) {
+ $row['entity'] instanceof Doctor
+ ? $row['entity']->setActiveDoctorAppointment(false)
+ : $row['entity']->setIsActive(false);
+ }
+ $this->em->flush();
+
+ $io->success(sprintf('%d رکورد از انتشار عمومی خارج شد (حذف نشد).', count($unpublishable)));
+
+ return Command::SUCCESS;
+ }
+
+ /** @return array */
+ private function auditDoctors(SymfonyStyle $io): array
+ {
+ $rows = [];
+ $table = [];
+
+ /** @var Doctor $doctor */
+ foreach ($this->em->getRepository(Doctor::class)->findAll() as $doctor) {
+ if (!DisplayName::isPlaceholder($doctor->getName())) {
+ continue;
+ }
+
+ $appointments = (int) $this->em->createQuery(
+ 'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a WHERE a.doctor = :d'
+ )->setParameter('d', $doctor)->getSingleScalarResult();
+
+ $rows[] = [
+ 'entity' => $doctor,
+ 'hasRelations' => $appointments > 0,
+ 'published' => $doctor->isActiveDoctorAppointment(),
+ ];
+ $table[] = [
+ $doctor->getId(),
+ $doctor->getName(),
+ $doctor->getOwnerStatus(),
+ $appointments,
+ $doctor->isActiveDoctorAppointment() ? 'بله' : 'خیر',
+ ];
+ }
+
+ if ($table !== []) {
+ $io->section(sprintf('پزشکان با نام نامعتبر (%d)', count($table)));
+ $io->table(['id', 'name', 'owner_status', 'نوبتها', 'منتشر شده'], $table);
+ }
+
+ return $rows;
+ }
+
+ /** @return array */
+ private function auditClinics(SymfonyStyle $io): array
+ {
+ $rows = [];
+ $table = [];
+
+ /** @var Clinic $clinic */
+ foreach ($this->em->getRepository(Clinic::class)->findAll() as $clinic) {
+ if (!DisplayName::isPlaceholder($clinic->getName())) {
+ continue;
+ }
+
+ $doctorCount = $clinic->getDoctors()->count();
+
+ $rows[] = [
+ 'entity' => $clinic,
+ 'hasRelations' => $doctorCount > 0,
+ 'published' => $clinic->isActive(),
+ ];
+ $table[] = [
+ $clinic->getId(),
+ $clinic->getName(),
+ $doctorCount,
+ $clinic->isActive() ? 'بله' : 'خیر',
+ ];
+ }
+
+ if ($table !== []) {
+ $io->section(sprintf('کلینیکها با نام نامعتبر (%d)', count($table)));
+ $io->table(['id', 'name', 'پزشکان', 'فعال'], $table);
+ }
+
+ return $rows;
+ }
+}
diff --git a/src/Shared/Util/DisplayName.php b/src/Shared/Util/DisplayName.php
new file mode 100644
index 00000000..471fda85
--- /dev/null
+++ b/src/Shared/Util/DisplayName.php
@@ -0,0 +1,51 @@
+ صفحات سایت رندر میشود.
+ * شمارهتلفن یا «test» نام واقعی نیست و صفحه را بیارزش میکند.
+ *
+ * این قواعد باید با nobat724_front/lib/entityQuality.js یکی بماند؛ سایت عمومی
+ * همین رکوردها را noindex میکند. آنجا ماسک است، اینجا جلوگیری از تولید.
+ */
+final class DisplayName
+{
+ private const PHONE_LIKE = '/^0?9\d{9}$/';
+
+ private const PLACEHOLDERS = ['test', 'تست', '-', '—', 'null', 'undefined'];
+
+ private const MIN_LENGTH = 2;
+
+ /** نامی که نباید در نتایج عمومی منتشر شود. */
+ public static function isPlaceholder(?string $name): bool
+ {
+ $trimmed = trim((string) $name);
+ if ($trimmed === '' || mb_strlen($trimmed) < self::MIN_LENGTH) {
+ return true;
+ }
+
+ if (preg_match(self::PHONE_LIKE, str_replace([' ', '-', ''], '', $trimmed)) === 1) {
+ return true;
+ }
+
+ return in_array(mb_strtolower($trimmed), self::PLACEHOLDERS, true);
+ }
+
+ /**
+ * @throws AppException وقتی نام واقعی نباشد — ExceptionSubscriber آن را به 422 تبدیل میکند
+ */
+ public static function assertReal(?string $name): void
+ {
+ if (self::isPlaceholder($name)) {
+ throw new AppException(
+ ErrorCodes::ERR_VALIDATION_001,
+ 'نام معتبر نیست؛ شماره تلفن یا مقدار آزمایشی بهعنوان نام پذیرفته نمیشود',
+ 422
+ );
+ }
+ }
+}
diff --git a/tests/Doctor/PollutedNameRejectionTest.php b/tests/Doctor/PollutedNameRejectionTest.php
new file mode 100644
index 00000000..3d4acd19
--- /dev/null
+++ b/tests/Doctor/PollutedNameRejectionTest.php
@@ -0,0 +1,115 @@
+ and search results, so a
+ * phone number or "test" must never be storable. The guard lives on the entity
+ * because eight different call sites construct a Doctor.
+ */
+class PollutedNameRejectionTest extends ApiTestCase
+{
+ public function testDoctorCannotBeCreatedWithPhoneNumberAsName(): void
+ {
+ $this->expectException(AppException::class);
+ new Doctor($this->createUser(['ROLE_DOCTOR']), '09390039833');
+ }
+
+ public function testDoctorCannotBeRenamedToPlaceholder(): void
+ {
+ $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر علی احمدی');
+
+ $this->expectException(AppException::class);
+ $doctor->setName('test');
+ }
+
+ public function testClinicCannotBeNamedAfterPhoneNumber(): void
+ {
+ $clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
+
+ $this->expectException(AppException::class);
+ $clinic->setName('09398631203');
+ }
+
+ public function testClinicNameMayStayNullBeforeItIsSet(): void
+ {
+ $clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
+ $clinic->setName(null);
+
+ $this->assertNull($clinic->getName());
+ }
+
+ public function testAdminCreatingDoctorWithPhoneNameGets422(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $this->em->flush();
+
+ $this->authJson('POST', '/api/v1/admin/doctors', $admin, [
+ 'name' => '09390039833',
+ 'mobile_number' => '0912' . random_int(1000000, 9999999),
+ ]);
+
+ $this->assertSame(422, $this->responseCode());
+ }
+
+ /**
+ * ریشهٔ آلودگی تولیدی: دعوت پزشک فقط با موبایل، شماره را بهعنوان نام مینشاند.
+ */
+ public function testInvitingDoctorByMobileDoesNotUseMobileAsName(): void
+ {
+ $owner = $this->createUser(['ROLE_CLINIC']);
+ $clinic = new Clinic($owner);
+ $this->em->persist($clinic);
+ $this->em->flush();
+
+ $mobile = '0912' . random_int(1000000, 9999999);
+ $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
+ 'mobile' => $mobile,
+ ]);
+ $this->assertSame(201, $this->responseCode());
+
+ $invitation = $this->em->getRepository(ClinicDoctorInvitation::class)
+ ->findOneBy(['mobile' => $mobile]);
+ $this->assertNotNull($invitation, 'invitation was not created');
+
+ $this->client->request('POST', "/api/v1/clinic-invitation/{$invitation->getToken()}/accept");
+ $this->assertSame(200, $this->responseCode());
+
+ $this->em->clear();
+ $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
+
+ $this->assertNotNull($doctor, 'invited doctor was not created');
+ $this->assertNotSame($mobile, $doctor->getName(), 'mobile number leaked into the doctor name');
+ $this->assertSame('پزشک دعوتشده', $doctor->getName());
+ }
+
+ public function testInvitedNameIsUsedWhenTheClinicProvidesOne(): void
+ {
+ $owner = $this->createUser(['ROLE_CLINIC']);
+ $clinic = new Clinic($owner);
+ $this->em->persist($clinic);
+ $this->em->flush();
+
+ $mobile = '0912' . random_int(1000000, 9999999);
+ $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
+ 'mobile' => $mobile,
+ 'name' => 'دکتر مریم رضایی',
+ ]);
+ $this->assertSame(201, $this->responseCode());
+
+ $invitation = $this->em->getRepository(ClinicDoctorInvitation::class)
+ ->findOneBy(['mobile' => $mobile]);
+ $this->client->request('POST', "/api/v1/clinic-invitation/{$invitation->getToken()}/accept");
+ $this->assertSame(200, $this->responseCode());
+
+ $this->em->clear();
+ $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
+ $this->assertSame('دکتر مریم رضایی', $doctor->getName());
+ }
+}
diff --git a/tests/Shared/DisplayNameTest.php b/tests/Shared/DisplayNameTest.php
new file mode 100644
index 00000000..b32a9d8f
--- /dev/null
+++ b/tests/Shared/DisplayNameTest.php
@@ -0,0 +1,83 @@
+ */
+ public static function pollutedNames(): array
+ {
+ return [
+ 'mobile' => ['09390039833'],
+ 'mobile without leading' => ['9390039833'],
+ 'mobile with spaces' => ['0939 003 9833'],
+ 'mobile with dashes' => ['0939-003-9833'],
+ 'test latin' => ['test'],
+ 'test latin uppercase' => ['TEST'],
+ 'test persian' => ['تست'],
+ 'dash' => ['-'],
+ 'empty' => [''],
+ 'whitespace only' => [' '],
+ 'single char' => ['a'],
+ 'literal null' => ['null'],
+ ];
+ }
+
+ #[DataProvider('pollutedNames')]
+ public function testPollutedNamesAreRejected(string $name): void
+ {
+ // «{$name}» — بدون آکولاد، PHP کاراکتر » را جزو نام متغیر میگیرد
+ $this->assertTrue(DisplayName::isPlaceholder($name), "«{$name}» باید نامعتبر باشد");
+
+ $this->expectException(AppException::class);
+ DisplayName::assertReal($name);
+ }
+
+ /** @return array */
+ public static function realNames(): array
+ {
+ return [
+ 'persian full name' => ['سیده مهدیه کشاورز'],
+ 'with title' => ['دکتر علی احمدی'],
+ 'clinic name' => ['کلینیک تخصصی امید تبریز'],
+ 'short persian' => ['رضا'],
+ 'latin name' => ['John Smith'],
+ // شمارهای که الگوی موبایل ایران نیست، نام عجیبی است ولی سانسور نمیشود
+ 'landline' => ['02191550875'],
+ ];
+ }
+
+ #[DataProvider('realNames')]
+ public function testRealNamesPass(string $name): void
+ {
+ $this->assertFalse(DisplayName::isPlaceholder($name), "«{$name}» باید معتبر باشد");
+
+ DisplayName::assertReal($name);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testNullIsTreatedAsPlaceholder(): void
+ {
+ $this->assertTrue(DisplayName::isPlaceholder(null));
+ }
+
+ public function testRejectionCarries422(): void
+ {
+ try {
+ DisplayName::assertReal('09390039833');
+ $this->fail('expected AppException');
+ } catch (AppException $e) {
+ $this->assertSame(422, $e->getHttpStatus());
+ $this->assertStringContainsString('نام معتبر نیست', $e->getMessage());
+ }
+ }
+}