feat(validation): enforce naming rules for doctors and clinics to prevent placeholders

This commit is contained in:
hamed
2026-07-19 08:38:11 +03:30
parent 44b89d7add
commit d780b5cbb6
9 changed files with 461 additions and 3 deletions
+15
View File
@@ -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 | نام کلینیک شماره‌تلفن یا مقدار آزمایشی است |
---
+16
View File
@@ -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`).
دلیل: نام پزشک در `<title>` و نتایج جست‌وجوی سایت عمومی رندر می‌شود؛ رکوردی با نام «09390039833» یک صفحهٔ بی‌ارزش ایندکس‌شدنی می‌سازد.
> دعوت پزشک توسط کلینیک، اگر نام ارسال نشود، دیگر شمارهٔ موبایل را به‌عنوان نام نمی‌نشاند — برچسب خنثای «پزشک دعوت‌شده» می‌گیرد تا خود پزشک پروفایلش را claim کند. (ریشهٔ آلودگی تولیدی همین بود.)
فرمان ممیزی رکوردهای موجود:
```bash
php bin/console app:audit-polluted-records # فقط گزارش
php bin/console app:audit-polluted-records --force # خارج‌کردن از انتشار (بدون حذف)
```
### `city` / `state` در پاسخ لیست
آرایه با حداکثر یک عضو — هم‌شکل با `city`/`state` در پاسخ جزئیات پزشک و پاسخ لیست کلینیک‌ها.
+3 -1
View File
@@ -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; }
@@ -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;
}
/**
* برای کاربری که هنوز رمز عبور ندارد یک رمز تولید می‌کند تا بتواند وارد شود.
* رمز کاربران موجود هرگز بازنویسی نمی‌شود.
+4
View File
@@ -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;
}
@@ -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;
}
}
+51
View File
@@ -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
);
}
}
}
+115
View File
@@ -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());
}
}
+83
View File
@@ -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());
}
}
}