# مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبت‌کننده ## پروژه `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 matches($user, $doctor->getRepresentationId()); } public function ownsClinic(User $user, Clinic $clinic): bool { return $this->matches($user, $clinic->getRepresentationId()); } /** * اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند. * * @param list $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/` — دکمهٔ ویرایش و آپلود لگو دیده شود و کد نظام پزشکی دیده نشود. ### ۸. مستندات - `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()`) تا اجرای دوم هم سبز بماند.