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; + // شمارهٔ موبایل نامِ پزشک نیست. دعوت بدون نام قبلاً موبایل را به‌عنوان نام + // می‌نشاند و همان رکورد در نتایج عمومی و <title> صفحات سایت منتشر می‌شد. + $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 @@ +<?php + +namespace App\Shared\Command; + +use App\Clinic\Entity\Clinic; +use App\Doctor\Entity\Doctor; +use App\Shared\Util\DisplayName; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * پزشکان/کلینیک‌هایی که نام واقعی ندارند (شماره‌تلفن، «test») در نتایج عمومی و در + * <title> صفحات سایت منتشر می‌شوند. این فرمان آن‌ها را پیدا و دسته‌بندی می‌کند. + * + * گزارش‌محور است: بدون --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<int, array{entity: Doctor, hasRelations: bool, published: bool}> */ + 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<int, array{entity: Clinic, hasRelations: bool, published: bool}> */ + 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 @@ +<?php + +namespace App\Shared\Util; + +use App\Shared\Constant\ErrorCodes; +use App\Shared\Exception\AppException; + +/** + * نام نمایشی پزشک/کلینیک در نتایج عمومی و در <title> صفحات سایت رندر می‌شود. + * شماره‌تلفن یا «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 @@ +<?php + +namespace App\Tests\Doctor; + +use App\Clinic\Entity\Clinic; +use App\ClinicInvitation\Entity\ClinicDoctorInvitation; +use App\Doctor\Entity\Doctor; +use App\Shared\Exception\AppException; +use App\Tests\ApiTestCase; + +/** + * A doctor/clinic name reaches the public site's <title> 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 @@ +<?php + +namespace App\Tests\Shared; + +use App\Shared\Exception\AppException; +use App\Shared\Util\DisplayName; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * These rules must stay in sync with nobat724_front/lib/entityQuality.js — the + * public site noindexes exactly the records this class refuses to create. + */ +class DisplayNameTest extends TestCase +{ + /** @return array<string, array{string}> */ + 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<string, array{string}> */ + 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()); + } + } +}