# مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبت‌کننده

## پروژه

`clinicpro` — بک‌اند Symfony و پنل ادمین React.

تک‌ریپو است. `nobat724_front` فقط سه فراخوانی داشبورد نماینده دارد
(`services/response.js` خطوط ۲۰۲ تا ۲۱۱) و به هیچ‌کدام از اندپوینت‌های این تسک دست نمی‌زند.

## زمینه

نماینده امروز می‌تواند پزشک و کلینیک بسازد. هنگام ساخت،
`representation_id` روی رکورد ست می‌شود:

```php
// src/Representation/Controller/RepresentationActionController.php:299
$rep = $this->representationRepo->findByUser($user);
if ($rep !== null) {
    $doctor->setRepresentationId($rep->getId());
}
```

ولی بعد از ساخت، هیچ راهی برای کامل کردن پروفایل ندارد.
نه لگو، نه گالری، نه متن معرفی، نه تخصص، نه آدرس.
عملاً onboarding نیمه‌کاره می‌ماند و پزشک تازه‌ساخته روی سایت عمومی
یک رکورد خالی است.

## مشکل / هدف

نماینده باید روی هر پزشک و کلینیکی که `representation_id` آن به او اشاره دارد،
فیلدهای **محتوایی و ظاهری** را ویرایش کند — و فقط همان‌ها.

مجوز **دائمی** است و به `representation_id` گره می‌خورد.
هیچ فیلد جدید، هیچ migration، هیچ state تازه‌ای لازم نیست.

## معیار پذیرش

- ✅ موفق: نماینده‌ای که پزشک X را ساخته،
  `PATCH /api/v1/doctor/{X.uuid}` با بدنهٔ `{"info": "متن جدید", "images": [...]}` می‌فرستد
  → `200` و رکورد ذخیره می‌شود.
  همین برای `PATCH /api/v1/clinic/{uuid}` با `{"clinic_logo": "...", "info": "..."}`.
- ✅ موفق: `GET /api/v1/doctor/{X.uuid}` با توکن همان نماینده → `can_edit: true`.
  همان درخواست با توکن نمایندهٔ دیگر → `can_edit: false`.
  بدون توکن → `can_edit: false` و بقیهٔ پاسخ مثل قبل.
- ❌ خطا: نمایندهٔ **دیگری** (که این پزشک را نساخته) همان PATCH را بفرستد
  → `403` با `ERR_AUTH_006`.
- ❌ خطا: نمایندهٔ مالک، کلید ممنوع بفرستد
  (`medical_system_code` یا `active` برای پزشک، `doctors` برای کلینیک)
  → `403` با نام همان فیلد در `errors[0].field`. **هیچ چیزی ذخیره نمی‌شود.**
- ⚠️ مرزی: کاربری با `ROLE_REPRESENTATION` که ردیف `Representation` ندارد
  → `403`، نه `500`.
- ⚠️ مرزی: پزشکی که `representation_id` آن `null` است
  → هیچ نماینده‌ای اجازه ندارد؛ `403`.
- ⚠️ مرزی: خودِ پزشک و مالک کلینیک و ادمین **دقیقاً مثل قبل** رفتار می‌کنند —
  whitelist روی آن‌ها اعمال نمی‌شود و همچنان می‌توانند `medical_system_code`
  و `doctors` را عوض کنند. این تسک هیچ دسترسی موجودی را تنگ نمی‌کند.
- ⚠️ مرزی: هر PATCH موفقِ نماینده دقیقاً یک ردیف `AppLog` می‌سازد.
  PATCH مالک یا ادمین هیچ ردیفی نمی‌سازد.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Representation/Security/RepresentationEditPolicy.php` | **جدید** — مالکیت و whitelist |
| `src/Doctor/Controller/DoctorController.php` | `update`، `show`، سه اکشن آدرس پزشک |
| `src/Clinic/Controller/ClinicController.php` | `update`، `show`، سه اکشن آدرس کلینیک |
| `src/Representation/Repository/RepresentationRepository.php` | `findByUser()` موجود است |
| `src/Shared/Logging/AppLog.php` | entity لاگ موجود |
| `assets/admin/pages/DoctorDetailPage.tsx` | `isReadOnly` خط ۱۱۲۵ |
| `assets/admin/pages/ClinicDetailPage.tsx` | `isReadOnly` خط ۳۶۴ |
| `docs/api/doctor.md` · `docs/api/clinic.md` | سند اندپوینت‌ها |

## وضعیت فعلی

### دروازهٔ پزشک — فقط خود پزشک یا ادمین

```php
// src/Doctor/Controller/DoctorController.php:340
#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
    $doctor = $this->doctorRepo->findByUuid($uuid);
    if ($doctor === null) {
        return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
    }

    if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
        return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
    }

    $data = json_decode($request->getContent(), true) ?? [];
    if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));

    $this->hydrateDoctor($doctor, $data);
    $this->doctorRepo->save($doctor);

    return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
}
```

### دروازهٔ کلینیک — از checker موجود رد می‌شود

```php
// src/Clinic/Controller/ClinicController.php:223
#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
    $this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');

    $clinic = $this->clinicRepo->findByUuid($uuid);
    if ($clinic === null) {
        return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
    }

    // مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
    if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
        return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
    }

    $data = json_decode($request->getContent(), true) ?? [];
    if (($err = $this->validateGallerySize($data)) !== null) {
        return $err;
    }
    $this->hydrateClinic($clinic, $data);
    $this->clinicRepo->save($clinic);

    [$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);

    return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
}
```

`ClinicDoctorPermissionChecker::can()` نقش نماینده را نمی‌شناسد:

```php
// src/Clinic/Security/ClinicDoctorPermissionChecker.php:44
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
{
    if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
        return true;
    }

    $doctor = $this->doctorRepo->findByUser($user);
    if ($doctor === null || !$clinic->hasDoctor($doctor)) {
        return false;
    }

    return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
}
```

### آدرس‌ها — دروازهٔ جدا

```php
// src/Doctor/Controller/DoctorController.php:656 — PATCH آدرس پزشک
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
    return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```

```php
// src/Clinic/Controller/ClinicController.php:766 — PATCH آدرس کلینیک (و DELETE، خط ۷۹۱)
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
    return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```

```php
// src/Doctor/Controller/DoctorController.php:542 — POST آدرس پزشک
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) {
    return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403);
}
```

### UI — عمداً قفل است

```tsx
// assets/admin/pages/DoctorDetailPage.tsx:1124
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
```

```tsx
// assets/admin/pages/ClinicDetailPage.tsx:363
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly  = primaryRole === 'representation';
```

### GET جزئیات — هنوز کاربر جاری را نمی‌گیرد

```php
// src/Doctor/Controller/DoctorController.php:150
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
```

```php
// src/Clinic/Controller/ClinicController.php:159
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
```

هر دو عمومی‌اند و `#[CurrentUser]` ندارند.

## وظایف

### ۱. کلاس سیاست — `RepresentationEditPolicy`

فایل جدید: `src/Representation/Security/RepresentationEditPolicy.php`

تنها تصمیم‌گیرندهٔ «این نماینده روی این رکورد چه اجازه‌ای دارد».
هیچ controllerی نباید `representation_id` را دستی مقایسه کند.

```php
<?php

namespace App\Representation\Security;

use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Representation\Repository\RepresentationRepository;

/**
 * «نمایندهٔ ثبت‌کننده روی پروفایلی که خودش ساخته چه اجازه‌ای دارد؟»
 *
 * مجوز دائمی است و تنها به representation_id گره می‌خورد — نماینده تا وقتی
 * رکورد به او اشاره می‌کند مالکِ محتوای آن است. عمداً فقط فیلدهای محتوایی
 * باز است: عضویت پزشکان در کلینیک و کد نظام پزشکی و فعال/غیرفعال بودن،
 * تصمیم‌های صاحبِ رکوردند نه فروشنده‌ای که او را ثبت کرده.
 */
class RepresentationEditPolicy
{
    /** فیلدهایی که نماینده روی پروفایل پزشک می‌تواند بفرستد. */
    public const DOCTOR_FIELDS = [
        'title', 'gender', 'degree', 'info', 'detail',
        'mobile_number', 'activity_time',
        'images', 'image_data', 'social_media',
        'specialties', 'doctor_services', 'expertise',
        'states', 'cities',
    ];

    /** فیلدهایی که نماینده روی پروفایل کلینیک می‌تواند بفرستد. */
    public const CLINIC_FIELDS = [
        'name', 'info', 'address', 'telephone',
        'working_days', '24_7', 'latitude', 'longitude',
        'practice_domain_uuid', 'state', 'city',
        'social_media', 'image_clinic', 'clinic_logo',
    ];

    public function __construct(
        private readonly RepresentationRepository $repRepo,
    ) {}

    public function ownsDoctor(User $user, Doctor $doctor): bool
    {
        return $this->matches($user, $doctor->getRepresentationId());
    }

    public function ownsClinic(User $user, Clinic $clinic): bool
    {
        return $this->matches($user, $clinic->getRepresentationId());
    }

    /**
     * اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند.
     *
     * @param list<string> $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS
     */
    public function firstForbiddenField(array $data, array $allowed): ?string
    {
        foreach (array_keys($data) as $key) {
            if (!in_array($key, $allowed, true)) {
                return (string) $key;
            }
        }
        return null;
    }

    private function matches(User $user, ?int $representationId): bool
    {
        if ($representationId === null || !$user->hasRole('ROLE_REPRESENTATION')) {
            return false;
        }

        $rep = $this->repRepo->findByUser($user);

        return $rep !== null && $rep->getId() === $representationId;
    }
}
```

نکته‌های اجباری:

- `matches()` وقتی `ROLE_REPRESENTATION` نیست، **بدون کوئری** برمی‌گردد.
  این همان حالت مرزی «کاربر با نقش نماینده ولی بدون ردیف Representation» را هم
  به `false` می‌بندد، نه به exception.
- کلاس هیچ HTTP نمی‌شناسد. پاسخ ۴۰۳ کارِ controller است.

**نحوه تست:** unit test خالص با entityهای ساختگی —
`tests/Representation/RepresentationEditPolicyTest.php`.
سناریوها: مالکِ درست `true`؛ نمایندهٔ دیگر `false`؛ `representation_id === null` → `false`؛
کاربر بدون `ROLE_REPRESENTATION` → `false`؛ `firstForbiddenField` روی
`['info' => 'x', 'doctors' => []]` با `CLINIC_FIELDS` باید `'doctors'` بدهد و
روی `['info' => 'x']` باید `null` بدهد.

### ۲. لاگ‌کردن ویرایشِ نماینده

سرویس کوچک کنار سیاست: `src/Representation/Security/RepresentationEditLogger.php`

از entity موجود `App\Shared\Logging\AppLog` استفاده کن — هیچ جدول جدیدی نساز.
سازندهٔ آن `(level, message, context, channel, path)` می‌گیرد.

```php
$this->em->persist(new AppLog(
    'info',
    sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $entityType, $uuid),
    json_encode(['representation_id' => $repId, 'fields' => array_keys($data)], JSON_UNESCAPED_UNICODE),
    'representation_edit',
    $request->getPathInfo(),
));
```

فقط وقتی لاگ بنویس که ویرایش‌کننده **نماینده** باشد.
مالک و ادمین هیچ ردیفی نمی‌سازند — وگرنه `/admin/logs` پر از نویز می‌شود.

**نحوه تست:** بعد از یک PATCH موفقِ نماینده،
`SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'` باید یکی زیاد شده باشد.
بعد از PATCH ادمین روی همان رکورد، عددی تغییر نکند.

### ۳. باز کردن `PATCH /api/v1/doctor/{uuid}`

در `DoctorController::update` شرط ۴۰۳ فعلی را نگه دار و یک شاخهٔ نماینده کنارش بگذار.
ترتیب مهم است: اول مالکیت، بعد whitelist، بعد hydrate.

```php
$isOwnerOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
$isRepOwner     = !$isOwnerOrAdmin && $this->editPolicy->ownsDoctor($user, $doctor);

if (!$isOwnerOrAdmin && !$isRepOwner) {
    return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}

$data = json_decode($request->getContent(), true) ?? [];

if ($isRepOwner) {
    $bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::DOCTOR_FIELDS);
    if ($bad !== null) {
        return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad);
    }
}
```

whitelist **فقط** روی `$isRepOwner` اجرا می‌شود. مسیر مالک و ادمین دست‌نخورده می‌ماند.

**نحوه تست:** تست فانکشنال با `ApiTestCase` —
`tests/Representation/RepresentationProfileEditTest.php`.
یک نماینده و پزشکش بساز (از `POST /api/v1/representation/doctor` استفاده کن، نه fixture دستی)،
بعد PATCH با `info` بزن و `200` بگیر، بعد PATCH با `medical_system_code` بزن و `403` بگیر
و مطمئن شو مقدار قبلی در دیتابیس عوض نشده.

### ۴. باز کردن `PATCH /api/v1/clinic/{uuid}`

همان الگو. ولی اینجا `permChecker` جلوی راه است.

**آن را تغییر نده.** `ClinicDoctorPermissionChecker` دربارهٔ عضویت پزشک در کلینیک است
و نماینده اصلاً پزشکِ عضو نیست؛ اضافه‌کردن نقش نماینده به آن، مسئولیتِ کلاس را دوتا می‌کند.
به‌جایش در controller کنارش بگذار:

```php
$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic);

if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
    return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```

حواست به خط ۲۳۰ باشد:

```php
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
```

این پیش‌چکِ منشی است و پیش از واکشی رکورد اجرا می‌شود.
بررسی کن که برای کاربرِ `ROLE_REPRESENTATION` (که منشی نیست) throw نکند.
اگر throw می‌کند، شرطش را طوری بگذار که فقط برای نقش منشی اجرا شود — و در تست ثابتش کن.

whitelist با `RepresentationEditPolicy::CLINIC_FIELDS`.

**نحوه تست:** نماینده و کلینیکش را با `POST /api/v1/representation/clinic` بساز.
`PATCH` با `{"clinic_logo": "https://x/y.png", "info": "..."}` → `200`.
`PATCH` با `{"doctors": [1]}` → `403` و `errors[0].field === 'doctors'`.
یک نمایندهٔ دوم بساز و همان PATCH را بزن → `403`.

### ۵. آدرس‌ها — شش اکشن

همان سیاست را در این‌ها هم صدا بزن:

- `POST /api/v1/clinic-pro/doctor-address` — نماینده باید `doctor_uuid` بفرستد،
  دقیقاً مثل مسیر ادمین در خط ۵۵۲. مالکیت همان پزشک بررسی شود.
- `PATCH /api/v1/clinic-pro/doctor-address/{id}`
- `DELETE /api/v1/clinic-pro/doctor-address/{id}`
- `POST /api/v1/clinic/{clinicUuid}/address`
- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`

روی آدرس‌ها whitelist لازم نیست — کل رکورد آدرس محتوایی است.
شرط `TYPE_PERSONAL` در خط ۶۵۲ باید سر جایش بماند؛ نماینده هم نباید
آدرس کلینیک را از مسیر آدرسِ پزشک عوض کند.

**نحوه تست:** نماینده برای پزشکش آدرس بسازد، ویرایش کند، حذف کند — هر سه `200`.
نمایندهٔ دوم روی همان آدرس `403` بگیرد.

### ۶. فلگ `can_edit` در پاسخ GET جزئیات

هر دو `show` را طوری عوض کن که کاربر جاری را اختیاری بگیرند:

```php
public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse
```

و در آرایهٔ خروجی:

```php
'can_edit' => $user !== null && (
    $doctor->getUser()->getId() === $user->getId()
    || $user->hasRole('ROLE_ADMIN')
    || $this->editPolicy->ownsDoctor($user, $doctor)
),
```

برای کلینیک همان با `permChecker->can(...) || editPolicy->ownsClinic(...)`.

هر دو اندپوینت عمومی‌اند. بدون توکن باید `can_edit: false` بدهند و
هیچ بخش دیگری از پاسخ عوض نشود — سایت عمومی همین‌ها را مصرف می‌کند.

**نحوه تست:** سه بار `GET /api/v1/doctor/{uuid}` — بدون توکن، با توکن نمایندهٔ مالک،
با توکن نمایندهٔ دیگر. مقادیر `false` و `true` و `false`.

### ۷. باز کردن UI پنل

در `DoctorDetailPage.tsx` و `ClinicDetailPage.tsx` این خط را بردار:

```tsx
const isReadOnly = primaryRole === 'representation';
```

و جایش از پاسخ سرور بخوان:

```tsx
const isReadOnly = primaryRole === 'representation' && !doctor?.can_edit;
```

هیچ منطق مجوزی را در فرانت بازنویسی نکن. `can_edit` تنها منبع حقیقت است.

فیلدهای بیرون از whitelist باید برای نماینده در فرم **مخفی یا disabled** باشند،
نه اینکه ارسال شوند و ۴۰۳ بگیرند:

- پزشک: کد نظام پزشکی، و کلید فعال/غیرفعال
- کلینیک: مدیریت پزشکان کلینیک

نماینده همچنان می‌تواند پزشک را فعال/غیرفعال کند، ولی از اندپوینت اختصاصی خودش:
`POST /api/v1/representation/doctors/{uuid}/status`.
اگر آن دکمه در صفحه هست، به همان اندپوینت وصلش کن نه به `PATCH`.

تایپ‌ها را در `assets/admin/types/index.ts` به‌روز کن: `can_edit?: boolean`.

**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` سبز،
`ddev exec yarn dev` بدون خطا، و ورود دستی با یک کاربر نماینده در
`https://clinic-pro.ddev.site/admin/doctors/<uuid>` — دکمهٔ ویرایش و آپلود لگو دیده شود
و کد نظام پزشکی دیده نشود.

### ۸. مستندات

- `docs/api/doctor.md` — `PATCH /api/v1/doctor/{uuid}`: نقش نماینده، فهرست فیلدهای مجاز،
  و ۴۰۳ فیلد ممنوع. `GET`: فیلد `can_edit`.
- `docs/api/clinic.md` — همان برای کلینیک.
- اندپوینت‌های آدرس در هر دو سند.
- JSON نمونه باید **خروجی اجرای واقعی** باشد، نه دست‌ساز.

## نکات مهم

- **الگو: Policy Object.** یک کلاس، یک سؤال: «این نماینده چه اجازه‌ای دارد».
  دلیل انتخاب: منطق مجوز الان در هشت اکشن تکرار می‌شود؛ اگر inline بنویسی،
  فردا که قاعده عوض شود هشت جا باید عوض شود و یکی جا می‌ماند.
  `ClinicDoctorPermissionChecker` را گسترش نده — آن دربارهٔ عضویت پزشک در کلینیک است،
  و پزشکِ نماینده اصلاً عضو نیست.
- **این تسک هیچ دسترسی موجودی را تنگ نمی‌کند.** فقط باز می‌کند.
  اگر تستی از رفتار پزشک یا مالک یا ادمین شکست، یعنی whitelist اشتباه به آن مسیر هم خورده.
- **`active` عمداً بیرون است.** نماینده اندپوینت اختصاصی دارد:
  `POST /api/v1/representation/doctors/{uuid}/status` (خط ۶۳۲ همان controller).
  دو مسیر برای یک کار نساز.
- **اندپوینت‌های آپلود دست‌نخورده می‌مانند.**
  `/file/upload/clinic_pro/doctor/field_image` و `.../clinic/field_clinic_logo`
  الان هم برای هر کاربر لاگین‌شده بازند و فقط URL برمی‌گردانند؛
  دروازهٔ واقعی همان PATCH است که URL را ذخیره می‌کند.
- **کلید ممنوع = ۴۰۳، نه حذف بی‌صدا.** حذف بی‌صدا یعنی نماینده فکر می‌کند ذخیره شده
  و تا مدت‌ها کسی نمی‌فهمد. `$this->error(..., 403, $fieldName)` امضای فیلد را هم می‌گیرد.
- **`مالکیت` را از `representation_id` بخوان، نه از نقش.**
  هر کاربری می‌تواند `ROLE_REPRESENTATION` داشته باشد؛ آنچه مهم است اینکه
  رکورد `Representation` او همان id باشد که روی پزشک/کلینیک نشسته.
- **قرارداد API عوض نمی‌شود، فقط گسترده می‌شود.** `can_edit` فیلد جدید و اختیاری است.
  ولی `GET /api/v1/doctor/{uuid}` و `GET /api/v1/clinic/{uuid}` را
  `nobat724_front` هم مصرف می‌کند؛ بعد از تغییر، صفحهٔ پزشک و کلینیک سایت عمومی را
  دستی باز کن و مطمئن شو چیزی نشکسته. build آن‌ها خطا نمی‌دهد.
- تست‌ها زیر `tests/Representation/` بروند. دیتابیس تست هرگز reset نمی‌شود،
  پس دادهٔ هر تست را با مقدار یکتا بساز (`uniqid()`) تا اجرای دوم هم سبز بماند.
