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:
@@ -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()`) تا اجرای دوم هم سبز بماند.
|
||||
Reference in New Issue
Block a user