feat(representation): let registering reps edit their doctors and clinics

A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.

Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.

ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.

Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.

Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.

Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.

Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-08 15:50:17 +03:30
co-authored by Claude Opus 5
parent d74a351e5a
commit fb1cb20c11
16 changed files with 2106 additions and 68 deletions
@@ -0,0 +1,510 @@
# مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبت‌کننده
## پروژه
`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()`) تا اجرای دوم هم سبز بماند.